// The "claude-code" harness plug-in (row 65, REQ-PLUG-2, Q156): a managed session on the host's // Claude Code CLI, MOSAIC_CLAUDE_BIN when set (the bus unit pins it), else `claude` on PATH. The // tool gate is claude-gate.mjs behind the PreToolUse hook and the typed tools are mcp-server.mjs; // both stay in packages/harness (a module can't change that boundary). The launcher, the bundle // and the usage reader call these on the host; the runner gets the answers as data in // session.json. packages/plugins/README.md lists the interface. import { spawnSync } from "node:child_process"; import { accessSync, constants, realpathSync, statSync } from "node:fs"; import { delimiter, isAbsolute, join } from "node:path"; import { fileURLToPath } from "node:url"; import { claudeBuiltins, MCP_PREFIX } from "../../../harness/src/gate.mjs"; import { hasSessionCredential, SESSION_CREDENTIALS } from "../../../seat/src/session.mjs"; export const interfaceVersion = 1; const REPO = fileURLToPath(new URL("../../../../", import.meta.url)); const GATE = fileURLToPath(new URL("../../../harness/src/claude-gate.mjs", import.meta.url)); const MCP_SERVER = fileURLToPath(new URL("../../../harness/src/mcp-server.mjs", import.meta.url)); const STREAM = join(REPO, "adapters", "claude", "stream.mjs"); // Row 66 (#1559): the unslop check's hook script, which the Stop and UserPromptSubmit hooks run. It // logs to MOSAIC_UNSLOP_LOG, which the runner sets, and the runner tells the operator. const UNSLOP = Object.freeze({ unslopHook: join(REPO, "tools", "unslop-hook", "claude-hook.js"), unslopCheck: join(REPO, "tools", "unslop-hook", "unslop-check.js"), unslopLists: join(REPO, "tools", "unslop-hook", "lists.json"), }); // `claude --effort ` on 2.1.302 (probed 2026-10-19, agents/sage/work/queue-65/BUILD.md). // It has no off or minimal. const LEVELS = Object.freeze(["low", "medium", "high", "xhigh", "max"]); // docs/plans/2026-10-04_slice-1.md, "What S6 can rely on", lines 1-5. const RELIES_ON = Object.freeze(["S0-1 command hook deny, no --bare", "S0-2 hook wrapped as || exit 2", "S0-3 a missing gate blocks through the wrapper", "S0-4 timeout -k 2 10 || exit 2 with hook timeout 20", "S0-5 bash is limited by the tool limit only"]); // The hook: N = 10, K = 2, hook timeout 20, which leaves the margin the brief asks for above N + K. const GATE_TIMEOUT = Object.freeze({ n: 10, k: 2, hook: 20 }); export const adapter = () => join(REPO, "adapters", "claude", "adapter.sh"); export const levels = () => LEVELS; export const outputPolicy = () => "adapter"; const executable = (path) => { try { accessSync(path, constants.X_OK); return statSync(path).isFile(); } catch { return false; } }; // The binary sessions run: MOSAIC_CLAUDE_BIN, else the first `claude` on PATH, or null. function binary(env) { if (env.MOSAIC_CLAUDE_BIN) return isAbsolute(env.MOSAIC_CLAUDE_BIN) && executable(env.MOSAIC_CLAUDE_BIN) ? env.MOSAIC_CLAUDE_BIN : null; for (const dir of (env.PATH ?? "").split(delimiter)) if (dir && executable(join(dir, "claude"))) return join(dir, "claude"); return null; } // A session needs a credential from the host's environment and the binary it runs. export function preflight({ env, proxy = null }) { if (!(typeof proxy?.key === "string" && proxy.key) && !hasSessionCredential(env)) { const names = SESSION_CREDENTIALS.map((set) => set.join(" with ")).join(", "); return { code: "no-session-credential", message: `the bus host has none of ${names}; the bus unit's EnvironmentFile sets one when the bus starts` }; } if (!binary(env)) { return { code: "harness-binary-missing", // Names the variable, never its value, which is a host path (#1873). message: env.MOSAIC_CLAUDE_BIN ? "MOSAIC_CLAUDE_BIN isn't an executable file at an absolute path" : "no claude on the bus host's PATH", }; } return null; } export function orgPresets() { return [{ name: 'claude-code', label: "Claude via this machine's sign-in", description: 'Use Claude Code with the credentials available to this Console.', thinking: { levels: [...LEVELS], default: 'low' }, available: ({ env }) => { const problem = preflight({ env }); return problem ? { ok: false, reason: problem.code === 'no-session-credential' ? 'No session credential is available to the Console.' : 'Claude Code is not available to the Console.' } : { ok: true }; }, overlay: ({ thinking }) => ({ plugins: { channel: ['cli', 'webui'], harness: ['claude-code'] }, launch: { by: 'chief', instances: ['assistant'], models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } } }, assistantVars: { harness: 'claude-code', model: 'opus', thinking } }) }]; } // `claude --version` from the binary sessions run, or null when it doesn't answer. export function version({ env }) { const r = spawnSync(env.MOSAIC_CLAUDE_BIN || "claude", ["--version"], { encoding: "utf8", timeout: 10000, env }); return r.status === 0 ? r.stdout.trim().split("\n")[0] || null : null; } // Its launches spend the business's launch.account (row 126). export const countsAccount = () => true; // The pinned binary, when it lives outside /usr (which the sandbox already binds), and the unslop // hook's files. export function mounts({ env }) { const unslop = Object.values(UNSLOP).map((src) => ({ src, why: "the unslop check the session's Stop and UserPromptSubmit hooks run" })); unslop.push({ src: STREAM, why: "the adapter's private model stream reader" }); if (!env.MOSAIC_CLAUDE_BIN) return unslop; const real = realpathSync(env.MOSAIC_CLAUDE_BIN); return [...(real.startsWith("/usr/") ? [] : [{ src: real, dest: env.MOSAIC_CLAUDE_BIN, why: "the pinned Claude Code binary (MOSAIC_CLAUDE_BIN)" }]), ...unslop]; } // WebSearch and WebFetch are its own, so it serves harness-builtin web. export const builtinWeb = () => true; export const builtins = ({ tools, webBuiltin = false }) => claudeBuiltins({ tools, webBuiltin }); export const typedName = (name) => MCP_PREFIX + name; export const reliesOn = () => RELIES_ON; export const gateTimeout = () => ({ ...GATE_TIMEOUT }); // settings.json holds the gate hook and the two unslop hooks (row 66), and mcp.json the typed // tools' server. An unslop hook ends with `; exit 0`, so it never blocks a reply or a prompt. // files: the bundle's own (policy, tools). write(name, text) puts a file in the bundle directory // and returns [name, sha256]; quote(path) refuses a single quote. export function bundle({ dir, files: core, session, write, quote }) { const files = { settings: join(dir, "settings.json"), mcp: join(dir, "mcp.json") }; const node = process.execPath; const command = `timeout -k ${GATE_TIMEOUT.k} ${GATE_TIMEOUT.n} ${quote(node)} ${quote(GATE)} ${quote(core.policy)} || exit 2`; const unslop = (which) => [{ hooks: [{ type: "command", command: `timeout -k ${GATE_TIMEOUT.k} ${GATE_TIMEOUT.n} ${quote(node)} ${quote(UNSLOP.unslopHook)} ${which}; exit 0`, timeout: GATE_TIMEOUT.hook }] }]; const digests = [ write("settings.json", JSON.stringify({ hooks: { PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command, timeout: GATE_TIMEOUT.hook }] }], Stop: unslop("stop"), UserPromptSubmit: unslop("prompt"), }, }, null, 2) + "\n"), write("mcp.json", JSON.stringify({ mcpServers: { mosaic: { type: "stdio", command: node, args: [MCP_SERVER, core.tools, session.toolSocket] } }, }, null, 2) + "\n"), ]; return { files, digests, sources: { claudeGate: GATE, mcpServer: MCP_SERVER, claudeStream: STREAM, ...UNSLOP } }; } export function env({ files, policy, session, proxy = null }) { return { MOSAIC_TOOLS: claudeBuiltins(policy).join(","), MOSAIC_CLAUDE_SETTINGS: files.settings, MOSAIC_CLAUDE_MCP_CONFIG: files.mcp, MOSAIC_THINKING: session.thinking, // stream.mjs marks upstream model events; final stdout may be an API error. MOSAIC_MODEL_ACTIVITY_SOURCE: "adapter", ...(proxy ? { ANTHROPIC_BASE_URL: proxy.baseUrl, ANTHROPIC_AUTH_TOKEN: proxy.key } : {}), }; } // A turn ends when claude exits; there is no marker. export const turnEnd = () => "exit"; export const transcript = ({ runDir, store = null }) => (store ? store.projects : join(runDir, "home", ".claude", "projects")); // Row 307 (#1814): Claude Code has no pinned compaction format yet (Vikunja 466), so a collection // reads nothing and the count is unknown with harness-unsupported, never 0. export const compactions = () => ({ found: [], cursors: {}, known: false, reason: "harness-unsupported", version: null });