trantor 0.18.102 → 0.18.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.102",
3
+ "version": "0.18.104",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
package/bin/crew/core.mjs CHANGED
@@ -4,6 +4,9 @@ import { spawnSync } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { STATE_FLAGS } from "../../lib/state/flags.mjs";
6
6
 
7
+ // the card limits the runner reads (#11559 budget, #11855 context cap), tunable at `trantor up`
8
+ export const RUNNER_LIMIT_FLAGS = ["TRANTOR_CARD_TOKEN_BUDGET", "TRANTOR_CARD_CONTEXT_CAP", "TRANTOR_CARD_CAP_MIN_STEPS", "TRANTOR_CARD_CAP_MAX_HANDOFFS"];
9
+
7
10
  export const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
8
11
 
9
12
  export function commandExists(name, env = process.env) {
@@ -149,7 +152,7 @@ export function gridColumns(size) {
149
152
  // in lib/state/flags.mjs beside the resolver: the launcher env stays the forwarder (the runner boots
150
153
  // before any file is sourced), and #7159 lets the runner fall back to ~/.agent-bus/.env itself, so a
151
154
  // flag documented there finally arms the seat that reads it.
152
- const FORWARDED_ENV = STATE_FLAGS;
155
+ const FORWARDED_ENV = [...STATE_FLAGS, ...RUNNER_LIMIT_FLAGS];
153
156
 
154
157
  export function runnerCommand(ctx, agent, model = "", effort = null, persona = agent) {
155
158
  const forwarded = FORWARDED_ENV
@@ -30,7 +30,7 @@ import {
30
30
  import { snapshotBeforeCut } from "./cut-snapshot.mjs";
31
31
  import { ocTurnUsage, dshTurnUsage, usageTotal, processedInput, codexStepInput, kimiStepInput, claudeStepInput } from "../lib/turn-usage.mjs";
32
32
  import { capConfig, capDecision, capHandoffPrompt, capPointerLine, capExhaustedNote } from "../lib/context-cap.mjs";
33
- import { ocCompactionContent, ocCompactionLogLine } from "../lib/seat-compaction.mjs";
33
+ import { ocCompactionContent, ocCompactionLogLine, readOpencodeUserConfig, seatMcpOverlay, seatConfigContent } from "../lib/seat-compaction.mjs";
34
34
  import { seatSignalPrefix } from "../lib/seat-signal-guard.mjs";
35
35
  import { listContainers, newContainers, recordContainers, statePathFor as dockerStatePathFor, sweep as dockerSweep } from "../lib/docker-janitor.mjs";
36
36
  import {
@@ -250,7 +250,9 @@ const CLI = {
250
250
  // kimi-code (successor to kimi-cli) has no --print (-p alone is non-interactive), REJECTS
251
251
  // --yolo in prompt mode (prompt mode auto-approves tools), and emits session_-prefixed ids.
252
252
  kimi: { first: `kimi{M} -p "$(cat {P})" < /dev/null`,
253
- next: `kimi{M} -r {SID} -p "$(cat {P})" < /dev/null`, mflag: " --model ", sid: /To resume this session: kimi -r (\S+)/ },
253
+ // -S resumes; kimi-code 0.34 silently ignores -r (its own exit hint still prints -r) and
254
+ // opens a new session, so every kimi turn ran cold with no history
255
+ next: `kimi{M} -S {SID} -p "$(cat {P})" < /dev/null`, mflag: " --model ", sid: /To resume this session:\s*kimi (?:-r|-S|--session) (\S+)/ },
254
256
  // #6154: `run -c` resumes the globally last session on this machine, any project's, and its stored
255
257
  // directory becomes the path root. So every spawn pins --dir and a resume pins -s (ocSid below).
256
258
  deepseek: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
@@ -784,6 +786,8 @@ function capStepProcessed(sinceMs) {
784
786
  // core/src/session/compaction.ts) — so reserved is sized from the seat model's catalog window.
785
787
  const OC_COMPACTION_WINDOW = contextWindow(MODEL);
786
788
  const OC_COMPACTION = ocCompactionContent(process.env, OC_COMPACTION_WINDOW);
789
+ // #11854: plus every non-relay MCP server of the user's opencode config switched off for the seat.
790
+ const OC_SEAT_CONFIG = seatConfigContent(OC_COMPACTION, seatMcpOverlay(readOpencodeUserConfig()));
787
791
 
788
792
  let sid = releaseState?.sid || "";
789
793
  // #6206: the watchdog is DETACHED, so a runner that dies without ending it leaves an orphan
@@ -985,7 +989,7 @@ exit $turn_exit`;
985
989
  // #11559: opencode only compacts when the context is FULL, which on big-limit models is far
986
990
  // past the point a seat card should reach. The merged-content injection arms compaction per
987
991
  // seat (schema: opencode.ai/config.json) without touching the operator's global config.
988
- if (cli.pinned && OC_COMPACTION) spawnEnv.OPENCODE_CONFIG_CONTENT = OC_COMPACTION;
992
+ if (cli.pinned && OC_SEAT_CONFIG) spawnEnv.OPENCODE_CONFIG_CONTENT = OC_SEAT_CONFIG;
989
993
  spawnOpts.env = spawnEnv;
990
994
  spawnOpts.maxBuffer = 16 * 1024 * 1024;
991
995
  // The timeout signals the shell to snapshot before sweeping, just like the normal box.
@@ -1049,7 +1053,8 @@ exit $turn_exit`;
1049
1053
  if (env.turn) log(`state step: ${describeTurn(env.turn)} · ${costLine(env.cost)}`);
1050
1054
  else log(`\x1b[33mstate step: no TurnResult — ${env.error}\x1b[0m`);
1051
1055
  }
1052
- if (cli.sid && r.stdout) { const m = r.stdout.match(cli.sid); if (m) sid = m[1]; }
1056
+ // kimi prints its resume hint on stderr, so a stdout-only match never captured its sid
1057
+ if (cli.sid) { const all = [...`${r.stdout || ""}\n${ownOut}`.matchAll(new RegExp(cli.sid.source, "g"))]; if (all.length) sid = all.at(-1)[1]; }
1053
1058
  // #6154: the opencode family prints no sid on stdout — the id comes from opencode's own DB,
1054
1059
  // keyed by the worktree the session was created in. Fail-open: nothing found leaves sid empty,
1055
1060
  // and the next turn starts fresh rather than resuming whatever other project ran last.
@@ -3,6 +3,10 @@
3
3
  // additionalProperties:false; calculateCeiling in packages/core/src/session/compaction.ts). So the
4
4
  // seat target "compact near 100K" is reached by sizing reserved from the model's window.
5
5
 
6
+ import { readFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+
6
10
  export const OC_COMPACTION_ENV = "TRANTOR_OC_COMPACTION";
7
11
  // The context level a seat card should stay near: on a 1M-window model reserved becomes 900K
8
12
  // so auto-compact fires at ~100K, not the ~936K a fixed 64K buffer leaves.
@@ -49,6 +53,41 @@ export function ocCompactionContent(env = process.env, window = undefined) {
49
53
  return JSON.stringify({ compaction: parsed });
50
54
  }
51
55
 
56
+ export const SEAT_MCP_ENV = "TRANTOR_SEAT_MCP";
57
+
58
+ // The user's opencode config object, or null: $OPENCODE_CONFIG else ~/.config/opencode/opencode.json
59
+ // (then .jsonc with whole-line // comments stripped). Fails open on anything unreadable.
60
+ export function readOpencodeUserConfig(env = process.env, home = homedir()) {
61
+ const dir = join(home, ".config", "opencode");
62
+ const files = env.OPENCODE_CONFIG ? [env.OPENCODE_CONFIG] : [join(dir, "opencode.json"), join(dir, "opencode.jsonc")];
63
+ for (const f of files) {
64
+ try {
65
+ const raw = readFileSync(f, "utf8");
66
+ return JSON.parse(f.endsWith(".jsonc") ? raw.replace(/^\s*\/\/.*$/gm, "") : raw);
67
+ } catch {}
68
+ }
69
+ return null;
70
+ }
71
+
72
+ // #11854: a seat only needs the relay server; every other MCP server in the user's config pays its
73
+ // tool definitions on every model step. Returns { mcp: { name: { enabled: false } } } or null.
74
+ export function seatMcpOverlay(userConfig, env = process.env) {
75
+ if (String(env[SEAT_MCP_ENV] || "").trim() === "all") return null;
76
+ const mcp = userConfig?.mcp;
77
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof
78
+ if (!mcp || typeof mcp !== "object" || Array.isArray(mcp)) return null;
79
+ const off = Object.keys(mcp).filter((n) => n !== "relay");
80
+ return off.length ? { mcp: Object.fromEntries(off.map((n) => [n, { enabled: false }])) } : null;
81
+ }
82
+
83
+ // The OPENCODE_CONFIG_CONTENT for an opencode seat: compaction JSON plus the MCP overlay, "" if neither.
84
+ export function seatConfigContent(compactionJson, mcpOverlay) {
85
+ if (!mcpOverlay) return compactionJson;
86
+ let base = {};
87
+ try { base = compactionJson ? JSON.parse(compactionJson) : {}; } catch {}
88
+ return JSON.stringify({ ...base, ...mcpOverlay });
89
+ }
90
+
52
91
  // The one per-turn runner line: what the injected block will actually do for this seat.
53
92
  export function ocCompactionLogLine(window = undefined) {
54
93
  const reserved = reservedForContext(window);
package/mcp.mjs CHANGED
@@ -109,7 +109,7 @@ server.tool("relay_whoami", "Show this session's relay identity, project, hub UR
109
109
  return { content: [{ type: "text", text }] };
110
110
  });
111
111
 
112
- server.tool("relay_contracts", "What you dispatched and are still owed. Lists every DIRECT message you sent to another session, with how long it has been outstanding, whether that session is still alive, and its last known status. Each one carries a disposition: WAITING (assignee alive and working — normal), STALLED (assignee offline or overdue — poke it, swap it, or reassign), ABANDONED (assignee gone so long the contract can never be answered — the work needs reassigning, nobody is coming back), SUPERSEDED (the assignee is alive and has since answered a NEWER contract from you, so this row was never going to be answered — no action needed), or answered, with the outcome text. Call this before concluding a crew is idle, stuck, or done — silence on the bus is not evidence either way.", {},
112
+ server.tool("relay_contracts", "What you dispatched and are still owed: each direct message you sent, its age, whether the assignee is alive, and a disposition. WAITING (alive, working), STALLED (offline or overdue: poke, swap or reassign), ABANDONED (gone for good: reassign), SUPERSEDED (they answered a newer contract: no action), or answered with the outcome. Call before concluding a crew is idle, stuck or done; bus silence is not evidence.", {},
113
113
  async () => {
114
114
  let r;
115
115
  try { r = await api("GET", `/contracts?session=${encodeURIComponent(SESSION)}&project=${encodeURIComponent(PROJECT)}`); }
@@ -159,23 +159,23 @@ server.tool("relay_contracts", "What you dispatched and are still owed. Lists ev
159
159
  return { content: [{ type: "text", text }] };
160
160
  });
161
161
 
162
- server.tool("relay_task_add", "Add a Kanban card to a project's board on the dashboard (what you're about to work on). Defaults: THIS project, assigned to you, status 'todo'. Pass `project` to target another board — e.g. when you orchestrate a crew that runs in a different directory than the one you launched Claude from. Keep the team's progress visible. Attach a `note` whenever context isn't obvious from the title — it lands on the card's permanent log ({ts,by,text}, kept: last 40).",
163
- { title: z.string().describe("short task title"), status: z.enum(["todo","doing","testing","failed","done","blocked"]).optional(), assignee: z.string().optional().describe("session id to assign (default: you)"), difficulty: z.enum(["easy","medium","hard"]).optional().describe("difficulty tag — drives model/agent routing (relay_advise) and shows on the board"), model: z.string().optional().describe("the model this card is routed to (from relay_advise routing, or the CLI default) — shown on the card"), deps: z.array(z.number()).optional().describe("card ids this card depends on — drawn as branch edges in the Flow view (e.g. integration depends on every crew card)"), phase: z.string().optional().describe("phase/milestone this card belongs to (e.g. 'P5', 'Auth', 'Launch') — groups it in the Flow view's phase flowchart. Optional; otherwise inferred from the title prefix + time."), note: z.string().max(2000).optional().describe("optional card-log entry (<=2000 chars): context, the plan, or a link — stored on the card as {ts,by,text}"), project: z.string().optional().describe("board to add to (default: this session's project). Set to the crew's project when you orchestrate from a different directory"), checklist: z.array(z.string().max(200)).max(20).optional().describe("acceptance items for the card — the honest denominator for its progress bar. Tick them off with relay_task_check as each is truly met"), drill: z.string().max(300).optional().describe("the card's DRILL (build doctrine rule 1, <=300 chars): the exact thing a person does on the built artifact and what they must see — not a test command. The hub refuses to move a card to done without a drill line (this field, a checklist item or a note starting with 'Drill:'), so a card cut without one is not ready to be worked") },
162
+ server.tool("relay_task_add", "Add a Kanban card (defaults: this project, assigned to you, status todo). `project` targets another board. Attach a `note` when the title alone lacks context; it lands on the card's log (last 40 kept).",
163
+ { title: z.string(), status: z.enum(["todo","doing","testing","failed","done","blocked"]).optional(), assignee: z.string().optional(), difficulty: z.enum(["easy","medium","hard"]).optional(), model: z.string().optional(), deps: z.array(z.number()).optional(), phase: z.string().optional(), note: z.string().max(2000).optional(), project: z.string().optional(), checklist: z.array(z.string().max(200)).max(20).optional().describe("acceptance items; tick with relay_task_check only when truly met"), drill: z.string().max(300).optional().describe("the DRILL: what a person does on the built artifact and must see, not a test command. The hub refuses done without a drill line (this field, a checklist item or a note starting 'Drill:')") },
164
164
  async ({ title, status, assignee, difficulty, model, deps, phase, note, project, checklist, drill }) => {
165
165
  const proj = project || PROJECT;
166
166
  const { task } = await api("POST", "/task", { project: proj, title, status: status || "todo", assignee: assignee || SESSION, difficulty, model, deps, phase, note, checklist, drill, by: SESSION });
167
167
  return { content: [{ type: "text", text: `card #${task.id} added to ${proj}: "${title}" [${task.status}]${phase?` · phase ${phase}`:""}${task.checklist?.length?` · ${task.checklist.length} acceptance item(s)`:""}${task.drill ? " · drill set" : " · NO DRILL — the hub will refuse done until one is set"}` }] };
168
168
  });
169
169
 
170
- server.tool("relay_task_check", "Tick (or untick) ONE acceptance item on a card's checklist — the card's progress bar reads checked/total, so tick an item only when it is genuinely met (tests run, behavior observed), never to make the bar move. Items are 0-indexed in the order relay_task_add listed them.",
171
- { id: z.number().describe("card id"), index: z.number().int().min(0).describe("0-based checklist item index"), done: z.boolean().optional().describe("default true; pass false to untick") },
170
+ server.tool("relay_task_check", "Tick (or untick) one checklist item on a card. Tick only when genuinely met (tests run, behavior observed), never to move the progress bar. Items are 0-indexed in relay_task_add order.",
171
+ { id: z.number().describe("card id"), index: z.number().int().min(0), done: z.boolean().optional() },
172
172
  async ({ id, index, done }) => {
173
173
  const { task } = await api("POST", "/task/checklist-toggle", { id, index, done: done !== false, by: SESSION });
174
174
  const n = task.checklist.filter(c => c.done).length;
175
175
  return { content: [{ type: "text", text: `card #${id} checklist: [${done !== false ? "x" : " "}] "${task.checklist[index].text}" — ${n}/${task.checklist.length} done` }] };
176
176
  });
177
177
 
178
- server.tool("relay_phase_goal", "Set what a PHASE is for — its goal — shown as the phase header in the Flow view (overrides the theme auto-derived from card titles). Capture this when you plan a phase so the board says what each milestone needs to do, not just 'P5'. Phase keys match relay_task_add's `phase` (or the inferred title-prefix family like 'P5').",
178
+ server.tool("relay_phase_goal", "Set a phase's goal, shown as its header in the Flow view. Set it when you plan a phase. Phase keys match relay_task_add's `phase`.",
179
179
  { phase: z.string().describe("the phase key (e.g. 'P5', 'Auth', 'Launch')"), goal: z.string().describe("1-2 sentences: what this phase delivers + done-criteria"), project: z.string().optional().describe("board (default: this session's project)") },
180
180
  async ({ phase, goal, project }) => {
181
181
  const proj = project || PROJECT;
@@ -183,8 +183,8 @@ server.tool("relay_phase_goal", "Set what a PHASE is for — its goal — shown
183
183
  return { content: [{ type: "text", text: `phase "${phase}" goal set for ${proj}` }] };
184
184
  });
185
185
 
186
- server.tool("relay_task_move", "Move a Kanban card as you progress: todo -> doing -> testing -> done. NEVER move straight to done: move to 'testing' when you finish, run the project's tests/typecheck, then 'done' only if green — or 'failed' (with a relay_send explaining what broke) if not. The orchestrator bounces failed cards back to doing. blocked = waiting on something external. A move to 'testing' or 'done' MUST carry a `note` (<=2000 chars): what you changed and the evidence (the test command + counts). The note lands on the card's permanent log — the board shows its ·N count, so a silent move reads as unverified work.",
187
- { id: z.number(), status: z.enum(["todo","doing","testing","failed","done","blocked"]), note: z.string().max(2000).optional().describe("card-log entry (<=2000 chars) — REQUIRED on moves to testing/done: what changed + the evidence (command, pass counts)"), drill: z.string().max(300).optional().describe("set or fix the card's drill line (<=300 chars) in the same call — what a person does on the built artifact and must see. A move to done is refused by the hub when the card has no drill line") },
186
+ server.tool("relay_task_move", "Move a card: todo -> doing -> testing -> done. Never go straight to done: move to 'testing' when finished, run the tests/typecheck, then 'done' only if green, else 'failed' (relay_send what broke). blocked = waiting on something external. A move to testing or done MUST carry a `note`: what changed plus the evidence (command, pass counts).",
187
+ { id: z.number(), status: z.enum(["todo","doing","testing","failed","done","blocked"]), note: z.string().max(2000).optional().describe("card-log entry; REQUIRED on testing/done"), drill: z.string().max(300).optional().describe("set the drill line in this call; done is refused without one") },
188
188
  async ({ id, status, note, drill }) => {
189
189
  // #7750: seat-side hollow-move check. The hub cannot see this worktree, so the evidence is
190
190
  // gathered HERE at report time; a hollow move still lands (never blocks a no-code card), the
@@ -224,7 +224,7 @@ server.tool("relay_task_move", "Move a Kanban card as you progress: todo -> doin
224
224
  return { content: [{ type: "text", text: `card #${id} -> ${status}` }] };
225
225
  });
226
226
 
227
- server.tool("relay_project_brief", "Set a one-paragraph brief for a project shown on the dashboard: what it is, why it matters, and the goal. Defaults to THIS project; pass `project` to brief a crew board you orchestrate from elsewhere. Set it once when you start work so anyone watching the board understands the project at a glance (the board itself shows where it is in the process).",
227
+ server.tool("relay_project_brief", "Set a one-paragraph project brief shown on the dashboard: what it is, why it matters, the goal. Defaults to this project; set it once when you start work.",
228
228
  { brief: z.string().describe("1-3 sentences: what this project is + why + the goal"), project: z.string().optional().describe("board to brief (default: this session's project)") },
229
229
  async ({ brief, project }) => {
230
230
  const proj = project || PROJECT;
@@ -232,10 +232,10 @@ server.tool("relay_project_brief", "Set a one-paragraph brief for a project show
232
232
  return { content: [{ type: "text", text: `brief set for ${proj}` }] };
233
233
  });
234
234
 
235
- server.tool("relay_advise", "THE ADVISOR — ask the brain how to execute a body of work before spending tokens. Give it your work packages (with difficulty); it weighs task shape x the user's plan economics x context horizon and returns: mode (solo|scrooge|crew|hybrid), per-package executor+model routing, and a real-money estimate with quota-pool accounting. Call this at project kickoff and PRESENT the summary to the user before firing anything up.",
236
- { task: z.string().describe("one-line description of the overall job"),
237
- packages: z.array(z.object({ title: z.string(), difficulty: z.enum(["easy","medium","hard"]).optional(), kind: z.string().optional() })).describe("the work packages you'd cut as cards"),
238
- horizon: z.enum(["short","medium","long"]).optional().describe("how long this build will run (default inferred from package count)") },
235
+ server.tool("relay_advise", "Ask how to execute a body of work before spending tokens. Returns mode (solo|scrooge|crew|hybrid), per-package executor+model routing and a cost estimate. Call at project kickoff and present the summary to the user before starting anything.",
236
+ { task: z.string(),
237
+ packages: z.array(z.object({ title: z.string(), difficulty: z.enum(["easy","medium","hard"]).optional(), kind: z.string().optional() })),
238
+ horizon: z.enum(["short","medium","long"]).optional() },
239
239
  async ({ task, packages, horizon }) => {
240
240
  // #7762: the advisor reads THIS project's seat record (derived, fail-open) so a seat whose
241
241
  // recent cards produced nothing is benched at that difficulty instead of re-picked as cheap.
@@ -245,9 +245,9 @@ server.tool("relay_advise", "THE ADVISOR — ask the brain how to execute a body
245
245
  return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
246
246
  });
247
247
 
248
- server.tool("relay_scrooge", "Delegate a SMALL, SELF-CONTAINED piece of grunt work (draft a function, summarize, extract, classify, boilerplate) to a cheap external model via Scrooge — costs a fraction of a cent and keeps the result OUT of expensive context where possible. Returns the model's output plus the ledger receipt. Use for stateless one-shots; use the crew for anything stateful or large.",
249
- { prompt: z.string().describe("the complete, self-contained task (include all needed context — the cheap model sees ONLY this)"),
250
- task: z.string().optional().describe("scrooge task type: code, summarize, extract, draft, verify, reason, cheap (default code)"),
248
+ server.tool("relay_scrooge", "Delegate a small, self-contained piece of grunt work (draft, summarize, extract, classify, boilerplate) to a cheap external model via Scrooge. Returns the output plus the ledger receipt. Stateless one-shots only; use the crew for anything stateful or large.",
249
+ { prompt: z.string().describe("complete self-contained task; the model sees only this"),
250
+ task: z.string().optional().describe("code (default) | summarize | extract | draft | verify | reason | cheap"),
251
251
  difficulty: z.enum(["easy","medium","hard"]).optional() },
252
252
  async ({ prompt, task, difficulty }) => {
253
253
  const r = spawnSync("scrooge", ["-t", task || "code", "-d", difficulty || "easy"], { input: prompt, encoding: "utf8", timeout: 120000, maxBuffer: 8 * 1024 * 1024 });
@@ -257,21 +257,21 @@ server.tool("relay_scrooge", "Delegate a SMALL, SELF-CONTAINED piece of grunt wo
257
257
  });
258
258
 
259
259
  server.tool("relay_lesson", "Record a LESSON learned from a failure so future crews avoid it — injected into agents' kickoff prompts automatically. Use when you diagnose a recurring or preventable failure. scope: 'global' (applies to every agent) or an agent brand ('kimi','codex','gemini','deepseek') when it's that CLI's quirk.",
260
- { text: z.string().describe("one-line imperative guardrail, e.g. 'never move a card to done without npm test passing'"), scope: z.string().optional().describe("'global' (default) or an agent brand") },
260
+ { text: z.string().describe("one-line imperative guardrail"), scope: z.string().optional().describe("'global' (default) or an agent brand") },
261
261
  async ({ text, scope }) => {
262
262
  const r = await api("POST", "/lesson", { text, scope: scope || "global", by: SESSION });
263
263
  return { content: [{ type: "text", text: r.dedup ? "lesson already recorded" : `lesson recorded (${r.count} total)` }] };
264
264
  });
265
265
 
266
- server.tool("relay_verify_gate", "Record a VERIFICATION GATE — a claim that MUST be independently verified before the related work ships (e.g. 'Gail breast coefficients match the published BCRAT model'). Unlike a note buried in a handoff narrative, a gate is STRUCTURED: it travels with handoffs and is shown PROMINENTLY to whoever takes over, so a safety-critical 'verify before commit' can't be skimmed past. action 'add' when you produce code whose correctness you have NOT independently proven (especially formulas/coefficients/security/data-shape); 'resolve' once you've verified it (or waived with the user); 'list' to see open gates. Defaults to THIS project.",
267
- { action: z.enum(["add", "resolve", "list"]).describe("add a gate · resolve one · list open gates"),
268
- claim: z.string().optional().describe("what must be verified (required for add) — a specific, checkable claim"),
269
- why: z.string().optional().describe("why it matters / the risk if it ships unverified"),
270
- howToVerify: z.string().optional().describe("the concrete check that would verify it (source to cross-check, command to run)"),
266
+ server.tool("relay_verify_gate", "Record a verification gate: a claim that must be independently verified before the work ships. It travels with handoffs and is shown prominently to whoever takes over. 'add' when you wrote code whose correctness you have not proven (formulas, security, data shape); 'resolve' once verified or waived with the user; 'list' open gates.",
267
+ { action: z.enum(["add", "resolve", "list"]),
268
+ claim: z.string().optional().describe("claim to verify (required for add)"),
269
+ why: z.string().optional(),
270
+ howToVerify: z.string().optional(),
271
271
  id: z.number().optional().describe("gate id to resolve"),
272
- status: z.string().optional().describe("resolve status: 'verified' (default) | 'failed' | 'waived'"),
273
- note: z.string().optional().describe("resolution note (what you checked / why waived)"),
274
- project: z.string().optional().describe("target project (default: this session's project)") },
272
+ status: z.string().optional(),
273
+ note: z.string().optional(),
274
+ project: z.string().optional().describe("project (default: this one)") },
275
275
  async ({ action, claim, why, howToVerify, id, status, note, project }) => {
276
276
  const proj = project || PROJECT;
277
277
  if (action === "list") {
@@ -289,12 +289,12 @@ server.tool("relay_verify_gate", "Record a VERIFICATION GATE — a claim that MU
289
289
  return { content: [{ type: "text", text: r.dedup ? `gate already open (#${r.gate.id})` : `🔒 verification gate #${r.gate.id} recorded — surfaces on every handoff until you resolve it` }] };
290
290
  });
291
291
 
292
- server.tool("relay_propose", "PROPOSE a standing permission or scope change to the human operator — the ONLY approver; nothing auto-approves. A proposal must state its BOUND: scope (what), condition (when it applies), exclusions (what is still NOT covered) — a permission without a bound is a blank cheque and is rejected. It sits pending (max 3 per session — withdraw one to file another) until the operator decides in the app or CLI; you get a bus message with the decision. Denials are REMEMBERED: a near-duplicate of a denied proposal is refused, so never re-propose — refine the bound or move on. File it and continue your mission; never nag.",
293
- { scope: z.string().describe("WHAT standing permission you want, specific and checkable — e.g. 'push directly to main in this repo'"),
294
- condition: z.string().describe("WHEN it applies — e.g. 'only after the full test suite exits 0'"),
295
- exclusions: z.string().describe("what is still NOT covered — e.g. 'never force-push, never touch release tags'"),
296
- key: z.string().optional().describe("optional machine-readable capability slug (e.g. 'patrol.reap-orphans') — lets tools check the grant exactly instead of matching prose"),
297
- project: z.string().optional().describe("project the permission concerns (default: this session's project)") },
292
+ server.tool("relay_propose", "Propose a standing permission or scope change to the human operator, the only approver. It must state its bound (scope, condition, exclusions) or it is rejected. Pending max 3 per session (withdraw one to file another); the decision arrives as a bus message. A near-duplicate of a denied proposal is refused: refine the bound or move on. File it, continue your mission, never nag.",
293
+ { scope: z.string(),
294
+ condition: z.string(),
295
+ exclusions: z.string(),
296
+ key: z.string().optional(),
297
+ project: z.string().optional().describe("project (default: this one)") },
298
298
  async ({ scope, condition, exclusions, key, project }) => {
299
299
  // signedPost directly (not api()) — a refusal's BODY is the teaching moment (denial note,
300
300
  // queue guidance) and api() throws it away, leaving only "hub 409".
@@ -310,7 +310,7 @@ server.tool("relay_propose", "PROPOSE a standing permission or scope change to t
310
310
  : `proposal #${pr.id} filed and PENDING operator review. Continue your mission — you'll get a bus message when it's decided. Do NOT act as if it were approved.` }] };
311
311
  });
312
312
 
313
- server.tool("relay_proposals", "List THIS session's permission proposals and their statuses (pending / approved / denied / revoked / withdrawn), including the operator's decision notes. An APPROVED proposal is a standing GRANT — act within its stated bound without re-asking. Check here before relying on a permission you proposed — pending is not approved.", {},
313
+ server.tool("relay_proposals", "List this session's permission proposals and statuses (pending / approved / denied / revoked / withdrawn) with decision notes. An approved proposal is a standing grant: act within its bound without re-asking. Pending is not approved.", {},
314
314
  async () => {
315
315
  const { proposals } = await api("GET", `/proposals?session=${encodeURIComponent(SESSION)}`);
316
316
  if (!proposals?.length) return { content: [{ type: "text", text: "no proposals filed by this session" }] };
@@ -375,10 +375,10 @@ function cardView(tasks, id, proj) {
375
375
  return out.join("\n");
376
376
  }
377
377
 
378
- server.tool("relay_board", "Show a project's Kanban board (all cards + their status + assignee). Defaults to THIS project; pass `project` to read a crew board you orchestrate from elsewhere. Cards carrying log notes show a ·N count (the card's note-log size).",
379
- { project: z.string().optional().describe("board to show (default: this session's project)"),
380
- card: z.number().optional().describe("read ONE card instead of the board: the card itself, its notes, and the last five done cards whose title shares a word with it. This is what a seat starting a card should call — the whole board is 1900+ cards of someone else's work."),
381
- mine: z.boolean().optional().describe("your cards instead of the board: everything assigned to the calling session in doing/testing/todo, newest first, each with the full card:<id> shape. The FIRST call of a turn whose wake names no card id (#7763) — no second message from the orchestrator needed.") },
378
+ server.tool("relay_board", "Show a project's Kanban board (cards, status, assignee; ·N = note count). Defaults to this project.",
379
+ { project: z.string().optional(),
380
+ card: z.number().optional().describe("read ONE card (with its notes and related done cards) instead of the board; call this when starting a card"),
381
+ mine: z.boolean().optional().describe("cards assigned to the calling session (doing/testing/todo), newest first; the first call of a turn whose wake names no card id") },
382
382
  async ({ project, card, mine }) => {
383
383
  const proj = project || PROJECT;
384
384
  // #6983: ask for the SLIM projection, and for that one card full when opening one. The board
@@ -419,10 +419,10 @@ server.tool("relay_peers", "Find who you can talk to: the live agent sessions on
419
419
  return { content: [{ type: "text", text: lines.join("\n") || "no peers yet" }] };
420
420
  });
421
421
 
422
- server.tool("relay_send", "Send a live message to another agent session (or 'all' to broadcast). Reach the other agent YOURSELF: if you are about to ask the human to pass something along, tell the session directly instead — asking a person to carry a message between two agents is a failure, not politeness. Don't know the id? relay_peers lists them, linked projects included. Cross-project action is a breach unless the operator linked the projects (`trantor policy link <a> <b> --reason \"<why>\"`) — the hub answers 403 for a send into an unlinked project.",
423
- { to: z.string().describe("target session id, or 'all'"), text: z.string().describe("message body"),
424
- wake: z.boolean().optional().describe("false = context, not a contract: the message batches into the target's next turn instead of buying it a whole CLI session. With wake unset or true, a direct message ALWAYS earns a turn whatever its text says (#7766) — so set false on every ack, FYI and queue note, not just on obvious chatter."),
425
- re: z.number().optional().describe("the message id you are ANSWERING. Set it whenever you reply to a specific message. Without it the hub matches your reply to the peer's OLDEST outstanding contract, so answering their newest question silently closes their oldest one and leaves the real one reading WAITING forever — which is how seats end up chasing contracts that were answered long ago.") },
422
+ server.tool("relay_send", "Send a live message to another agent session (or 'all'). Message the agent yourself; never ask the human to relay. Ids: relay_peers. Cross-project sends are a breach unless the operator linked the projects (`trantor policy link <a> <b> --reason \"<why>\"`); the hub answers 403 for an unlinked project.",
423
+ { to: z.string(), text: z.string(),
424
+ wake: z.boolean().optional().describe("false = context, not a contract: batches into the target's next turn. Unset or true, a direct message ALWAYS earns a turn whatever its text says, so set false on every ack, FYI and queue note."),
425
+ re: z.number().optional().describe("message id you are answering; set it on every reply. Without it the hub matches your reply to the peer's OLDEST outstanding contract, closing the wrong one.") },
426
426
  async ({ to, text, wake, re }) => {
427
427
  // The event log is append-only — a secret in it is unrecoverable, so refuse BEFORE
428
428
  // anything reaches the hub. Returns the offending kinds so the caller can fix it.
@@ -446,9 +446,9 @@ server.tool("relay_send", "Send a live message to another agent session (or 'all
446
446
  return { content: [{ type: "text", text: `sent #${id} to ${to}${wake === false ? " (batched — no turn)" : ""}` }] };
447
447
  });
448
448
 
449
- server.tool("relay_ask", "Ask a BLOCKING question on your card without forfeiting the contract (#7756). This is the correct move when the contract omits a fact you cannot proceed without — never invent the value. Sends a kind:ask message to the card's assigner: the hub blocks the card with your question as its note so the board reads why nothing is moving, your runner keeps the wake owed (no failure, no park), and the assigner's answer resumes THIS session with the original contract re-attached. End your turn right after calling it.",
450
- { card: z.number().describe("the card your current contract is working"),
451
- question: z.string().describe("the ONE missing fact you cannot proceed without, stated precisely") },
449
+ server.tool("relay_ask", "Ask a blocking question on your card without forfeiting the contract. Use it when the contract omits a fact you cannot proceed without; never invent the value. Sends a kind:ask to the card's assigner, blocks the card with your question as its note, and the answer resumes this session with the original contract re-attached. End your turn right after calling it.",
450
+ { card: z.number(),
451
+ question: z.string().describe("the one missing fact, stated precisely") },
452
452
  async ({ card, question }) => {
453
453
  // Append-only log rule, same as relay_send: a secret is refused BEFORE anything reaches the hub.
454
454
  const scrub = assertNoSecrets(question);
@@ -474,7 +474,7 @@ server.tool("relay_ask", "Ask a BLOCKING question on your card without forfeitin
474
474
  });
475
475
 
476
476
  server.tool("relay_duty_failure", "Duty-seat only: record that a required cross-session socket nudge was skipped or that a relay send returned 403. The hub appends the failure to the target project's active focus card and exposes it in trantor doctor.",
477
- { recipient: z.string().describe("recipient session whose project is affected"), project: z.string().optional().describe("target project; normally inferred from the recipient"), kind: z.enum(["relay-403", "skipped-nudge"]), detail: z.string().max(500).optional() },
477
+ { recipient: z.string(), project: z.string().optional(), kind: z.enum(["relay-403", "skipped-nudge"]), detail: z.string().max(500).optional() },
478
478
  async ({ recipient, project, kind, detail }) => {
479
479
  const { failure } = await api("POST", "/duty/failure", { recipient, project, kind, detail });
480
480
  return { content: [{ type: "text", text: `recorded: ${failure.text}${failure.focusCard ? ` (focus card #${failure.focusCard})` : " (no active focus card)"}` }] };
@@ -506,7 +506,7 @@ server.tool("relay_handoff", "Write a rich handoff for THIS session so a fresh s
506
506
  return { content: [{ type: "text", text: `handoff saved (${rec.id}). A fresh session in ${name} will load it on start. Tell the user to open a new terminal here.` }] };
507
507
  });
508
508
 
509
- server.tool("relay_inbox", "Read NEW messages addressed to this session since the last read (non-blocking). The result is a bounded page — newest first, repeats of the same notice collapsed with ×N, capped near 20K chars. When older messages exist it ends with a before-cursor: pass it back as relay_inbox({before}) to page into older history.",
509
+ server.tool("relay_inbox", "Read new messages for this session since the last read (non-blocking). Bounded page, newest first, repeats collapsed with ×N. When older messages exist it ends with a before-cursor: pass it back as relay_inbox({before}).",
510
510
  { before: z.number().optional().describe("page BACK: return only messages with id < before (the cursor from the 'older not shown' footer). A peek — does not advance the new-message cursor.") },
511
511
  async ({ before }) => {
512
512
  if (before) {
@@ -522,7 +522,7 @@ server.tool("relay_inbox", "Read NEW messages addressed to this session since th
522
522
  return { content: [{ type: "text", text: page.text || "(no new messages)" }] };
523
523
  });
524
524
 
525
- server.tool("relay_wait", "Block up to `timeout` seconds waiting for the next message to this session (long-poll). Returns the instant a message arrives. When idle, park by calling this repeatedly. IMPORTANT: MCP clients cap or background long tool calls — OpenCode ~60s; Codex ~120s; Claude Code auto-backgrounds ANY MCP call at 120s (CC 2.1.212+, tunable via CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS), which breaks inline parking. Use timeout 50 and loop for cross-client safety.",
525
+ server.tool("relay_wait", "Long-poll up to `timeout` seconds for the next message to this session; returns the instant one arrives. When idle, park by calling it repeatedly. MCP clients cap long calls (OpenCode ~60s, Codex ~120s, Claude Code backgrounds at 120s), so use timeout 50 and loop.",
526
526
  { timeout: z.number().optional().describe("seconds to wait, default 25. Effective wait is capped at 110s so the call always returns inline (a longer wait would be auto-backgrounded by Claude Code's 120s MCP floor or dropped by other clients). Use 50 and call repeatedly to park while idle.") },
527
527
  async ({ timeout }) => {
528
528
  // Cap below the 120s MCP auto-background floor (CC 2.1.212+) so relay_wait always
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.102",
3
+ "version": "0.18.104",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"