// The launch bundle: everything a managed session runs from, generated // from one resolved role instance and written once into the run // directory. prompt.md, policy.json, tools.json and manifest.json for both // harnesses; settings.json (the gate hook) and mcp.json (the typed tools) // for Claude Code. The Pi extension and the Claude gate stay at their // source paths; the manifest records their digests. // // Each harness layer the bundle relies on is a row S0 line // (docs/plans/2026-10-04_slice-1.md, "What S6 can rely on"); the manifest // names them. import { createHash } from "node:crypto"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { typedTools, untypedActions } from "./tools.mjs"; import { claudeBuiltins, MCP_PREFIX } from "./gate.mjs"; export const BUNDLE_VERSION = 1; export const HARNESSES = Object.freeze(["pi", "claude-code"]); export const SOURCES = Object.freeze({ piExtension: fileURLToPath(new URL("./pi-extension.mjs", import.meta.url)), claudeGate: fileURLToPath(new URL("./claude-gate.mjs", import.meta.url)), mcpServer: fileURLToPath(new URL("./mcp-server.mjs", import.meta.url)), gate: fileURLToPath(new URL("./gate.mjs", import.meta.url)), tools: fileURLToPath(new URL("./tools.mjs", import.meta.url)), }); // S0 lines 1-5 for both harnesses; line 6 is the Agent SDK, which S6 // doesn't use. const RELIES_ON = Object.freeze({ pi: ["S0-1 tool_call block", "S0-2 a throw blocks", "S0-3 a missing -e refuses to start", "S0-4 external wall clock plus agent_end check", "S0-5 bash is limited by the tool limit only"], "claude-code": ["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 Claude hook: N = 10, K = 2, hook timeout 20, which leaves the // margin the brief asks for above N + K. const GATE_TIMEOUT = { n: 10, k: 2, hook: 20 }; export class BundleError extends Error {} const sha256 = (text) => createHash("sha256").update(text).digest("hex"); const quote = (s) => { if (s.includes("'")) throw new BundleError(`path can't hold a single quote: ${s}`); return `'${s}'`; }; // The harness, provider and model one instance runs with. The agent-layer // `harness` and `model` variables win; otherwise the system's execution // adapter and model. Anything outside the two harnesses refuses. export function sessionModel(resolved) { const v = resolved.vars; const harness = v.harness ?? v["execution.adapter"]; if (!HARNESSES.includes(harness)) throw new BundleError(`instance ${resolved.instance}: harness ${JSON.stringify(harness)} isn't one of ${HARNESSES.join(", ")}`); const model = v.model ?? v["execution.model"]; if (typeof model !== "string" || !model) throw new BundleError(`instance ${resolved.instance}: no model`); return { harness, provider: v["execution.provider"] ?? null, model, thinking: v.thinking ?? null }; } function prompt({ contract, resolved, session, tools, untyped }) { const lines = [ contract.trimEnd(), "", "## This session", "", `- Business: ${resolved.business}. Role instance: ${resolved.instance} (definition ${resolved.definition}). Run: ${session.run}.`, `- Launched by: ${session.launchedBy}. Harness: ${session.harness}, model ${session.model}.`, `- Workspace: ${session.workspace}. File tools work inside it only.`, `- Built-in tools: ${resolved.limits.tools.length ? resolved.limits.tools.join(", ") : "none"}.`, `- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal names its reason.`, "- Each message to you arrives as one turn. Your final answer goes back to the sender as the reply, so end each turn with the answer itself.", "- You never hold a token. You act on the tracker, the forge and other roles only through the bus tools.", ]; if (untyped.length) lines.push(`- Actions you hold that have no bus tool yet: ${untyped.join(", ")}. Say so instead of working around it.`); return lines.join("\n") + "\n"; } // buildBundle({ dir, resolved, contract, arbiter, tracker, credentials, session, piVersion, claudeVersion }) // dir the bundle directory, created here (0700) // resolved resolveInstance(...) output // contract the role contract's text // arbiter true when the business names this instance as an arbiter // tracker true when the broker has task verbs for the business // credentials the broker's credentialStatus(...) for the instance (metadata only) // session { run, launchedBy, workspace, toolSocket, turnMarker } // piVersion the pinned pi version, for the manifest // claudeVersion the host's `claude --version`, for the manifest // Returns { manifest, files } with absolute paths. export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, credentials = [], session, piVersion = null, claudeVersion = null }) { const model = sessionModel(resolved); const tools = typedTools({ resolved, arbiter, tracker }); const untyped = untypedActions(resolved, tools); mkdirSync(dir, { recursive: true, mode: 0o700 }); const write = (name, text) => { writeFileSync(join(dir, name), text, { mode: 0o600, flag: "wx" }); return [name, sha256(text)]; }; const policy = { harness: model.harness, workspace: session.workspace, tools: [...resolved.limits.tools], typed: tools.map((t) => t.name), network: resolved.limits.network, needs: Object.keys(resolved.credentials ?? {}), credentials, }; const files = { prompt: join(dir, "prompt.md"), policy: join(dir, "policy.json"), tools: join(dir, "tools.json"), manifest: join(dir, "manifest.json"), }; const digests = [ write("prompt.md", prompt({ contract, resolved, session: { ...session, ...model }, tools, untyped })), write("policy.json", JSON.stringify(policy, null, 2) + "\n"), write("tools.json", JSON.stringify(tools, null, 2) + "\n"), ]; const sources = { gate: SOURCES.gate, tools: SOURCES.tools }; if (model.harness === "pi") { sources.piExtension = SOURCES.piExtension; } else { sources.claudeGate = SOURCES.claudeGate; sources.mcpServer = SOURCES.mcpServer; files.settings = join(dir, "settings.json"); files.mcp = join(dir, "mcp.json"); const node = process.execPath; const command = `timeout -k ${GATE_TIMEOUT.k} ${GATE_TIMEOUT.n} ${quote(node)} ${quote(SOURCES.claudeGate)} ${quote(files.policy)} || exit 2`; digests.push(write("settings.json", JSON.stringify({ hooks: { PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command, timeout: GATE_TIMEOUT.hook }] }] }, }, null, 2) + "\n")); digests.push(write("mcp.json", JSON.stringify({ mcpServers: { mosaic: { type: "stdio", command: node, args: [SOURCES.mcpServer, files.tools, session.toolSocket] } }, }, null, 2) + "\n")); } const manifest = { bundleVersion: BUNDLE_VERSION, business: resolved.business, instance: resolved.instance, definition: resolved.definition, run: session.run, launchedBy: session.launchedBy, resolvedDigest: resolved.digest, ...model, piVersion: model.harness === "pi" ? piVersion : null, claudeVersion: model.harness === "claude-code" ? claudeVersion : null, builtins: model.harness === "pi" ? [...resolved.limits.tools] : claudeBuiltins(policy), typed: tools.map((t) => (model.harness === "pi" ? t.name : MCP_PREFIX + t.name)), untyped, skills: [], reliesOn: RELIES_ON[model.harness], gateTimeout: model.harness === "claude-code" ? GATE_TIMEOUT : null, files: Object.fromEntries(digests), sources: Object.fromEntries(Object.entries(sources).map(([k, p]) => [k, { path: p, sha256: sha256(readFileSync(p)) }])), }; writeFileSync(files.manifest, JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600, flag: "wx" }); return { manifest, files, policy, tools }; }