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]>
91 lines
4.0 KiB
JavaScript
91 lines
4.0 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { spawn } from "node:child_process";
|
|
import { writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { createInterface } from "node:readline";
|
|
import { SOURCES } from "../src/bundle.mjs";
|
|
import { typedTools } from "../src/tools.mjs";
|
|
import { fakeToolSocket, resolvedFor, scratch } from "./helpers.mjs";
|
|
|
|
// The server over stdio: send(message) resolves with the reply to its id.
|
|
function serve(t, toolsFile, socket) {
|
|
const child = spawn(process.execPath, [SOURCES.mcpServer, toolsFile, socket], { stdio: ["pipe", "pipe", "pipe"] });
|
|
t.after(() => child.kill());
|
|
const waiting = new Map();
|
|
const orphans = [];
|
|
createInterface({ input: child.stdout }).on("line", (line) => {
|
|
const m = JSON.parse(line);
|
|
const w = waiting.get(m.id);
|
|
if (w) {
|
|
waiting.delete(m.id);
|
|
w(m);
|
|
} else orphans.push(m);
|
|
});
|
|
let next = 1;
|
|
return {
|
|
orphans,
|
|
send(method, params) {
|
|
const id = next++;
|
|
return new Promise((resolve) => {
|
|
waiting.set(id, resolve);
|
|
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
|
});
|
|
},
|
|
raw: (text) => child.stdin.write(text),
|
|
};
|
|
}
|
|
|
|
async function setup(t) {
|
|
const dir = scratch(t);
|
|
const tools = typedTools({ resolved: resolvedFor(t, "pm"), tracker: true });
|
|
const toolsFile = join(dir, "tools.json");
|
|
writeFileSync(toolsFile, JSON.stringify(tools));
|
|
const sock = await fakeToolSocket(t, dir, (tool, args) => {
|
|
if (tool === "launch") throw new Error("capacity-full");
|
|
return { tool, args };
|
|
});
|
|
return { dir, tools, sock, server: serve(t, toolsFile, sock.path) };
|
|
}
|
|
|
|
test("initialize, ping and tools/list", async (t) => {
|
|
const { tools, server } = await setup(t);
|
|
const init = await server.send("initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "x", version: "0" } });
|
|
assert.deepEqual(init.result, { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "mosaic", version: "1" } });
|
|
assert.deepEqual((await server.send("ping")).result, {});
|
|
const list = (await server.send("tools/list")).result.tools;
|
|
assert.deepEqual(list.map((x) => x.name), tools.map((x) => x.name));
|
|
const launch = list.find((x) => x.name === "launch");
|
|
assert.deepEqual(launch.inputSchema, tools.find((x) => x.name === "launch").parameters);
|
|
});
|
|
|
|
test("tools/call goes through the tool socket; a refusal is an isError result", async (t) => {
|
|
const { sock, server } = await setup(t);
|
|
const ok = (await server.send("tools/call", { name: "list_agents", arguments: { scope: "all" } })).result;
|
|
assert.equal(ok.isError, undefined);
|
|
assert.deepEqual(JSON.parse(ok.content[0].text), { tool: "list_agents", args: { scope: "all" } });
|
|
const refused = (await server.send("tools/call", { name: "launch", arguments: { instance: "coder" } })).result;
|
|
assert.equal(refused.isError, true);
|
|
assert.equal(refused.content[0].text, "refused: capacity-full");
|
|
assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "launch"]);
|
|
});
|
|
|
|
test("unknown tools and methods are JSON-RPC errors and never reach the socket", async (t) => {
|
|
const { sock, server } = await setup(t);
|
|
const unknown = await server.send("tools/call", { name: "resolve_decision", arguments: {} });
|
|
assert.equal(unknown.error.code, -32602);
|
|
assert.equal((await server.send("resources/list")).error.code, -32601);
|
|
server.raw("not json\n");
|
|
// A notification gets no reply; the next request still answers.
|
|
server.raw(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
|
|
assert.deepEqual((await server.send("ping")).result, {});
|
|
assert.deepEqual(server.orphans, [{ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }]);
|
|
assert.equal(sock.calls.length, 0);
|
|
});
|
|
|
|
test("a missing argument is a usage error", async (t) => {
|
|
const child = spawn(process.execPath, [SOURCES.mcpServer], { stdio: "pipe" });
|
|
const code = await new Promise((r) => child.on("exit", r));
|
|
assert.equal(code, 2);
|
|
});
|