trantor 0.18.49 → 0.18.51

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.49",
3
+ "version": "0.18.51",
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": {
@@ -10,13 +10,14 @@
10
10
  //
11
11
  // Env seams (the drill's off switches, same doctrine as TRANTOR_NO_HANDOFF_SPAWN):
12
12
  // TRANTOR_BATON_IDLE_DEADLINE_S give up waiting for idle after this many seconds (default 600)
13
+ // TRANTOR_BATON_AGENT_DROP_MS give herdr this long to retire the ended agent (default 10000)
13
14
  // TRANTOR_BATON_REOPEN command to reopen the pane, instead of `trantor open`
14
15
  // (the drill points this at its own herdr world)
15
16
  // Detached means nobody reads stdout: everything lands in <bus>/logs/baton-pane-<project>.log.
16
17
  import { readFileSync, existsSync, mkdirSync, appendFileSync } from "node:fs";
17
18
  import { join, basename } from "node:path";
18
19
  import { homedir } from "node:os";
19
- import { execFileSync, execSync } from "node:child_process";
20
+ import { execFileSync, spawnSync } from "node:child_process";
20
21
  import { createConnection } from "node:net";
21
22
 
22
23
  const arg = (name) => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : ""; };
@@ -67,8 +68,50 @@ function socketRequest(req, timeoutMs = 30_000) {
67
68
 
68
69
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
69
70
  const agentStatus = (pane) => herdrJson(["agent", "get", pane])?.result?.agent?.agent_status || null;
71
+ const hasAgent = (pane) => Boolean(herdrJson(["agent", "get", pane])?.result?.agent);
70
72
  const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
71
73
 
74
+ // The shells a pane idles in once its agent is gone. Their pid is never the process to end.
75
+ const SHELL_NAMES = new Set(["zsh", "bash", "sh", "fish", "dash", "ksh", "tcsh", "csh", "nu", "login"]);
76
+ function isShellProcess(p) {
77
+ const raw = String(p?.name || p?.argv0 || "").trim();
78
+ const name = basename(raw.replace(/^-/, "")); // login shells spell themselves "-zsh"
79
+ return SHELL_NAMES.has(name);
80
+ }
81
+
82
+ /** The pid the graceful end targets, from herdr's `pane process-info` result.process_info —
83
+ * parity with lib.rs foreground_pid_from_process_info. #6668: the 09-07 12:35 chain would have
84
+ * TERMed 80368, the pane's bare zsh, because foreground_process_group_id was taken as-is. The
85
+ * shell (process_info.shell_pid, or any entry named like one) is never a candidate: with only
86
+ * the shell in the foreground there is nothing to end, and the answer is null. */
87
+ export function foregroundPid(info) {
88
+ if (!info) return null;
89
+ const procs = Array.isArray(info.foreground_processes) ? info.foreground_processes : [];
90
+ const shellPid = Number(info.shell_pid) || 0;
91
+ const byPid = (pid) => procs.find(p => Number(p?.pid) === pid);
92
+ const usable = (pid) => pid > 0 && pid !== shellPid && !isShellProcess(byPid(pid));
93
+ const group = Number(info.foreground_process_group_id) || 0;
94
+ if (usable(group)) return group;
95
+ const claude = procs.find(p => /claude/i.test([p?.name, p?.argv0, p?.cmdline].map(s => String(s || "")).join(" ")) && !isShellProcess(p));
96
+ if (claude && Number(claude.pid) > 0 && Number(claude.pid) !== shellPid) return Number(claude.pid);
97
+ for (let i = procs.length - 1; i >= 0; i--) {
98
+ const pid = Number(procs[i]?.pid) || 0;
99
+ if (usable(pid)) return pid;
100
+ }
101
+ return null;
102
+ }
103
+
104
+ export function agentDropStep(present, elapsedMs, deadlineMs) {
105
+ if (!present) return "dropped";
106
+ return elapsedMs >= deadlineMs ? "deadline" : "wait";
107
+ }
108
+
109
+ function summary(value, limit = 240) {
110
+ const oneLine = String(value || "").trim().replace(/\s+/g, " ");
111
+ if (!oneLine) return "<empty>";
112
+ return oneLine.length > limit ? `${oneLine.slice(0, limit - 1)}…` : oneLine;
113
+ }
114
+
72
115
  async function main() {
73
116
  if (!handoffFile || !existsSync(handoffFile)) { log(`no handoff file (${handoffFile}) — abort`); process.exit(1); }
74
117
  const pane = arg("--pane") || orchPane((() => { try { return readFileSync(join(busDir, "crew-windows.txt"), "utf8"); } catch { return ""; } })(), projectName);
@@ -88,7 +131,7 @@ async function main() {
88
131
 
89
132
  // 2. Graceful end, mirroring end_process_gracefully: TERM, short wait, KILL.
90
133
  const info = herdrJson(["pane", "process-info", "--pane", pane])?.result?.process_info;
91
- const pid = Number(info?.foreground_process_group_id) || Number(info?.foreground_processes?.[0]?.pid) || 0;
134
+ const pid = foregroundPid(info) || 0;
92
135
  if (pid > 0 && alive(pid)) {
93
136
  try { process.kill(pid, "SIGTERM"); } catch {}
94
137
  const killAt = Date.now() + 10_000;
@@ -96,18 +139,26 @@ async function main() {
96
139
  if (alive(pid)) { try { process.kill(pid, "SIGKILL"); } catch {} }
97
140
  log(`ended pid ${pid}`);
98
141
  } else {
99
- log("no foreground process to end (already gone)");
142
+ log("no foreground process to end (already gone, or only the pane's shell is in the foreground)");
143
+ }
144
+
145
+ const dropStarted = Date.now();
146
+ const dropDeadline = Number(process.env.TRANTOR_BATON_AGENT_DROP_MS) || 10_000;
147
+ let dropOutcome;
148
+ while ((dropOutcome = agentDropStep(hasAgent(pane), Date.now() - dropStarted, dropDeadline)) === "wait") {
149
+ await sleep(200);
100
150
  }
151
+ log(`agent drop ${dropOutcome} after ${Date.now() - dropStarted}ms`);
101
152
 
102
153
  // 3. Reopen. `trantor open` rebinds orch-sessions.txt and restarts the pane's session — the
103
154
  // bookkeeping the classic seam regression comes from skipping. The drill overrides this to
104
155
  // stay inside its own herdr world.
105
156
  const reopen = process.env.TRANTOR_BATON_REOPEN || "trantor open";
106
- try {
107
- execSync(reopen, { cwd: projectDir, encoding: "utf8", timeout: 120_000, stdio: "pipe" });
108
- log(`reopened via: ${reopen}`);
109
- } catch (e) {
110
- log(`reopen FAILED (${String(e?.message).slice(0, 200)}) — handoff waits on disk`);
157
+ log(`reopen starting via: ${reopen}`);
158
+ const reopened = spawnSync(reopen, { cwd: projectDir, encoding: "utf8", timeout: 120_000, stdio: "pipe", shell: true });
159
+ log(`reopen result: status=${reopened.status ?? "none"} stdout=${summary(reopened.stdout)} stderr=${summary(reopened.stderr)}`);
160
+ if (reopened.error || reopened.status !== 0) {
161
+ log(`reopen FAILED (${summary(reopened.error?.message || reopened.signal || `exit ${reopened.status}`)}) — handoff waits on disk`);
111
162
  process.exit(1);
112
163
  }
113
164
 
@@ -118,7 +169,9 @@ async function main() {
118
169
  }
119
170
  try {
120
171
  const raw = await socketRequest({ id: "trantor:agent.prompt", method: "agent.prompt", params: { target: pane, text: KICKOFF_PROMPT } });
121
- log(`kickoff: ${String(JSON.parse(raw).result?.type)}`);
172
+ const response = JSON.parse(raw);
173
+ const result = response.result?.type || response.error?.code || "unexpected_response";
174
+ log(`kickoff result: ${result}${response.error?.message ? ` (${summary(response.error.message)})` : ""}`);
122
175
  } catch (e) {
123
176
  log(`kickoff FAILED (${String(e?.message).slice(0, 120)}) — successor may sit idle until spoken to`);
124
177
  }
package/bin/baton.mjs CHANGED
@@ -8,7 +8,7 @@ import { join, basename, dirname } from "node:path";
8
8
  import { homedir } from "node:os";
9
9
  import { spawn } from "node:child_process";
10
10
  import { fileURLToPath } from "node:url";
11
- import { writeHandoff, spawnBaton, resolveHandoffSurface, armBaton, contextUsage, controllingTty, turnInFlight, armMaxMs } from "../hooks/lib/handoff.mjs";
11
+ import { writeHandoff, spawnBaton, resolveHandoffSurface, armBaton, contextUsage, controllingTty, turnInFlight, armMaxMs, sessionProcessState } from "../hooks/lib/handoff.mjs";
12
12
 
13
13
  // #6074: the skill path (write-handoff.mjs) and this CLI path must share ONE resolution of which
14
14
  // project this is and where the session lives. Both call resolveHandoffSurface; the name comes
@@ -82,7 +82,13 @@ function autoBaton() {
82
82
  // a turn still in flight ARMS instead of writing: no record, no spawn, and the session's own
83
83
  // Stop hook fires the baton at the boundary, where the summary describes finished work.
84
84
  const force = process.argv.includes("--force");
85
- if (!force && turnInFlight(transcript)) {
85
+ // #6668: a transcript whose session has NO live process is at its boundary, whatever its last
86
+ // row says. The 09-07 12:35 chain armed on a session that had exited at 12:16 (its tail was a
87
+ // tool_result "Connection closed"), then sat in the 17-minute boundary wait for a Stop hook no
88
+ // process would ever run. Write now; the record describes a session that is over.
89
+ const processState = sessionProcessState(sessionId);
90
+ if (processState === "dead") console.log(`session ${sessionId} has no live process — at its boundary, writing now`);
91
+ if (!force && processState !== "dead" && turnInFlight(transcript)) {
86
92
  armBaton(sessionId, {
87
93
  projectDir: cwd,
88
94
  transcript, reason: trigger, windowId: "", tty: controllingTty(),
package/bin/connect.mjs CHANGED
@@ -16,6 +16,14 @@ import { fileURLToPath } from "node:url";
16
16
  const DRY = process.argv.includes("--dry-run");
17
17
  const MCP = join(dirname(dirname(fileURLToPath(import.meta.url))), "mcp.mjs");
18
18
  const URL_ = process.env.RELAY_URL || "http://127.0.0.1:4477";
19
+ // Graft (github.com/NanoNets/context-graph-engine): a local Tree-sitter dependency graph served
20
+ // over MCP (graft_find_code / _find_all / _trace_calls / _file_api / _repo_map). Wired next to
21
+ // `relay` so a seat can locate code with one call instead of grep+read-many — the graph refreshes
22
+ // itself before each query (no freshness hook) and serves the nearest ancestor with a graft/ index,
23
+ // so it keys off the seat's cwd project. A project with no graft/ index simply returns empty tools,
24
+ // never an error. `graft build` (or `graft init`) seeds a project's index; graft/ is gitignored.
25
+ const GRAFT = (() => { try { return execSync("command -v graft", { encoding: "utf8", shell: "/bin/sh" }).trim(); } catch { return "graft"; } })();
26
+ const HAS_GRAFT = GRAFT !== "graft" || (() => { try { execSync("command -v graft", { stdio: "ignore", shell: "/bin/sh" }); return true; } catch { return false; } })();
19
27
  const has = (cmd) => { try { execSync(`command -v ${cmd}`, { stdio: "ignore", shell: "/bin/sh" }); return true; } catch { return false; } };
20
28
  const stamp = new Date().toISOString().slice(0, 10);
21
29
  const backup = (p) => { const b = `${p}.bak-${stamp}`; if (!existsSync(b)) copyFileSync(p, b); return b; };
@@ -65,6 +73,16 @@ if (has("codex")) {
65
73
  if (!DRY) { if (existsSync(p)) backup(p); else mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, cur + block); }
66
74
  report("codex", cur ? "wired" : "wired (new config)", p);
67
75
  }
76
+ // graft alongside relay
77
+ if (HAS_GRAFT) {
78
+ const g = existsSync(p) ? readFileSync(p, "utf8") : "";
79
+ if (g.includes("[mcp_servers.graft]")) report("codex", "graft already wired");
80
+ else {
81
+ const gblock = `\n# trantor — Graft code-graph tools (graft_find_code/_find_all/_trace_calls/_file_api/_repo_map)\n[mcp_servers.graft]\ncommand = "${GRAFT}"\nargs = ["mcp"]\n`;
82
+ if (!DRY) { if (existsSync(p)) backup(p); writeFileSync(p, g + gblock); }
83
+ report("codex", "graft wired", p);
84
+ }
85
+ }
68
86
  }
69
87
 
70
88
  // ---- Gemini CLI ---- (existing relay entries are never overwritten — user customization wins)
@@ -73,6 +91,7 @@ if (has("gemini")) {
73
91
  report("gemini", patchJson(p, d => {
74
92
  d.mcpServers ||= {};
75
93
  d.mcpServers.relay ||= { command: "node", args: [MCP], env: relayEnv("gemini") };
94
+ if (HAS_GRAFT) d.mcpServers.graft ||= { command: GRAFT, args: ["mcp"] };
76
95
  }), p);
77
96
  }
78
97
 
@@ -82,6 +101,7 @@ if (has("kimi")) {
82
101
  report("kimi", patchJson(p, d => {
83
102
  d.mcpServers ||= {};
84
103
  d.mcpServers.relay ||= { command: "node", args: [MCP], env: relayEnv("kimi") };
104
+ if (HAS_GRAFT) d.mcpServers.graft ||= { command: GRAFT, args: ["mcp"] };
85
105
  }), p);
86
106
  }
87
107
 
@@ -92,6 +112,7 @@ if (has("opencode")) {
92
112
  d.$schema ||= "https://opencode.ai/config.json";
93
113
  d.mcp ||= {};
94
114
  d.mcp.relay ||= { type: "local", command: ["node", MCP], enabled: true };
115
+ if (HAS_GRAFT) d.mcp.graft ||= { type: "local", command: [GRAFT, "mcp"], enabled: true };
95
116
  d.mcp.relay.environment ||= {};
96
117
  // Migrate the old generated pin too: `||=` alone left RELAY_AGENT=opencode in every existing
97
118
  // config forever, where OpenCode overlaid it on the qwen/glm/deepseek runner environment.
@@ -135,7 +156,7 @@ if (has("dsh")) {
135
156
  },
136
157
  dsh: { profile: { bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-headless"] } },
137
158
  };
138
- const patch = `# trantor — generated by \`trantor connect\` (edits survive: regenerate by deleting this file)
159
+ const patch = `# trantor — generated by \`trantor connect\` (connect rewrites this file only when it is missing a row; delete it to force a full regen)
139
160
  - insert:
140
161
  - id: trantor-cc-hooks
141
162
  name: '@deepseek-ai/dsh-hooks-claude-code'
@@ -154,10 +175,29 @@ if (has("dsh")) {
154
175
  RELAY_AGENT: !!js process.env.RELAY_AGENT ?? 'dsh'
155
176
  RELAY_PROJECT: !!js process.env.RELAY_PROJECT ?? ''
156
177
  RELAY_SESSION: !!js process.env.RELAY_SESSION ?? ''
157
- `;
178
+ ${HAS_GRAFT ? ` - id: trantor-graft
179
+ name: '@deepseek-ai/dsh-mcp-client'
180
+ config:
181
+ serverName: graft
182
+ transport: stdio
183
+ command: ${GRAFT}
184
+ args: ['mcp']
185
+ ` : ""}`;
186
+ // "a profile exists" is not "a profile is current": connect grows rows over time (relay, then
187
+ // graft, then whatever comes next), and an existence check short-circuits on a profile written
188
+ // by an older connect forever — the seat silently never gets the new row. So the gate is
189
+ // CONTENT-based: every row id this connect would write must already be in the patch; a missing
190
+ // one regenerates the patch (backed up). Presence, not diff — user edits to rows that ARE there
191
+ // still win, the same rule as the gemini/kimi/opencode `||=` patches above.
192
+ const expectedIds = [...patch.matchAll(/- id: (\S+)/g)].map(m => m[1]);
193
+ const cur = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : "";
194
+ const missing = expectedIds.filter(id => !cur.split("\n").some(l => l.trim() === `- id: ${id}`));
158
195
  const fresh = !existsSync(patchPath);
159
- if (!fresh) report("dsh", "already wired", prof);
160
- else {
196
+ if (!fresh && !missing.length) report("dsh", "already wired", prof);
197
+ else if (!fresh) {
198
+ if (!DRY) { backup(patchPath); writeFileSync(patchPath, patch); }
199
+ report("dsh", `regenerated — was missing: ${missing.join(", ")}`, prof);
200
+ } else {
161
201
  if (!DRY) {
162
202
  mkdirSync(prof, { recursive: true });
163
203
  // The seat runs the plugin's hooks MINUS SessionStart: the crew runner already owns
package/bin/crew/open.mjs CHANGED
@@ -1,8 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { call } from "./core.mjs";
5
- import { createWorkspace, herdrCall, paneHasAgent, reportAgent, splitPane, workspaceList, workspacePane } from "./herdr.mjs";
2
+ import { existsSync, openSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, join } from "node:path";
5
+ import { spawn, execSync } from "node:child_process";
6
+ import { call, parseJsonOutput } from "./core.mjs";
7
+ import { createWorkspace, herdrCall, reportAgent, splitPane, workspaceList, workspacePane } from "./herdr.mjs";
6
8
  import { dropState, readRows, recordState } from "./state.mjs";
7
9
  import { resolveOrchestratorDir } from "./worktrees.mjs";
8
10
 
@@ -72,6 +74,19 @@ function orchestratorCommand(ctx, id) {
72
74
  return `${env} claude${harnessFlag(ctx)} ${action}`;
73
75
  }
74
76
 
77
+ export function paneHasAgent(ctx, pane) {
78
+ const result = herdrCall(ctx, ["pane", "process-info", "--pane", pane]);
79
+ const info = parseJsonOutput(result.stdout)?.result?.process_info;
80
+ if (!result.ok || !info) return false;
81
+ const processes = Array.isArray(info.foreground_processes) ? info.foreground_processes : [];
82
+ return processes.some(process => {
83
+ const command = [process.name, process.argv0, process.cmdline, ...(Array.isArray(process.argv) ? process.argv : [])]
84
+ .filter(Boolean)
85
+ .join(" ");
86
+ return /(^|[/\s])claude(?:\.exe)?(?:$|\s)/i.test(command);
87
+ });
88
+ }
89
+
75
90
  function tracked(ctx, live) {
76
91
  let workspace = "";
77
92
  let pane = "";
@@ -91,7 +106,8 @@ function reattach(ctx, workspace, pane, id) {
91
106
  const renamed = herdrCall(ctx, ["pane", "rename", pane, `orchestrator · ${ctx.project}`]);
92
107
  if (!renamed.ok) { dropState(ctx, ctx.project, "orch"); return false; }
93
108
  if (!paneHasAgent(ctx, pane)) {
94
- herdrCall(ctx, ["pane", "run", pane, orchestratorCommand(ctx, id)]);
109
+ const started = herdrCall(ctx, ["pane", "run", pane, orchestratorCommand(ctx, id)]);
110
+ if (!started.ok) throw new Error(`trantor open: could not start the orchestrator in pane ${pane}`);
95
111
  reportAgent(ctx, pane, "claude");
96
112
  console.error(`— orchestrator pane was empty: resumed session ${id} in herdr:${workspace || "?"}/${pane} —`);
97
113
  } else console.error(`— orchestrator already hosted: reattached to herdr:${workspace || "?"}/${pane} —`);
@@ -136,6 +152,38 @@ function hostPane(ctx, chosen, id) {
136
152
  return pane;
137
153
  }
138
154
 
155
+ // #6888: index the project once so its seats' Graft tools (graft_find_code, _trace_calls, …) have a
156
+ // graph to serve. Non-blocking — the pane opens now, the ~7s build lands in the background; a no-op
157
+ // if graft isn't installed or the index already exists (the graph self-refreshes per query after the
158
+ // first build). Keeps the 20MB index out of the project's git.
159
+ export function maybeBuildGraft(dir) {
160
+ if (!dir) return;
161
+ try { execSync("command -v graft", { stdio: "ignore", shell: "/bin/sh" }); } catch { return; }
162
+ try {
163
+ if (existsSync(join(dir, "graft", ".graph"))) return;
164
+ const gi = join(dir, ".gitignore");
165
+ try {
166
+ const cur = existsSync(gi) ? readFileSync(gi, "utf8") : "";
167
+ if (!/^graft\/?$/m.test(cur)) writeFileSync(gi, cur + (cur && !cur.endsWith("\n") ? "\n" : "") + "graft/\n");
168
+ } catch {}
169
+ // The build is backgrounded, so we cannot gate the pane on its exit code — but a failure has to
170
+ // land SOMEWHERE. It used to go to stdio:"ignore", which meant a graft whose native parsers were
171
+ // never built (npm v12 skips node-gyp by default; see deploy/setup.sh) failed in total silence
172
+ // while the line below still claimed an index was being built.
173
+ //
174
+ // The log goes to the OS temp dir rather than the project: nothing to gitignore, nothing to clean
175
+ // up, and no exit handler — after unref() the parent usually dies first, so an on-exit cleanup
176
+ // would be a promise we cannot keep. It is simply overwritten by the next open of this project.
177
+ const log = join(tmpdir(), `trantor-graft-build-${basename(dir)}.log`);
178
+ let fd = "ignore";
179
+ try { fd = openSync(log, "w"); } catch {}
180
+ const child = spawn("graft", ["build", dir], { cwd: dir, stdio: ["ignore", fd, fd], detached: true });
181
+ child.on("error", () => {}); // ENOENT/EACCES: the log and the absent index are the evidence
182
+ child.unref();
183
+ console.error(`— indexing this project for the seats' Graft tools in the background (failures land in ${log}) —`);
184
+ } catch {}
185
+ }
186
+
139
187
  export function openOrchestrator(ctx, args) {
140
188
  let parsed;
141
189
  try { parsed = parseArgs(args); }
@@ -143,6 +191,7 @@ export function openOrchestrator(ctx, args) {
143
191
  if (parsed.help) { usage(); return 0; }
144
192
  try {
145
193
  Object.assign(ctx, resolveOrchestratorDir(ctx, parsed.project));
194
+ if (!ctx.dry) maybeBuildGraft(ctx.dir);
146
195
  if (!ctx.have.herdr) throw new Error("trantor open needs herdr (the pane host) — install: curl -fsSL https://herdr.dev/install.sh | sh");
147
196
  let id = sessionId(ctx);
148
197
  if (hasPendingHandoff(ctx)) {
@@ -28,7 +28,8 @@ import {
28
28
  senderProjectOf, isLinkedProject,
29
29
  } from "../lib/turn-policy.mjs";
30
30
  import {
31
- auditDutyNudges, claudeTranscriptDir, dutyNudgeDirective, observedDutyNudgeIds, planDutyNudges,
31
+ auditDutyNudges, claimDutyNudges, claudeTranscriptDir, dutyEscalations, dutyNudgeDirective,
32
+ observedDutyNudgeIds,
32
33
  } from "../lib/duty-nudges.mjs";
33
34
 
34
35
  const AGENT = process.argv[2];
@@ -468,10 +469,40 @@ async function parkSeat(reason, undelivered, resetHint = 0) {
468
469
  if (orch !== SESSION) await api("/send", { from: SESSION, to: orch, text, project: PROJ, kind: "alert" }).catch(() => {});
469
470
  }
470
471
  log(`\x1b[31mparked (${reason})${when ? ` — retrying after ${when}` : " — no reset time in the output; waiting for a restart"}\x1b[0m`);
472
+ // The two /send calls above are the whole escalation, and on 2026-09-09 that was not enough:
473
+ // the DUTY seat parked on a quota read, held 48 messages for 21.9 hours, and announced it over
474
+ // the very bus that had stopped moving, to an orchestrator that was idle and therefore could not
475
+ // receive it. The alarm for "the bus is stuck" cannot itself be a bus message. So park also
476
+ // rings a bell the operator can actually hear, out of band, once per park.
477
+ notifyOperator(`Trantor: ${SESSION} PARKED (${reason})`,
478
+ `${undelivered} message(s) held${when ? ` — retrying after ${when}` : ` — needs \`trantor up ${AGENT}\``}`);
471
479
  // No reset time means no timer can clear it: hold until the operator restarts the seat.
472
480
  return resetAt || Number.MAX_SAFE_INTEGER;
473
481
  }
474
482
 
483
+ /**
484
+ * Reach the operator on a channel that does not depend on the bus, the hub, or a live session.
485
+ * Best-effort and strictly non-fatal: a seat must never die because a notifier is missing.
486
+ * Silence-able with TRANTOR_NO_DESKTOP_NOTIFY=1 for headless boxes and test runs.
487
+ */
488
+ function notifyOperator(title, body) {
489
+ if (process.env.TRANTOR_NO_DESKTOP_NOTIFY === "1") return;
490
+ try {
491
+ // Always leave a durable trace first: a notification can be missed or suppressed, a file cannot.
492
+ // This is what `trantor doctor` reads, so the escalation survives a machine nobody was sitting at.
493
+ const alertsPath = join(homedir(), ".agent-bus", "alerts.jsonl");
494
+ appendFileSync(alertsPath, `${JSON.stringify({ ts: Date.now(), session: SESSION, title, body })}\n`);
495
+ } catch {}
496
+ try {
497
+ if (process.platform === "darwin") {
498
+ // osascript is present on every mac; no dependency to install and nothing to keep running.
499
+ const esc = (s) => String(s).replace(/["\\]/g, "\\$&");
500
+ spawnSync("osascript", ["-e", `display notification "${esc(body)}" with title "${esc(title)}"`],
501
+ { timeout: 5000, stdio: "ignore" });
502
+ }
503
+ } catch {}
504
+ }
505
+
475
506
  // The seat's own balance rows, for the #6131 read: a stalled turn that printed nothing on a seat
476
507
  // whose plan is spent is exhaustion, not a crash. Bounded and best-effort — a slow provider API
477
508
  // must never hold up the failure path, and an unreachable one just leaves the reason as it was.
@@ -886,7 +917,24 @@ function isRunnerSession(session) {
886
917
  return /^[a-z0-9_.-]+$/.test(label) && !label.startsWith("hub:");
887
918
  }
888
919
 
920
+ // A hub staleness alert describes a condition that was true for a moment: "#16909 has been
921
+ // UNDELIVERED for 2m — go nudge someone". Acting on it 22 hours later is meaningless, and the queue
922
+ // had no expiry, so on 2026-09-09 the duty seat's backlog became SELF-POISONING: the hub kept
923
+ // noticing undelivered mail and sending more alerts, duty could not work them off, and a restart
924
+ // faithfully redelivered 49 dead nudges and re-wedged the seat. 46 of those 49 were hub alerts, the
925
+ // oldest 22.1 hours old, every one describing a two-minute condition.
926
+ //
927
+ // So these EXPIRE. Deliberately narrow: only messages the HUB generated about staleness, never a
928
+ // message from a peer. A real contract is never dropped for being old — a seat that misses a
929
+ // teammate's request is the failure this bus exists to prevent, and no backlog is worth causing it.
930
+ const HUB_ALERT_TTL_MS = Number(process.env.TRANTOR_HUB_ALERT_TTL_MS || 30 * 60_000);
931
+ const isExpiredHubAlert = (m) =>
932
+ m?.from === "hub:duty" &&
933
+ Number.isFinite(m?.ts) &&
934
+ Date.now() - m.ts > HUB_ALERT_TTL_MS;
935
+
889
936
  function shouldWake(message) {
937
+ if (isExpiredHubAlert(message)) return false;
890
938
  if (isReceipt(message) || isStatusBroadcast(message)) return false;
891
939
  // #6134: the SENDER decides. `wake:false` says "this is context, not a contract" — it batches
892
940
  // into the next turn's prompt like a broadcast and never buys a CLI session of its own.
@@ -956,8 +1004,14 @@ function askedExcerpt(message) {
956
1004
  // broadcasts batched behind them. Restored from disk first: a runner that was killed mid-turn
957
1005
  // (or a machine that rebooted) still owes those messages, and the hub will never send them again.
958
1006
  const restored = loadPending();
1007
+ // Say what the restore SHED, not just what it kept. A queue that quietly halves itself on restart
1008
+ // is indistinguishable from one that lost real work, and this is the moment the expiry above
1009
+ // actually bites — a wedged seat comes back carrying only what still means something.
1010
+ const shed = restored.wake.filter(isExpiredHubAlert).length +
1011
+ restored.bcast.filter(isExpiredHubAlert).length;
959
1012
  let pendingWake = restored.wake.filter(shouldWake);
960
- let pendingBcast = restored.bcast.filter(m => !isReceipt(m) && !isStatusBroadcast(m));
1013
+ let pendingBcast = restored.bcast.filter(m => !isExpiredHubAlert(m) && !isReceipt(m) && !isStatusBroadcast(m));
1014
+ if (shed) log(`\x1b[33mdropped ${shed} expired hub staleness alert(s) older than ${Math.round(HUB_ALERT_TTL_MS / 60000)}m — they describe conditions that have long since changed\x1b[0m`);
961
1015
  let retryAt = 0; // 0 = deliver at the next opportunity
962
1016
  let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
963
1017
  if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
@@ -1067,10 +1121,30 @@ function askedExcerpt(message) {
1067
1121
  // queued, on disk, with a backoff — which is the whole point of the change.
1068
1122
  async function deliverWake() {
1069
1123
  const wake = pendingWake;
1070
- const wakeCapped = capWake(wake);
1124
+ const dutyPlan = DUTY_NUDGES
1125
+ ? await claimDutyNudges({
1126
+ messages: wake,
1127
+ statePath: DUTY_NUDGE_STATE,
1128
+ owner: `${RUNNER_ID}:${TURN + 1}`,
1129
+ })
1130
+ : { items: [], targets: [], owner: "" };
1131
+ const claimedIds = new Set(dutyPlan.items.map(item => item.id));
1132
+ const wakeForTurn = DUTY_NUDGES
1133
+ ? wake.filter(message => {
1134
+ const escalation = dutyEscalations([message])[0];
1135
+ return !escalation || claimedIds.has(escalation.id);
1136
+ })
1137
+ : wake;
1138
+ if (!wakeForTurn.length) {
1139
+ pendingWake = [];
1140
+ savePending([], pendingBcast);
1141
+ log("duty escalation already nudged or reserved by another turn — consumed without a duplicate model wake");
1142
+ return;
1143
+ }
1144
+ const wakeCapped = capWake(wakeForTurn);
1071
1145
  const bcastCapped = capBcast(pendingBcast);
1072
1146
  const wakeText = wakeCapped.text
1073
- ? `NEW BUS MESSAGE${wake.length > 1 ? "S" : ""} for you:\n${wakeCapped.text}\n`
1147
+ ? `NEW BUS MESSAGE${wakeForTurn.length > 1 ? "S" : ""} for you:\n${wakeCapped.text}\n`
1074
1148
  : "";
1075
1149
  const ctxText = bcastCapped.text
1076
1150
  ? `\nFYI broadcasts since your last turn (context only):\n${bcastCapped.text}\n`
@@ -1078,29 +1152,26 @@ function askedExcerpt(message) {
1078
1152
  // Say plainly that this is a second look. Without it the model re-reads an old escalation as
1079
1153
  // brand new and can redo work it already half-did before the turn died.
1080
1154
  const againText = deliveryFails
1081
- ? `\n(REDELIVERY, attempt ${deliveryFails + 1} — an earlier turn failed before acting on ${wake.length > 1 ? "these" : "this"}. Check what you already did before repeating it.)\n`
1155
+ ? `\n(REDELIVERY, attempt ${deliveryFails + 1} — an earlier turn failed before acting on ${wakeForTurn.length > 1 ? "these" : "this"}. Check what you already did before repeating it.)\n`
1082
1156
  : "";
1083
1157
  await loadLessons();
1084
1158
  const lessons = pickLessons(LESSONS_RAW, wakeCapped.text + " " + bcastCapped.text);
1085
- const trigger = wake.some(m => m.to === SESSION) ? "direct message" : "@mention";
1159
+ const trigger = wakeForTurn.some(m => m.to === SESSION) ? "direct message" : "@mention";
1086
1160
  // Who is owed an answer, captured BEFORE the turn: pendingWake is cleared on success.
1087
1161
  const assigners = [];
1088
- for (const m of wake) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
1089
- const asked = askedExcerpt(wake[0]);
1162
+ for (const m of wakeForTurn) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
1163
+ const asked = askedExcerpt(wakeForTurn[0]);
1090
1164
  const tStart = Date.now();
1091
1165
  // #6134: ONE SESSION PER CARD. A seat that resumes forever carries every card it ever worked
1092
1166
  // into every later turn — qwen's 85.7M tokens were 96.7% cached, i.e. replayed history. The
1093
1167
  // card that moved this wake decides: a different one starts a fresh CLI session, and the seat
1094
1168
  // is told so, because a fresh session remembers nothing and must be sent to its card.
1095
- const card = wake.map(m => cardRef(m.text)).find(Boolean) || 0;
1169
+ const card = wakeForTurn.map(m => cardRef(m.text)).find(Boolean) || 0;
1096
1170
  const fresh = card > 0 && card !== sessionCard;
1097
1171
  if (card) sessionCard = card;
1098
1172
  const freshText = fresh
1099
1173
  ? `\n(FRESH SESSION for card #${card} — you are not the session that worked earlier cards and you remember none of them. Read your card first: relay_board with card:${card}.)\n`
1100
1174
  : "";
1101
- const dutyPlan = DUTY_NUDGES
1102
- ? planDutyNudges(wake, DUTY_NUDGE_STATE)
1103
- : { items: [], targets: [] };
1104
1175
  const prompt = composedTurn({
1105
1176
  wakeText, ctxText, againText: againText + freshText + dutyNudgeDirective(dutyPlan),
1106
1177
  tailText: "\nAct on what's addressed to you, then end your turn.\n\n",
@@ -1165,6 +1236,21 @@ function askedExcerpt(message) {
1165
1236
  const parkReason = PARKING_REASONS.has(reason) ? reason : (lastTurnCut ? "time-box" : "api-error");
1166
1237
  if (PARKING_REASONS.has(reason) || deliveryFails >= 2) {
1167
1238
  retryAt = await parkSeat(parkReason, pendingWake.length, quotaReset);
1239
+ // A supervised seat does not have to sit parked until someone notices. RUNNER_PARK_MAX_MS
1240
+ // is set only by `trantor duty up`, which runs the seat under a launchd keepalive: past the
1241
+ // ceiling, exit and let the supervisor restart it clean — a fresh process re-reads auth and
1242
+ // redelivers the queue from disk, which is exactly what un-wedged the 2026-09-09 incident
1243
+ // when the operator finally ran `trantor duty up` by hand 21.9 hours late.
1244
+ // Unsupervised seats keep the old behaviour: exiting would just kill them for good.
1245
+ const parkMax = Number(process.env.RUNNER_PARK_MAX_MS || 0);
1246
+ if (parkMax > 0) {
1247
+ const wakeIn = Math.max(0, Math.min(retryAt - Date.now(), parkMax));
1248
+ log(`\x1b[33msupervised seat: exiting in ${Math.round(wakeIn / 1000)}s so the keepalive restarts it clean\x1b[0m`);
1249
+ setTimeout(() => {
1250
+ log("parked past the ceiling — exiting for the keepalive to relaunch");
1251
+ process.exit(0); // 0, not 1: this is a deliberate hand-off, not a crash
1252
+ }, wakeIn).unref?.();
1253
+ }
1168
1254
  await notifyAssigners(assigners,
1169
1255
  `⛔ your contract is PARKED on ${SESSION} (${parkReason}) — not retrying · asked: "${asked}"`);
1170
1256
  lastTurnAt = Date.now();
package/bin/doctor.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // Checks: runtime, hub, plugin, each CLI (installed? wired? AUTHENTICATED?), API keys,
4
4
  // quota profile, optional Scrooge brain. Prints a checklist with copy-paste fixes.
5
5
  // node bin/doctor.mjs
6
- import { readFileSync, existsSync } from "node:fs";
6
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
7
7
  import { join, dirname } from "node:path";
8
8
  import { homedir } from "node:os";
9
9
  import { execSync } from "node:child_process";
@@ -304,6 +304,66 @@ prof?.providers && Object.keys(prof.providers).length
304
304
  ? ok(`quota profile set (${Object.entries(prof.providers).map(([k, v]) => `${k}=${v.plan}`).join(", ")})`)
305
305
  : warn("quota profile not set — the Advisor will assume API billing everywhere", `node ${join(ROOT, "bin", "profile.mjs")} set claude=max codex=plus deepseek=api … (use YOUR real plans)`);
306
306
 
307
+ // fleet — is the crew actually OPERATING, not just installed?
308
+ //
309
+ // Added 2026-09-09, because on that morning this command reported nine issues, every one about
310
+ // provider keys and billing attribution, while the duty seat had been holding 48 undelivered
311
+ // messages for 21.9 hours and the orchestrator had slept through a night of finished crew work.
312
+ // Doctor checked whether credentials EXIST. Nothing checked whether the fleet was MOVING. These
313
+ // two signals are both already on disk, written by the runner itself — nobody was reading them.
314
+ section("the fleet (is it actually running?)");
315
+ {
316
+ const busDir = join(H, ".agent-bus");
317
+ // 1. Undelivered queues. crew-runner persists pending-<agent>-<project>.json on every failed
318
+ // delivery and unlinks it when the queue drains, so a file with an old head means mail is
319
+ // stuck for that seat — whatever its process table says.
320
+ let stuck = 0;
321
+ const abandonedSeats = [];
322
+ try {
323
+ for (const f of readdirSync(busDir).filter(n => n.startsWith("pending-") && n.endsWith(".json"))) {
324
+ const j = read(join(busDir, f));
325
+ const held = [...(j?.wake || []), ...(j?.bcast || [])];
326
+ if (!held.length) continue;
327
+ const stamps = held.map(m => m?.ts).filter(Number.isFinite);
328
+ const oldest = stamps.length ? Math.min(...stamps) : j?.ts;
329
+ const hours = (Date.now() - oldest) / 3.6e6;
330
+ const seat = f.replace(/^pending-|\.json$/g, "");
331
+ // Three bands, because one flat warning per stuck queue is its own failure: this machine has
332
+ // leftovers from projects that ended weeks ago, and a doctor that cries about ten of them
333
+ // every run teaches you to skim past the one that matters. Under an hour is the retry ladder
334
+ // doing its job. Over a week is an abandoned seat, worth tidying, not worth alarming about.
335
+ // The band between is the live stall — the shape of the 2026-09-09 incident.
336
+ const abandoned = hours >= 24 * 7;
337
+ if (hours >= 1 && !abandoned) {
338
+ stuck++;
339
+ warn(`${seat}: ${held.length} message(s) undelivered, oldest ${hours.toFixed(1)}h old — mail is not moving`,
340
+ `the runner parks on quota/api failure and only a restart un-parks it: trantor up ${seat.split("-")[0]} (duty: trantor duty up)`);
341
+ } else if (abandoned) {
342
+ abandonedSeats.push(`${seat} (${(hours / 24).toFixed(0)}d)`);
343
+ } else {
344
+ note(`${seat}: ${held.length} queued, oldest ${hours.toFixed(1)}h — within the retry ladder`);
345
+ }
346
+ }
347
+ if (abandonedSeats.length) {
348
+ note(`${abandonedSeats.length} abandoned queue(s) older than a week: ${abandonedSeats.join(", ")} — leftovers from finished work, safe to delete`);
349
+ }
350
+ if (!stuck) ok("no live seat is sitting on undelivered mail");
351
+ } catch { note(`no bus directory at ${busDir} yet — nothing has run`); }
352
+
353
+ // 2. Park alerts. notifyOperator appends one line per park, so a park that happened while nobody
354
+ // was at the machine is still visible here afterwards — the point of writing it to disk.
355
+ try {
356
+ const alerts = readFileSync(join(busDir, "alerts.jsonl"), "utf8").trim().split("\n").filter(Boolean);
357
+ const recent = alerts.map(l => { try { return JSON.parse(l); } catch { return null; } })
358
+ .filter(a => a && Date.now() - a.ts < 24 * 3.6e6);
359
+ if (recent.length) {
360
+ const last = recent[recent.length - 1];
361
+ warn(`${recent.length} seat park alert(s) in the last 24h — most recent: ${last.title}`,
362
+ `read them: tail ~/.agent-bus/alerts.jsonl — then restart the seat named above`);
363
+ } else ok("no seat has parked in the last 24h");
364
+ } catch { ok("no seat has parked in the last 24h"); }
365
+ }
366
+
307
367
  say(issues ? `\n${issues} issue(s) — fix the → lines above, then re-run the doctor.` : "\nAll clear — open a claude session in any project and say: \"fire up the crew\".");
308
368
  // Must come BEFORE the exit — process.exit() here truncated the report entirely.
309
369
  if (JSON_MODE) console.log(JSON.stringify({ ...REPORT, issueCount: issues }));