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
9.1 KiB
JavaScript
156 lines
9.1 KiB
JavaScript
// Typed tools: the only way a managed session acts on the bus. Each tool
|
|
// maps one-to-one onto a broker verb (or, for `launch`, onto the host's
|
|
// launch socket), and a session gets a tool only for an action its resolved
|
|
// instance holds within-role or cross-role, plus the reads and
|
|
// raise_decision every agent has. The broker still decides every call;
|
|
// a tool's presence is not permission.
|
|
//
|
|
// The harness never sees the capability. Tools call the runner's tool
|
|
// socket (MOSAIC_TOOL_SOCKET) with `{tool, args}`; the runner checks the
|
|
// name against this list and forwards the call with its own capability.
|
|
|
|
import { connect } from "node:net";
|
|
import { ACTIONS } from "../../business/src/vocabulary.mjs";
|
|
|
|
const str = (description, extra = {}) => ({ type: "string", description, ...extra });
|
|
const obj = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
|
|
|
|
const TASK_REF = str("Task reference, as list_tasks shows it");
|
|
const COMMON = {
|
|
task_ref: TASK_REF,
|
|
decision: str("Id of a resolved decision that approves this call; needed when the action is cross-role or gated for you"),
|
|
expect: str("The task digest you last saw; the broker refuses if the task changed since", { pattern: "^[0-9a-f]{64}$" }),
|
|
};
|
|
const RELATION = obj({
|
|
kind: str("Relation kind", { enum: ["subtask", "parenttask", "related", "duplicateof", "duplicates", "blocking", "blocked", "precedes", "follows", "copiedfrom", "copiedto"] }),
|
|
task_ref: TASK_REF,
|
|
}, ["kind", "task_ref"]);
|
|
const LABEL_IDS = { type: "array", items: { type: "integer", minimum: 1 }, maxItems: 50, description: "Label ids from the business file" };
|
|
const DUE = { type: ["string", "null"], description: "Due date as an ISO 8601 UTC timestamp, or null to clear" };
|
|
const PRIORITY = { type: "integer", minimum: 0, maximum: 5, description: "Priority, 0 (unset) to 5 (do now)" };
|
|
|
|
// action -> [tool name, description, properties, required]
|
|
const TASK_TOOLS = {
|
|
"task.create": ["task_create", "Create a tracker task. Every task cites the request it came from and a requirement id.", {
|
|
title: str("Task title, one line"),
|
|
description: str("Task description"),
|
|
due_date: DUE,
|
|
priority: PRIORITY,
|
|
labels: LABEL_IDS,
|
|
relations: { type: "array", items: RELATION, maxItems: 50 },
|
|
request: str("Id of the message or human input this task answers"),
|
|
requirement: str("Requirement id, such as REQ-CRED-2", { pattern: "^REQ-[A-Z]+-[1-9][0-9]*$" }),
|
|
decision: COMMON.decision,
|
|
}, ["title", "request", "requirement"]],
|
|
"task.assign": ["task_assign", "Assign an unassigned task to a role.", { ...COMMON, role: str("Role instance to assign") }, ["task_ref", "role"]],
|
|
"task.reassign": ["task_reassign", "Move an assigned task to another role.", { ...COMMON, role: str("Role instance to assign") }, ["task_ref", "role"]],
|
|
"task.schedule": ["task_schedule", "Change a task's due date, labels or relations.", {
|
|
...COMMON,
|
|
due_date: DUE,
|
|
labels: obj({ add: LABEL_IDS, remove: LABEL_IDS }),
|
|
relations: obj({ add: { type: "array", items: RELATION, maxItems: 50 }, remove: { type: "array", items: RELATION, maxItems: 50 } }),
|
|
}, ["task_ref"]],
|
|
"task.priority.change": ["task_priority_change", "Change a task's priority.", { ...COMMON, priority: PRIORITY }, ["task_ref", "priority"]],
|
|
"task.scope.change": ["task_scope_change", "Change a task's title or description.", { ...COMMON, title: str("New title"), description: str("New description") }, ["task_ref"]],
|
|
"task.update.assigned": ["task_update_assigned", "Report progress on a task assigned to you: percent done, state or a comment.", {
|
|
...COMMON,
|
|
percent_done: { type: "number", minimum: 0, maximum: 1, description: "Fraction done, 0 to 1" },
|
|
state: str("Board state", { enum: ["todo", "in-progress", "in-review", "blocked"] }),
|
|
comment: str("Comment to add"),
|
|
}, ["task_ref"]],
|
|
"task.close": ["task_close", "Close a task after its review verdict.", { ...COMMON, verdict: str("Citation of the review verdict") }, ["task_ref", "verdict"]],
|
|
};
|
|
|
|
const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESULT", "RESULT", "INFO", "DECISION", "REACTION"];
|
|
|
|
// typedTools({ resolved, arbiter, tracker }) -> [{ name, verb, action, description, parameters }]
|
|
// resolved resolveInstance(...) output for the session's instance
|
|
// arbiter true when the business names this instance as an arbiter
|
|
// tracker true when the broker has task verbs for the business
|
|
// `verb` is the broker verb; the `launch` tool has verb "launch" and goes
|
|
// to the host instead.
|
|
export function typedTools({ resolved, arbiter = false, tracker = false }) {
|
|
const held = new Set([...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole]);
|
|
const tools = [];
|
|
const add = (name, verb, action, description, parameters) => tools.push({ name, verb, action, description, parameters });
|
|
if (held.has("message.send")) {
|
|
add("send_message", "message.send", "message.send", "Send a message to another role instance, or to \"human\". Your final answer to a message is sent back to its sender for you; use this for anything else.", obj({
|
|
to: str("Role instance, or \"human\""),
|
|
body: str("Message text"),
|
|
class: str("Message class", { enum: MESSAGE_CLASSES }),
|
|
in_reply_to: str("Id of the message this answers"),
|
|
decision: COMMON.decision,
|
|
}, ["to", "body"]));
|
|
}
|
|
if (resolved.launch) {
|
|
add("launch", "launch", "role.launch", `Launch a managed session for one of these role instances: ${resolved.launch.instances.join(", ")}. The host refuses an instance that is already running, a full model family, or a launch while launches are switched off.`, obj({
|
|
instance: str("Role instance to launch", { enum: [...resolved.launch.instances] }),
|
|
}, ["instance"]));
|
|
}
|
|
if (tracker) {
|
|
for (const [action, [name, description, properties, required]] of Object.entries(TASK_TOOLS)) {
|
|
if (held.has(action)) add(name, action, action, description, obj(properties, required));
|
|
}
|
|
}
|
|
add("raise_decision", "decision.raise", null, "Ask for a decision before a gated or cross-role action, or when you need a choice made. The answer arrives as a message.", obj({
|
|
action: str("The action the decision is about", { enum: [...ACTIONS] }),
|
|
question: str("The question, with enough context to answer it"),
|
|
options: { type: "array", minItems: 2, maxItems: 9, items: obj({ key: str("Short key"), text: str("What this option means") }, ["key", "text"]) },
|
|
recommendation: str("Your recommended option key, with a reason"),
|
|
blocking: { type: "boolean", description: "True when you can't continue without the answer" },
|
|
target: str("What the action applies to, such as a role instance or task reference"),
|
|
approvalChoice: str("The option key that approves the action"),
|
|
task_ref: TASK_REF,
|
|
requirement_ref: str("Requirement id"),
|
|
}, ["action", "question", "options", "recommendation", "blocking"]));
|
|
if (arbiter) {
|
|
add("resolve_decision", "decision.resolve", null, "Resolve a decision routed to you.", obj({
|
|
id: str("Decision id"),
|
|
choice: str("The option key you choose"),
|
|
note: str("Reason, one or two sentences"),
|
|
}, ["id", "choice"]));
|
|
}
|
|
add("list_agents", "agents", null, "List the role instances that hold a claim now.", obj({}));
|
|
add("inbox", "inbox", null, "List the open decisions routed to you.", obj({}));
|
|
add("trail", "trail", null, "Show the evidence trail for a subject: a run, message, decision or task.", obj({ subject: str("Subject id") }, ["subject"]));
|
|
if (tracker) add("list_tasks", "tasks", null, "List the business's tracker tasks as the broker last saw them.", obj({}));
|
|
return tools;
|
|
}
|
|
|
|
// Held actions that have no typed tool. The manifest lists them so a
|
|
// reviewer sees what the session can't do through the bus.
|
|
export function untypedActions(resolved, tools) {
|
|
const typed = new Set(tools.map((t) => t.action).filter(Boolean));
|
|
return [...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole].filter((a) => !typed.has(a));
|
|
}
|
|
|
|
// One call over a tool socket: a JSON line out, a JSON line back.
|
|
export function callTool(path, tool, args = {}, { timeout = 60_000 } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const socket = connect(path);
|
|
let input = "";
|
|
let settled = false;
|
|
const end = (error, value) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
socket.destroy();
|
|
error ? reject(error) : resolve(value);
|
|
};
|
|
socket.setTimeout(timeout, () => end(new Error("tool call timed out")));
|
|
socket.on("connect", () => socket.write(JSON.stringify({ tool, args }) + "\n"));
|
|
socket.on("error", (e) => end(new Error(`tool socket: ${e.code ?? e.message}`)));
|
|
socket.on("end", () => end(new Error("tool socket closed without a reply")));
|
|
socket.on("data", (b) => {
|
|
input += b.toString("utf8");
|
|
if (input.length > 4 * 1024 * 1024) return end(new Error("tool reply too large"));
|
|
if (!input.includes("\n")) return;
|
|
try {
|
|
const r = JSON.parse(input);
|
|
r.ok ? end(null, r.result) : end(new Error(`refused: ${r.error}`));
|
|
} catch {
|
|
end(new Error("tool reply isn't JSON"));
|
|
}
|
|
});
|
|
});
|
|
}
|