Slice 1 S6. packages/harness runs a role session under the pi or claude
adapter behind a tool gate, and packages/seat holds the session and its
process. The bus host gains a launch socket: `mosaic bus start
<business> --pm <harness>:<model>` runs the PM without a window, the PM
launches role sessions through the broker within the business file's
`launch.max`, and every launch, refusal and end is a launch-log line and
a broker event. `mosaic talk`, `mosaic launches off|on|list` and
`mosaic stop <run>` are new CLI verbs. packages/bus gains the IPC ops
identity, authorize, refuse, endLaunch and credentialStatus, plus
Broker.endLaunch (decision 77); no socket verb, event kind or schema
changed. The session environment is an allowlist, and the runner exits
before claiming if a founder credential reaches it.
Filbert built it (agents/filbert/work/s6). Darkwing reviewed on #1523:
round 1 changes (26991/26995), round 2 changes (27004/27010), round 3
changes (27012/27032), round 4 approve (27033/27034) on manifest
08a78972, 42 paths.
Sage's gate on 64b0f9f1 (12:32Z), sequential and teed: node suites
business 60, bus 74, cli 83, control-board 124, conversation 182,
discord 178, harness 56, ledger 78, mosaic 69, queue 149, runs 41,
seat 27, tasks 51, webui 39, every one 0 failed; test-auth 15,
test-conductor 17, test-config 24, test-discord 66,
test-extension-package 18, test-foundation 44, test-queue 27,
test-release 14, test-task 98 and 26 without Docker, all 0 failed.
64b0f9f1..ee7f909b touches none of the 42 paths. It landed after the
Q14 hold ended at 2026-10-11T17:19:23.857Z. The recorded PM-to-coder
run on a scratch data root is still owed before row 41 moves to done.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
156 lines
7.9 KiB
JavaScript
156 lines
7.9 KiB
JavaScript
// 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 <gate> || exit 2", "S0-3 a missing gate blocks through the wrapper", "S0-4 timeout -k 2 10 <gate> || 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 };
|
|
}
|