trantor 0.17.69 → 0.17.72

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.17.69",
3
+ "version": "0.17.72",
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/adopt.mjs CHANGED
@@ -18,9 +18,10 @@
18
18
  import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import { homedir } from "node:os";
21
- import { hostId } from "../lib/project.mjs";
21
+ import { hostId, DEFAULT_HUB_URL } from "../lib/project.mjs";
22
22
  import { loadOrCreate, signRequest } from "../lib/identity.mjs";
23
23
  import { sfetchJson } from "../lib/signed-fetch.mjs";
24
+ import { scan } from "../lib/splitbrain.mjs";
24
25
 
25
26
  const argv = process.argv.slice(2);
26
27
  const PROJECT = argv.find(a => !a.startsWith("--")) || "";
@@ -104,12 +105,44 @@ try {
104
105
  config.hubs[PROJECT] = TARGET;
105
106
  writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
106
107
  console.log(`pinned : ${PROJECT} → ${TARGET}`);
108
+
109
+ // TELL the stale sessions, don't just print at a human who may never see this terminal again.
110
+ // A live session holds its hub URL for its whole life: its MCP server resolved the route at
111
+ // boot and nothing re-reads config.json. So the moment the pin is written, every one of these
112
+ // is recording onto a hub nobody reads any more — the exact split-brain crebral-health spent
113
+ // two sessions diagnosing. They are still listening on the OLD hub, so that is where the
114
+ // notice has to go.
107
115
  if (livePeers.length) {
108
- console.log(`\n⚠ live sessions still route to the OLD hub until restarted:`);
116
+ const notice = `📦 ${PROJECT} has MOVED to ${TARGET}. You are still bound to ${LOCAL}, so your cards and messages now land on a hub nobody is reading. RESTART to pick up the pin — crew seats: \`trantor down && trantor up\` · Claude sessions: restart the session.`;
117
+ let told = 0;
118
+ for (const [session] of livePeers) {
119
+ try {
120
+ await sfetchJson(`${LOCAL}/send`, { identity: ownerId, payload: { from: owner, to: session, project: PROJECT, text: notice }, signal: AbortSignal.timeout(8000) });
121
+ told++;
122
+ } catch (e) { console.log(` ⚠ could not notify ${session}: ${e.message}`); }
123
+ }
124
+ // …and once to the room, for anything live that never registered as a peer.
125
+ try { await sfetchJson(`${LOCAL}/send`, { identity: ownerId, payload: { from: owner, to: "all", project: PROJECT, text: notice }, signal: AbortSignal.timeout(8000) }); } catch {}
126
+ console.log(`\n⚠ ${livePeers.length} live session(s) still route to the OLD hub — told ${told} of them to restart:`);
109
127
  for (const [s] of livePeers) console.log(` ${s}`);
110
128
  console.log(` crew seats: trantor down && trantor up · Claude sessions: restart them when convenient.`);
111
129
  }
112
130
  console.log(`\n✓ adopted. New sessions on ${PROJECT} land on ${TARGET}.`);
131
+
132
+ // Prove the move actually landed as one hub, rather than trusting that it did. A migration is
133
+ // precisely the moment a project is most likely to end up living in two places at once.
134
+ try {
135
+ const { findings, blind } = await scan(config, ownerId, { defaultUrl: DEFAULT_HUB_URL, timeoutMs: 6000 });
136
+ const mine = findings.filter(f => f.project === PROJECT);
137
+ if (mine.length) {
138
+ console.log(`\n⚠ split-brain check on ${PROJECT}:`);
139
+ for (const f of mine) { console.log(` ${f.message}`); console.log(` → ${f.fix}`); }
140
+ } else if (blind.length) {
141
+ console.log(`\nsplit-brain check: partial — could not read ${blind.map(b => b.url).join(", ")}`);
142
+ } else {
143
+ console.log(`split-brain check: clean — ${PROJECT} is live on one hub only.`);
144
+ }
145
+ } catch {}
113
146
  } catch (e) {
114
147
  console.error(`\n✗ adopt failed: ${e.message}`);
115
148
  console.error("nothing was pinned — routing is unchanged.");
@@ -9,7 +9,7 @@
9
9
  // agent arrives it RESUMES the CLI session (native resume = full context kept) with that
10
10
  // message as the prompt. The model just works and ends its turn; the runner does the rest.
11
11
  import { execSync, spawnSync } from "node:child_process";
12
- import { readFileSync, existsSync, appendFileSync } from "node:fs";
12
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync } from "node:fs";
13
13
  import { join, basename } from "node:path";
14
14
  import { homedir } from "node:os";
15
15
  import { resolveProject, resolveHub } from "../lib/project.mjs";
@@ -174,6 +174,39 @@ let consecFails = 0;
174
174
  let lastErrText = "";
175
175
  const ERRF = join(homedir(), ".agent-bus", `err-${AGENT}-${PROJ}.txt`);
176
176
 
177
+ // ---- undelivered wake messages (the runner owns delivery, not the hub) ----
178
+ // The hub hands a message out exactly ONCE: the poll cursor advances the instant we read it, and
179
+ // nothing ever re-fires. So a turn that died — API outage, quota wall, crashed CLI — used to take
180
+ // its wake message down with it, and an escalation addressed to this seat was gone forever with
181
+ // no trace anywhere. The queue below makes delivery the runner's job: a message is not consumed
182
+ // until a turn actually exits 0. It survives a runner restart on disk, retries on its own backoff
183
+ // so a silent bus still gets it through, and says how many are outstanding every time it reports.
184
+ const PENDF = join(homedir(), ".agent-bus", `pending-${AGENT}-${PROJ}.json`);
185
+ // A cap, so a long outage cannot grow the queue without bound. Overflow drops the OLDEST and says
186
+ // so on the bus — a silent drop is the exact failure this whole mechanism exists to end.
187
+ const PENDING_MAX = 50;
188
+ // Backoff between redelivery attempts. Starts fast (a blip clears in 30s) and lands at 15 minutes,
189
+ // which is the cadence for "this seat is properly down" rather than a retry storm against a hub
190
+ // that is already refusing us.
191
+ // TRANTOR_RETRY_MS (comma-separated ms) shortens the ladder so the redelivery drill can exercise
192
+ // a real backoff in seconds instead of waiting out the production one.
193
+ const RETRY_MS = (() => {
194
+ const custom = String(process.env.TRANTOR_RETRY_MS || "").split(",").map(Number).filter(n => Number.isFinite(n) && n >= 0);
195
+ return custom.length ? custom : [30e3, 60e3, 120e3, 300e3, 900e3];
196
+ })();
197
+ function savePending(wake, bcast) {
198
+ try {
199
+ if (!wake.length && !bcast.length) { try { unlinkSync(PENDF); } catch {} return; }
200
+ writeFileSync(PENDF, JSON.stringify({ agent: AGENT, project: PROJ, ts: Date.now(), wake, bcast }));
201
+ } catch {}
202
+ }
203
+ function loadPending() {
204
+ try {
205
+ const j = JSON.parse(readFileSync(PENDF, "utf8"));
206
+ return { wake: Array.isArray(j.wake) ? j.wake : [], bcast: Array.isArray(j.bcast) ? j.bcast : [] };
207
+ } catch { return { wake: [], bcast: [] }; }
208
+ }
209
+
177
210
  function classifyFailure(exit, errText) {
178
211
  const t = (errText || "").toLowerCase();
179
212
  if (exit === 127) return "missing-cli";
@@ -184,7 +217,7 @@ function classifyFailure(exit, errText) {
184
217
  return "crashed";
185
218
  }
186
219
 
187
- async function reportFailure(exit, trigger) {
220
+ async function reportFailure(exit, trigger, undelivered = 0) {
188
221
  consecFails++;
189
222
  const reason = classifyFailure(exit, lastErrText);
190
223
  const down = consecFails >= 2;
@@ -193,9 +226,12 @@ async function reportFailure(exit, trigger) {
193
226
  const hint = reason === "exhausted" ? " — needs `trantor swap`"
194
227
  : reason === "auth" ? " — check credentials"
195
228
  : reason === "missing-cli" ? " — CLI not on PATH" : "";
229
+ // The count of messages this seat is HOLDING is the operator-actionable half of a failure: a
230
+ // crashed pulse costs nothing, a crashed turn sitting on three escalations is someone waiting.
231
+ const held = undelivered ? ` · holding ${undelivered} undelivered message${undelivered > 1 ? "s" : ""} (will retry)` : "";
196
232
  const text = down
197
- ? `🛑 ${SESSION} DOWN — ${consecFails} consecutive failures (${reason}, exit ${exit})${hint}`
198
- : `⚠️ ${SESSION} turn FAILED (${trigger}, exit ${exit} · ${reason})${hint}`;
233
+ ? `🛑 ${SESSION} DOWN — ${consecFails} consecutive failures (${reason}, exit ${exit})${hint}${held}`
234
+ : `⚠️ ${SESSION} turn FAILED (${trigger}, exit ${exit} · ${reason})${hint}${held}`;
199
235
  await api("/send", { from: SESSION, to: "all", text, project: PROJ }).catch(() => {});
200
236
  cmuxStatus(down ? "down" : "error", "#ef6a6a", "alert", { alert: true, priority: 90 }); cmuxLog(`turn failed: ${reason} (exit ${exit})`, "error");
201
237
  log(`\x1b[31mreported failure to bus: ${reason} (exit ${exit})\x1b[0m`);
@@ -278,9 +314,18 @@ async function loadLessons() {
278
314
  });
279
315
  } catch {}
280
316
 
281
- let pendingBcast = [];
317
+ // Wake messages this seat has PULLED off the bus but not yet worked successfully, plus the
318
+ // broadcasts batched behind them. Restored from disk first: a runner that was killed mid-turn
319
+ // (or a machine that rebooted) still owes those messages, and the hub will never send them again.
320
+ const restored = loadPending();
321
+ let pendingWake = restored.wake;
322
+ let pendingBcast = restored.bcast;
323
+ let retryAt = 0; // 0 = deliver at the next opportunity
324
+ let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
325
+ if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
326
+
282
327
  const ec0 = runTurn(KICKOFF + LESSONS, true, "kickoff");
283
- if (ec0) await reportFailure(ec0, "kickoff"); // a failed kickoff = the "fired up, died, nobody knew" case
328
+ if (ec0) await reportFailure(ec0, "kickoff", pendingWake.length); // a failed kickoff = the "fired up, died, nobody knew" case
284
329
  let lastTurnAt = Date.now();
285
330
  if (PULSE_MS) log(`pulse armed — mission re-read every ${Math.round(PULSE_MS / 1000)}s (${MISSION_FILE})`);
286
331
  log(`parked — long-polling the bus as ${SESSION} (free; this poll is also the heartbeat)`);
@@ -295,9 +340,15 @@ async function loadLessons() {
295
340
  log("parked — waiting for the next message or pulse");
296
341
  continue;
297
342
  }
298
- // cap the long-poll hold so a due pulse never waits out a full silent 280s window
299
- const holdS = PULSE_MS
300
- ? Math.max(5, Math.min(280, Math.ceil((PULSE_MS - (Date.now() - lastTurnAt)) / 1000)))
343
+ // A due REDELIVERY runs before we go back to waiting during an outage the bus is silent by
344
+ // definition, so the retry timer is the only thing that will ever move these messages.
345
+ if (pendingWake.length && Date.now() >= retryAt) { await deliverWake(); continue; }
346
+ // cap the long-poll hold so neither a due pulse nor a due redelivery waits out a silent 280s window
347
+ const due = [];
348
+ if (PULSE_MS) due.push(PULSE_MS - (Date.now() - lastTurnAt));
349
+ if (pendingWake.length) due.push(retryAt - Date.now());
350
+ const holdS = due.length
351
+ ? Math.max(5, Math.min(280, Math.ceil(Math.min(...due) / 1000)))
301
352
  : 280;
302
353
  let msgs = [];
303
354
  try {
@@ -320,15 +371,51 @@ async function loadLessons() {
320
371
  const bcast = msgs.filter(m => m.to === "all" && !mentions.includes(m));
321
372
  pendingBcast.push(...bcast); // wake-policy: plain broadcasts batch, they don't wake
322
373
  const wake = [...direct, ...mentions];
323
- if (!wake.length) { if (bcast.length) log(`${bcast.length} broadcast(s) batched (no wake) — ${pendingBcast.length} pending`); continue; }
374
+ if (!wake.length) { if (bcast.length) { savePending(pendingWake, pendingBcast); log(`${bcast.length} broadcast(s) batched (no wake) — ${pendingBcast.length} pending`); } continue; }
375
+ // Queue BEFORE running the turn, and persist immediately. Everything between here and a clean
376
+ // exit 0 — the CLI dying, the machine losing power — now leaves a record of what this seat owes.
377
+ pendingWake.push(...wake);
378
+ if (pendingWake.length > PENDING_MAX) {
379
+ const dropped = pendingWake.splice(0, pendingWake.length - PENDING_MAX);
380
+ log(`\x1b[31mundelivered queue overflowed — dropped ${dropped.length} oldest message(s)\x1b[0m`);
381
+ await api("/send", { from: SESSION, to: "all", project: PROJ,
382
+ text: `⚠️ ${SESSION} dropped ${dropped.length} undelivered message(s) — queue hit its ${PENDING_MAX} cap during a failure streak` }).catch(() => {});
383
+ }
384
+ savePending(pendingWake, pendingBcast);
385
+ // Respect an active backoff: a new message during an outage joins the batch, it does not
386
+ // reset the clock and hammer a CLI that is already failing.
387
+ if (Date.now() < retryAt) { log(`queued — ${pendingWake.length} undelivered, next attempt in ${Math.max(0, Math.round((retryAt - Date.now()) / 1000))}s`); continue; }
388
+ await deliverWake();
389
+ log("parked — waiting for the next message");
390
+ }
391
+
392
+ // Run the pending batch. The messages are cleared ONLY on exit 0; any other outcome leaves them
393
+ // queued, on disk, with a backoff — which is the whole point of the change.
394
+ async function deliverWake() {
395
+ const wake = pendingWake;
324
396
  const ctx = pendingBcast.length ? `\nFYI broadcasts since your last turn (context only):\n${pendingBcast.map(m => `[${m.from} -> all]: ${m.text}`).join("\n")}\n` : "";
325
- pendingBcast = [];
326
397
  const lines = wake.map(m => `[${m.from}${m.to === "all" ? " -> all (mentions you)" : ""}]: ${m.text}`).join("\n");
327
- const prompt = `NEW BUS MESSAGE${wake.length > 1 ? "S" : ""} for you:\n${lines}\n${ctx}\nAct on what's addressed to you, then end your turn.\n\n${RULES}`;
398
+ // Say plainly that this is a second look. Without it the model re-reads an old escalation as
399
+ // brand new and can redo work it already half-did before the turn died.
400
+ const again = deliveryFails
401
+ ? `\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`
402
+ : "";
403
+ const prompt = `NEW BUS MESSAGE${wake.length > 1 ? "S" : ""} for you:\n${lines}\n${ctx}${again}\nAct on what's addressed to you, then end your turn.\n\n${RULES}`;
328
404
  await loadLessons();
329
- const ec = runTurn(prompt + LESSONS, false, direct.length ? "direct message" : "@mention");
330
- if (ec) await reportFailure(ec, "message"); else await reportHealthy();
405
+ const trigger = wake.some(m => m.to === SESSION) ? "direct message" : "@mention";
406
+ const ec = runTurn(prompt + LESSONS, false, deliveryFails ? `${trigger} (redelivery)` : trigger);
407
+ if (ec) {
408
+ deliveryFails++;
409
+ const wait = RETRY_MS[Math.min(deliveryFails - 1, RETRY_MS.length - 1)];
410
+ retryAt = Date.now() + wait;
411
+ savePending(pendingWake, pendingBcast);
412
+ await reportFailure(ec, "message", pendingWake.length);
413
+ log(`\x1b[31m${pendingWake.length} message(s) still UNDELIVERED — next attempt in ${Math.round(wait / 1000)}s\x1b[0m`);
414
+ } else {
415
+ pendingWake = []; pendingBcast = []; deliveryFails = 0; retryAt = 0;
416
+ savePending([], []);
417
+ await reportHealthy();
418
+ }
331
419
  lastTurnAt = Date.now();
332
- log("parked — waiting for the next message");
333
420
  }
334
421
  })();
package/bin/doctor.mjs CHANGED
@@ -8,6 +8,9 @@ import { join, dirname } from "node:path";
8
8
  import { homedir } from "node:os";
9
9
  import { execSync } from "node:child_process";
10
10
  import { fileURLToPath } from "node:url";
11
+ import { resolveProject, resolveHub, DEFAULT_HUB_URL } from "../lib/project.mjs";
12
+ import { loadOrCreate } from "../lib/identity.mjs";
13
+ import { scan } from "../lib/splitbrain.mjs";
11
14
 
12
15
  const H = homedir();
13
16
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -39,7 +42,11 @@ say("TRANTOR DOCTOR\n");
39
42
  section("core");
40
43
  Number(process.versions.node.split(".")[0]) >= 18 ? ok(`node ${process.versions.node}`) : warn(`node ${process.versions.node} too old`, "install node >= 18");
41
44
  const cfg = read(join(H, ".agent-bus", "config.json")) || {};
42
- const HUB = process.env.RELAY_URL || cfg.url || "http://127.0.0.1:4477";
45
+ // The hub THIS directory's project actually routes to — pins first. Reading only the global
46
+ // default meant the doctor could report a healthy local hub while every session in the project
47
+ // was talking to netcup, which is exactly the blindness the routing section below exists to end.
48
+ const PROJECT = resolveProject(process.cwd());
49
+ const HUB = resolveHub(PROJECT);
43
50
  try {
44
51
  const h = await (await fetch(`${HUB}/health`, { signal: AbortSignal.timeout(2000) })).json();
45
52
  ok(`hub up at ${HUB} (${h.peers} peers known)`);
@@ -56,6 +63,30 @@ if (pkg?.version) {
56
63
  tooOld ? warn(`trantor v${pkg.version} too old — heartbeat/presence requires v0.17.0+`, "npm update -g trantor") : ok(`trantor v${pkg.version}`);
57
64
  } else warn("could not read trantor version", "reinstall: npm install -g trantor");
58
65
 
66
+ // ── hub routing: is any project split across two hubs? ───────────────────────────────────────
67
+ // Cards, messages and collision detection only work over ONE hub. A project split across two
68
+ // breaks silently — every seat reports healthy and half the work records where nobody looks.
69
+ section("hub routing");
70
+ {
71
+ const pin = (cfg.hubs || {})[PROJECT] || "";
72
+ say(` ${PROJECT} → ${HUB}${pin ? " (pinned)" : process.env.RELAY_URL ? " (RELAY_URL override)" : " (unpinned — falls back to the default)"}`);
73
+ const owner = String(cfg.ownerIdentity || "");
74
+ // Unsigned, an enforce hub answers "signature required" and a full hub reads as deserted. Sign
75
+ // as the owner when we have one, and say plainly when we cannot rather than guessing.
76
+ const identity = owner ? (() => { try { return loadOrCreate(owner, "human"); } catch { return null; } })() : null;
77
+ if (!identity) note("no owner identity in config — hubs are probed UNSIGNED, so an enforce hub will refuse the read");
78
+ let scanned = null;
79
+ try { scanned = await scan(cfg, identity, { defaultUrl: DEFAULT_HUB_URL, timeoutMs: 6000 }); }
80
+ catch (e) { note(`split-brain check could not run (${e?.message || e})`); }
81
+ if (scanned) {
82
+ REPORT.splitbrain = { findings: scanned.findings, blind: scanned.blind, checked: scanned.checked };
83
+ for (const b of scanned.blind) warn(`hub ${b.url} could not be read — ${b.reason}`, "detection is PARTIAL until this hub answers; a split hiding behind it will not be reported");
84
+ for (const f of scanned.findings) f.severity === "warn" ? warn(f.message, f.fix) : warn(`SPLIT-BRAIN — ${f.message}`, f.fix);
85
+ if (!scanned.findings.length && !scanned.blind.length) ok(`no split-brain — every live project sits on exactly one hub (${scanned.checked} hub${scanned.checked === 1 ? "" : "s"} cross-checked)`);
86
+ else if (!scanned.findings.length) ok(`no split-brain among the ${scanned.checked} hub${scanned.checked === 1 ? "" : "s"} that answered`);
87
+ }
88
+ }
89
+
59
90
  // claude plugin
60
91
  section("claude (the orchestrator)");
61
92
  if (!has("claude")) warn("claude CLI not found", "install Claude Code: https://claude.com/claude-code");
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // trantor focus-title — give a session's focus card a title a human can skim, written by a CHEAP model.
3
+ //
4
+ // node bin/focus-title.mjs --id <cardId> --hub <url> --prompt-file <path> [--project <p>]
5
+ //
6
+ // The focus card is titled from the user's raw prompt by a regex in hooks/prompt-focus.mjs. That is
7
+ // the right thing to do IN the turn — a hook that waits on an LLM delays every prompt the user
8
+ // types — but a raw prompt makes a poor board card: it is long, it is addressed to Claude rather
9
+ // than describing work, and half of it is context the board does not need. So the hook posts the
10
+ // heuristic title instantly and hands the rewrite to this, DETACHED: the card is on the board in
11
+ // milliseconds and gets its readable line a few seconds later.
12
+ //
13
+ // Economics (the Scrooge doctrine): one `-t summarize -d easy` call, only for prompts the heuristic
14
+ // actually mangles — the hook does not even spawn this for a short, already-clear prompt. The
15
+ // result lands in `summary`, the same field the board already prefers over `title`, and the hub
16
+ // clears it on every refocus so a stale line can never shadow live work.
17
+ import { execSync, spawnSync } from "node:child_process";
18
+ import { readFileSync, existsSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { homedir } from "node:os";
21
+ import { loadOrCreate } from "../lib/identity.mjs";
22
+ import { sfetchJson } from "../lib/signed-fetch.mjs";
23
+
24
+ const argv = process.argv.slice(2);
25
+ const val = (k) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? (argv[i + 1] ?? "") : ""; };
26
+ const ID = Number(val("id"));
27
+ const HUB = val("hub");
28
+ const PROMPT_FILE = val("prompt-file");
29
+ if (!ID || !HUB || !PROMPT_FILE) process.exit(0); // nothing to do; never a visible failure
30
+
31
+ const scroogeBin = () => process.env.SCROOGE_BIN
32
+ || (() => { try { return execSync("command -v scrooge", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return ""; } })()
33
+ || (existsSync(new URL("../engine/bin/scrooge", import.meta.url)) ? new URL("../engine/bin/scrooge", import.meta.url).pathname : "");
34
+
35
+ try {
36
+ const raw = readFileSync(PROMPT_FILE, "utf8").replace(/\s+/g, " ").trim();
37
+ if (!raw) process.exit(0);
38
+ const bin = scroogeBin();
39
+ if (!bin) process.exit(0); // no economics engine installed — heuristic title stands
40
+
41
+ const ask = `Rewrite this message to an AI coding assistant as a Kanban card title: what the WORK is, action first, in plain words a human skims. At most 70 characters. No quotes, no trailing period, no "the user wants". If it is several requests, name the main one. Return ONLY the title.
42
+
43
+ ${raw.slice(0, 1800)}`;
44
+ const res = spawnSync(bin, ["-t", "summarize", "-d", "easy"], { input: ask, encoding: "utf8", timeout: 60000 });
45
+ if (res.error || !res.stdout) process.exit(0);
46
+ // A cheap model sometimes wraps or explains. Take the first non-empty line and strip the wrapper.
47
+ const line = String(res.stdout).split("\n").map(l => l.trim()).find(l => l && !/^```/.test(l)) || "";
48
+ const title = line.replace(/^["'`]+|["'`.]+$/g, "").replace(/^(title|card)\s*:\s*/i, "").trim().slice(0, 70);
49
+ // Guard against the failure modes that would make the board WORSE than the heuristic: an empty
50
+ // answer, a refusal, or the model echoing the prompt back at us.
51
+ if (title.length < 8 || /^(sorry|i can|as an ai)/i.test(title) || title.toLowerCase() === raw.toLowerCase().slice(0, title.length)) process.exit(0);
52
+
53
+ const owner = (() => { try { return JSON.parse(readFileSync(join(process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus"), "config.json"), "utf8")).ownerIdentity; } catch { return ""; } })();
54
+ const identity = loadOrCreate(owner || "admin", "human");
55
+ await sfetchJson(`${HUB}/task/update`, { identity, payload: { id: ID, summary: title, by: "scrooge-focus-title" }, signal: AbortSignal.timeout(8000) });
56
+ } catch { /* a board title is never worth surfacing an error for */ }
57
+ process.exit(0);
@@ -106,7 +106,10 @@ async function maybeEarlyWarn(stdinRaw, session) {
106
106
  }
107
107
 
108
108
  async function main(stdinRaw) {
109
- const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
109
+ // input.cwd FIRST every hook must derive the project the SAME way, or two hooks in one
110
+ // session resolve two projects, two hubs, and half the work records where nobody reads.
111
+ let _in = {}; try { _in = JSON.parse(stdinRaw || "{}"); } catch {}
112
+ const projectDir = _in.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
110
113
  // Mirror sessionstart.mjs: home-directory sessions aren't project work — don't register
111
114
  // them (would spawn a phantom "<username>" board). Opt in with RELAY_SESSION/RELAY_PROJECT.
112
115
  if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return;
@@ -33,20 +33,28 @@ const FETCH_TIMEOUT_MS = Number(process.env.RELAY_INBOX_TIMEOUT_MS || 1500);
33
33
  // additionalContext payload (the model still gets the readable message).
34
34
  function sanitize(s) { return String(s == null ? "" : s).replace(/[\x00-\x1f\x7f-\x9f]/g, " "); }
35
35
 
36
- async function getInbox(session, since, instance) {
37
- const { ok, json } = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${since}`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance });
36
+ async function getInbox(session, since, instance, project) {
37
+ const { ok, json } = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${since}`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance, project });
38
38
  if (!ok || !json) throw new Error("hub unreachable");
39
39
  return json; // { messages: [...], cursor, superseded? }
40
40
  }
41
41
 
42
- // PostToolUse hands us the tool-input JSON on stdin. We don't need it, but we must DRAIN it:
43
- // a large tool input (e.g. a big Write) can exceed the 64KB pipe buffer and block the parent's
44
- // write if nobody reads. Consume + discard, with a short timeout so we never hang.
42
+ // PostToolUse hands us the tool-input JSON on stdin, and we MUST drain it: a large tool input
43
+ // (e.g. a big Write) can exceed the 64KB pipe buffer and block the parent's write if nobody reads.
44
+ // It used to drain into the void and resolve with NOTHING, so `main(stdinRaw)` always got
45
+ // undefined — which silently cost two things: `session_id` (so the per-instance cursor in
46
+ // docs/INSTANCE-KEYS-CONTRACT.md never actually keyed by instance) and `cwd` (so this hook could
47
+ // only ever guess its project from the process directory). Draining and KEEPING the bytes is the
48
+ // same protection, minus the amnesia.
45
49
  function drainStdin() {
46
50
  return new Promise(res => {
47
- try { process.stdin.resume(); process.stdin.on("data", () => {}); process.stdin.on("end", res); }
48
- catch { res(); }
49
- setTimeout(res, 80);
51
+ let d = "";
52
+ try {
53
+ process.stdin.setEncoding("utf8"); process.stdin.resume();
54
+ process.stdin.on("data", c => (d += c));
55
+ process.stdin.on("end", () => res(d));
56
+ } catch { res(d); }
57
+ setTimeout(() => res(d), 80);
50
58
  });
51
59
  }
52
60
 
@@ -64,7 +72,10 @@ async function main(stdinRaw) {
64
72
  // name, different session_id) has its own ledger and can't eat this session's messages.
65
73
  let instanceId = "";
66
74
  try { instanceId = String(JSON.parse(stdinRaw || "{}").session_id || ""); } catch {}
67
- const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
75
+ // input.cwd FIRST every hook must derive the project the SAME way, or two hooks in one
76
+ // session resolve two projects, two hubs, and half the work records where nobody reads.
77
+ let _in = {}; try { _in = JSON.parse(stdinRaw || "{}"); } catch {}
78
+ const projectDir = _in.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
68
79
  // Mirror heartbeat.mjs / sessionstart.mjs: a home-directory session isn't project work and
69
80
  // isn't on the bus — nothing to deliver. Opt in with RELAY_SESSION / RELAY_PROJECT.
70
81
  if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return "{}";
@@ -94,7 +105,7 @@ async function main(stdinRaw) {
94
105
  // so we start listening "from now" instead of replaying the whole backlog of old broadcasts.
95
106
  if (!existsSync(cursorFile)) {
96
107
  try {
97
- const { cursor } = await getInbox(session, 0, instanceId);
108
+ const { cursor } = await getInbox(session, 0, instanceId, project);
98
109
  writeFileSync(cursorFile, String(cursor || 0));
99
110
  } catch {}
100
111
  return "{}";
@@ -105,7 +116,7 @@ async function main(stdinRaw) {
105
116
 
106
117
  let messages = [], next = cursor, superseded = false;
107
118
  try {
108
- const res = await getInbox(session, cursor, instanceId);
119
+ const res = await getInbox(session, cursor, instanceId, project);
109
120
  messages = Array.isArray(res.messages) ? res.messages : [];
110
121
  next = res.cursor || cursor;
111
122
  superseded = res.superseded === true;
package/hooks/lib/api.mjs CHANGED
@@ -49,6 +49,32 @@ export function sessionContext(projectDir) {
49
49
  return { session, project, projectDir: dir };
50
50
  }
51
51
 
52
+ // THE PROJECT A REQUEST IS ABOUT, which is not always the project the hook process is standing in.
53
+ //
54
+ // This distinction cost two diagnosis sessions. A hook stamps its payload with the project from
55
+ // Claude's session cwd (`input.cwd`), but the hub URL used to come from the HOOK PROCESS's cwd.
56
+ // Launch a session from ~/development and those disagree: every card says "crebral-health" and
57
+ // every one of them lands on the LOCAL hub, because "development" has no pin and falls through to
58
+ // the global default. Nothing errors, the seat looks healthy, and half the work records where
59
+ // nobody reads. So: the project travels WITH the request, explicit > payload > query > cwd.
60
+ function projectFromQuery(pathOrUrl) {
61
+ const m = String(pathOrUrl).match(/[?&]project=([^&]*)/);
62
+ try { return m ? decodeURIComponent(m[1]) : ""; } catch { return m ? m[1] : ""; }
63
+ }
64
+ function projectOf(explicit, payload, pathOrUrl) {
65
+ if (explicit) return explicit;
66
+ if (payload && typeof payload === "object" && payload.project) return String(payload.project);
67
+ return projectFromQuery(pathOrUrl);
68
+ }
69
+ // The signing identity for a request about `project`. An explicit RELAY_SESSION/RELAY_AGENT still
70
+ // wins (crew seats inherit them); otherwise the peer is named for the project being written, not
71
+ // for wherever the hook happens to be running.
72
+ function sessionFor(project) {
73
+ if (process.env.RELAY_SESSION) return process.env.RELAY_SESSION;
74
+ const p = project || sessionContext().project;
75
+ return process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${p}` : `${hostId()}:${p}`;
76
+ }
77
+
52
78
  // The keypair for a session name, memoised per process. loadOrCreate is itself idempotent + atomic,
53
79
  // but a hook may sign several requests in one run — avoid re-reading the file each time.
54
80
  const _idCache = new Map();
@@ -86,14 +112,19 @@ function enrolledPath(session) {
86
112
  const busDir = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
87
113
  return join(busDir, "keys", `${String(session).replace(/[^A-Za-z0-9_.-]/g, "_")}.enrolled`);
88
114
  }
89
- export async function ensureEnrolled(session, identity) {
115
+ export async function ensureEnrolled(session, identity, project) {
90
116
  if (!identity?.pubkey) return;
117
+ const hub = relayUrl(project);
91
118
  const stamp = enrolledPath(session);
92
- try { if (existsSync(stamp) && readFileSync(stamp, "utf8").trim() === identity.pubkey) return; } catch {}
119
+ // The stamp records the HUB as well as the key. It used to record only the key, so a session
120
+ // enrolled on one hub was considered enrolled everywhere — and its first request to a second
121
+ // hub went out as an unknown identity.
122
+ const mark = `${identity.pubkey}\t${hub}`;
123
+ try { if (existsSync(stamp) && readFileSync(stamp, "utf8").trim() === mark) return; } catch {}
93
124
  try {
94
125
  // sfetchJson is the FROZEN single call-site shape (lib/signed-fetch.mjs): it stringifies the
95
126
  // payload, sets content-type, and signs — so every hook signs identically with zero hand-rolling.
96
- const r = await sfetchJson(`${relayUrl()}/enroll`, {
127
+ const r = await sfetchJson(`${hub}/enroll`, {
97
128
  method: "POST",
98
129
  payload: { pubkey: identity.pubkey, name: session, kind: "agent" },
99
130
  identity,
@@ -101,7 +132,7 @@ export async function ensureEnrolled(session, identity) {
101
132
  });
102
133
  if (r.ok) {
103
134
  try { mkdirSync(join((process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus")), "keys"), { recursive: true }); } catch {}
104
- try { writeFileSync(stamp, identity.pubkey, { mode: 0o600 }); } catch {}
135
+ try { writeFileSync(stamp, mark, { mode: 0o600 }); } catch {}
105
136
  }
106
137
  } catch {}
107
138
  }
@@ -109,8 +140,8 @@ export async function ensureEnrolled(session, identity) {
109
140
  // Accept either a full URL (rare — a caller that already built one) or a hub-relative path. We do
110
141
  // NOT fold the origin into the signature (signed-fetch signs path+query only), so a request proxied
111
142
  // through a different host still verifies.
112
- function toUrl(pathOrUrl) {
113
- return /^https?:\/\//.test(pathOrUrl) ? pathOrUrl : `${relayUrl()}${pathOrUrl}`;
143
+ function toUrl(pathOrUrl, project) {
144
+ return /^https?:\/\//.test(pathOrUrl) ? pathOrUrl : `${relayUrl(project || projectFromQuery(pathOrUrl))}${pathOrUrl}`;
114
145
  }
115
146
 
116
147
  // Unsigned GET → { ok, status, json|null }. Never throws.
@@ -123,8 +154,8 @@ function toUrl(pathOrUrl) {
123
154
  // /peers roster. So reads stay unsigned: accepted+flagged under the default `warn` mode, and the
124
155
  // roster stays global. Flipping individual reads to signed later is a one-liner once a read needs
125
156
  // enforce-mode attribution (add a signedGet that passes `identity` through sfetchJson with GET).
126
- export async function getJSON(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
127
- const url = toUrl(pathOrUrl);
157
+ export async function getJSON(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS, project } = {}) {
158
+ const url = toUrl(pathOrUrl, projectOf(project, null, pathOrUrl));
128
159
  try {
129
160
  const r = await fetch(url, { method: "GET", signal: AbortSignal.timeout(timeoutMs) });
130
161
  // parse the body on FAILURE too — a refusal's payload (denial note, queue guidance,
@@ -145,13 +176,14 @@ export async function getJSON(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}
145
176
  // First user: the overseer-warn hook's /overseer/context — a project-scoped read, so the
146
177
  // enforce hub's own-project scope filtering is the correct behavior, not a loss. Roster-style
147
178
  // reads (/peers, /catchup cross-project discovery) stay on getJSON on purpose — see above.
148
- export async function signedGet(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS, session, instance } = {}) {
149
- const sess = session || sessionContext().session;
179
+ export async function signedGet(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS, session, instance, project } = {}) {
180
+ const proj = projectOf(project, null, pathOrUrl);
181
+ const sess = session || sessionFor(proj);
150
182
  const durable = loadIdentity(sess);
151
183
  const id = instance ? loadInstance(sess, instance) : durable;
152
- await ensureEnrolled(sess, durable); // instances never enroll — the DURABLE key does
184
+ await ensureEnrolled(sess, durable, proj); // instances never enroll — the DURABLE key does
153
185
  try {
154
- const r = await sfetchJson(toUrl(pathOrUrl), {
186
+ const r = await sfetchJson(toUrl(pathOrUrl, proj), {
155
187
  method: "GET",
156
188
  identity: id,
157
189
  signal: AbortSignal.timeout(timeoutMs),
@@ -170,15 +202,16 @@ export async function signedGet(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS, ses
170
202
  }
171
203
 
172
204
  // Signed POST → { ok, status, json|null }. Never throws.
173
- export async function signedPost(pathOrUrl, payload, { timeoutMs = DEFAULT_TIMEOUT_MS, session, instance } = {}) {
174
- const sess = session || sessionContext().session;
205
+ export async function signedPost(pathOrUrl, payload, { timeoutMs = DEFAULT_TIMEOUT_MS, session, instance, project } = {}) {
206
+ const proj = projectOf(project, payload, pathOrUrl);
207
+ const sess = session || sessionFor(proj);
175
208
  const durable = loadIdentity(sess);
176
209
  const id = instance ? loadInstance(sess, instance) : durable;
177
- await ensureEnrolled(sess, durable); // instances never enroll — the DURABLE key does
210
+ await ensureEnrolled(sess, durable, proj); // instances never enroll — the DURABLE key does
178
211
  try {
179
212
  // sfetchJson (FROZEN) stringifies the payload + signs with `id` in one call — the single shape
180
213
  // every client uses (lib/signed-fetch.mjs). We pass our memoised identity so it doesn't re-load.
181
- const r = await sfetchJson(toUrl(pathOrUrl), {
214
+ const r = await sfetchJson(toUrl(pathOrUrl, proj), {
182
215
  method: "POST",
183
216
  payload,
184
217
  identity: id,
@@ -18,7 +18,9 @@ function readStdin() {
18
18
 
19
19
  try {
20
20
  const input = JSON.parse((await readStdin()) || "{}");
21
- const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
21
+ // input.cwd FIRST every hook must derive the project the SAME way, or two hooks in one
22
+ // session resolve two projects, two hubs, and half the work records where nobody reads.
23
+ const projectDir = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
22
24
  const projectName = basename(projectDir);
23
25
  const transcript = input.transcript_path || "";
24
26
  const trigger = input.trigger || "auto";
@@ -3,11 +3,17 @@
3
3
  // so a REGULAR (non-crew) Claude session's OWN work shows IN PROGRESS on the board as it happens — not only
4
4
  // when it commits or dispatches a sub-agent. ONE rolling card per session (the hub re-titles it as the focus
5
5
  // shifts and closes it to "done" when the session goes offline). Trivial acks ("yes", "go ahead") don't
6
- // refocus. Fail-silent + fast: NO LLM call (the title is a heuristic clean of the prompt; Scrooge-summarized
7
- // titles are a follow-up). Never blocks or delays the turn.
6
+ // refocus. Fail-silent + fast: NO LLM call ON THE TURN PATH — the title is a heuristic clean of the
7
+ // prompt, posted immediately. A long prompt then hands its rewrite to bin/focus-title.mjs, spawned
8
+ // DETACHED so a cheap model can produce a readable board line a few seconds later without the user
9
+ // ever waiting on it. Never blocks or delays the turn.
8
10
  import { homedir } from "node:os";
11
+ import { join, dirname } from "node:path";
12
+ import { writeFileSync, mkdirSync } from "node:fs";
13
+ import { spawn } from "node:child_process";
14
+ import { fileURLToPath } from "node:url";
9
15
  import { resolveProject, hostId } from "../lib/project.mjs";
10
- import { signedPost } from "./lib/api.mjs";
16
+ import { signedPost, relayUrl } from "./lib/api.mjs";
11
17
 
12
18
  function readStdin() {
13
19
  return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
@@ -42,7 +48,29 @@ try {
42
48
  const project = resolveProject(cwd);
43
49
  const session = process.env.RELAY_SESSION
44
50
  || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
45
- await signedPost("/focus", { session, project, title: titleFrom(trimmed), by: session }, { session });
51
+ // The Claude Code session UUID. `session` above is a BUS id per host+project — so without this
52
+ // two Claude sessions in one project share (and fight over) a single focus card, and sub-agent
53
+ // cards, whose `parent` is exactly this UUID, have nothing to nest under.
54
+ const cc = String(input.session_id || "").slice(0, 120);
55
+ const r = await signedPost("/focus", { session, project, title: titleFrom(trimmed), by: session, cc }, { session });
56
+
57
+ // Only pay a model when the heuristic actually mangles the prompt. A short, already-clear ask
58
+ // ("fix the login redirect") reads fine as-is and buying a rewrite for it is exactly the kind of
59
+ // reflexive spend the economics doctrine exists to stop.
60
+ const id = r?.json?.id;
61
+ if (id && trimmed.length > 90 && process.env.TRANTOR_NO_SCROOGE_TITLES !== "1") {
62
+ try {
63
+ const busDir = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
64
+ mkdirSync(busDir, { recursive: true });
65
+ const pf = join(busDir, `focus-prompt-${String(cc || session).replace(/[^A-Za-z0-9_.-]/g, "_")}.txt`);
66
+ writeFileSync(pf, trimmed);
67
+ const worker = join(dirname(dirname(fileURLToPath(import.meta.url))), "bin", "focus-title.mjs");
68
+ // Detached + unref'd + stdio ignored: the hook returns NOW. Nothing downstream waits on this,
69
+ // and a worker that dies takes the heuristic title with it, which is a fine outcome.
70
+ spawn(process.execPath, [worker, "--id", String(id), "--hub", relayUrl(project), "--prompt-file", pf, "--project", project],
71
+ { detached: true, stdio: "ignore" }).unref();
72
+ } catch {}
73
+ }
46
74
  } catch (e) {
47
75
  process.stderr.write(`[trantor] prompt-focus error: ${e?.message || e}\n`);
48
76
  }
@@ -129,7 +129,10 @@ try {
129
129
  let source = "", stdinObj = {};
130
130
  try { stdinObj = JSON.parse((await readStdin()) || "{}"); source = stdinObj.source || ""; } catch {}
131
131
  userTitle = (stdinObj && stdinObj.session_title) ? String(stdinObj.session_title) : ""; // user already named it
132
- const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
132
+ // input.cwd FIRST, matching hooks/prompt-focus.mjs. When they disagreed, two hooks in ONE
133
+ // session resolved two different projects — and therefore two different hubs — so half a
134
+ // session's work recorded on a hub nobody was reading.
135
+ const projectDir = stdinObj.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
133
136
  // Sessions started in the home directory itself aren't project work — registering
134
137
  // them spawns a phantom "<username>" project board on the dashboard. Set
135
138
  // RELAY_SESSION (or RELAY_PROJECT) to deliberately put a home-dir session on the bus.
@@ -142,7 +145,9 @@ try {
142
145
  sessionTitle = project; // baseline — enriched with the current work item after the catch-up fetch below
143
146
  const session = process.env.RELAY_SESSION
144
147
  || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
145
- const url = relayUrl();
148
+ // relayUrl() with no project resolves from the hook process's cwd, which is not always the
149
+ // project the session is about. Pass it, or every call below can address the wrong hub.
150
+ const url = relayUrl(project);
146
151
 
147
152
  // register self + post an initial presence status (no LLM turn — instant for others to read)
148
153
  await jpost(`${url}/register`, { session, project, status: `active in ${project}` }, session).catch(() => {});
@@ -78,7 +78,7 @@ async function main() {
78
78
  let messages = [];
79
79
  try {
80
80
  // PEEK: look without claiming delivery. We may yet decide to let the stop through.
81
- const peek = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}&peek=1`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance: instanceId });
81
+ const peek = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}&peek=1`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance: instanceId, project });
82
82
  if (!peek.ok) return allow();
83
83
  // Superseded twin (instance-keys contract): a newer instance claimed the baton — this session
84
84
  // stands down. Blocking ITS stop over messages the new instance will handle would trap it.
@@ -92,7 +92,7 @@ async function main() {
92
92
  // Committed now: claim delivery for real so neither inbox-deliver nor the deferred waker repeats it.
93
93
  let next = cursor;
94
94
  try {
95
- const claim = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance: instanceId });
95
+ const claim = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance: instanceId, project });
96
96
  if (claim.ok) next = claim.json?.cursor || cursor;
97
97
  } catch {}
98
98
  try { writeFileSync(cursorFile, String(next)); } catch {}
@@ -53,8 +53,11 @@ try {
53
53
  // with the SubagentStop "done" card on any client that predates agent_id pairing (legacy fallback).
54
54
  const task = String(ti.prompt || ti.description || agentType).replace(/\s+/g, " ").trim().slice(0, 90);
55
55
  const title = `${agentType}: ${task}`.slice(0, 180);
56
+ // parent at CREATE time too, not only on the SubagentStart enrich — a card that spends its
57
+ // whole doing-life unparented is un-nestable exactly while it is the interesting one.
56
58
  await signedPost("/task", {
57
59
  project, title, status: "doing", agentType,
60
+ parent: String(input.session_id || "").slice(0, 120),
58
61
  assignee: `${agentType}:${project}`, by: `${hostId()}:${project}`,
59
62
  source: "cc-subagent", costKind: "subagent-notional", phase: "sub-agents",
60
63
  });
@@ -3,8 +3,8 @@
3
3
  // so SOLO work (no crew fired up) shows up live and accrues timeline history. The hub reconciles by
4
4
  // todo text (pending/in_progress/completed -> todo/doing/done). Fail-silent by contract: a bad hub,
5
5
  // a home-dir session, or any error must never block or break the tool flow.
6
- import { basename } from "node:path";
7
- import { homedir, hostname } from "node:os";
6
+ import { homedir } from "node:os";
7
+ import { resolveProject, hostId } from "../lib/project.mjs";
8
8
  import { signedPost } from "./lib/api.mjs";
9
9
  function readStdin() {
10
10
  return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
@@ -13,21 +13,26 @@ function readStdin() {
13
13
  }
14
14
 
15
15
  async function main() {
16
- const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
16
+ // stdin FIRST: input.cwd is the session's own directory and it decides both the project and the
17
+ // hub, so it has to be read before either is resolved.
18
+ let input = {};
19
+ try { input = JSON.parse((await readStdin()) || "{}"); } catch { return; }
20
+ const projectDir = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
17
21
  // Mirror sessionstart/heartbeat: home-directory sessions aren't project work — don't card them
18
22
  // (would spawn a phantom "<username>" board). Opt in with RELAY_SESSION/RELAY_PROJECT.
19
23
  if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return;
20
-
21
- let input = {};
22
- try { input = JSON.parse((await readStdin()) || "{}"); } catch { return; }
23
24
  if (input.tool_name && input.tool_name !== "TodoWrite") return; // the matcher should scope us, but be safe
24
25
  const todos = input.tool_input?.todos;
25
26
  if (!Array.isArray(todos) || !todos.length) return;
26
27
 
27
28
  // Identity EXACTLY as mcp.mjs/heartbeat resolve it, so we card the same peer the relay registered.
28
- const project = process.env.RELAY_PROJECT || basename(projectDir);
29
+ // This said `basename(projectDir)` and `hostname()`, which is not the same thing at all:
30
+ // resolveProject keys off the GIT ROOT (a subdirectory forked its own lane) and hostId is the
31
+ // stable machine id, where hostname() drifts to "MacBook-Pro-M1.local" on some networks and
32
+ // splits one machine into two peers.
33
+ const project = resolveProject(projectDir);
29
34
  const session = process.env.RELAY_SESSION
30
- || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostname()}:${project}`);
35
+ || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
31
36
 
32
37
  await signedPost("/todos", { session, project, by: session, todos: todos.map(t => ({ content: t.content, status: t.status })) });
33
38
  }
package/hub.mjs CHANGED
@@ -36,6 +36,11 @@ const PEER_TTL_MS = Math.max(Number.isFinite(_peerTtlRaw) ? _peerTtlRaw : PEER_T
36
36
  // long-running task is never touched — the owner-alive-but-idle case is handled by the manual /sweep path.
37
37
  const REAP_GRACE_MS = Number(process.env.RELAY_REAP_GRACE_MS || 15 * 60 * 1000); // 15m offline + untouched
38
38
  const FOCUS_OFFLINE_MS = Number(process.env.RELAY_FOCUS_OFFLINE_MS || ONLINE_MS); // close a focus card once its session is offline (not the old 6h)
39
+ // Backstop for the case the peer heartbeat cannot see: several Claude sessions share ONE bus
40
+ // identity (it is per host+project), so a sibling that is still alive keeps the whole assignee
41
+ // "online" and a dead session's focus card would hang in `doing` forever. Long, because a card
42
+ // untouched for an hour is routine — a big task runs a long time between prompts.
43
+ const FOCUS_IDLE_MS = Number(process.env.RELAY_FOCUS_IDLE_MS || 6 * 60 * 60 * 1000);
39
44
  const REAP_INTERVAL_MS = Number(process.env.RELAY_REAP_INTERVAL_MS || 60000); // how often the reaper sweeps (env-tunable; tests set it low)
40
45
  if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
41
46
  const isLoopbackHost = (host) => {
@@ -429,15 +434,49 @@ backfillCardEvents();
429
434
  // A session's live "focus" card (source:"session") tracks what a REGULAR session is working on RIGHT NOW
430
435
  // (set from each user prompt by hooks/prompt-focus.mjs). When the session ends (pruned offline), close its
431
436
  // open focus card to "done" so the board doesn't keep a dead session "in progress" forever.
432
- function closeFocus(session) {
433
- const t = state.tasks.find(x => x.source === "session" && x.assignee === session && x.status !== "done");
434
- if (!t) return false;
435
- (t.history ||= []).push({ from: t.status, to: "done", by: session, ts: now() });
437
+ function closeFocusCard(t, by) {
438
+ if (!t || t.status === "done") return false;
439
+ (t.history ||= []).push({ from: t.status, to: "done", by, ts: now() });
436
440
  if (t.history.length > 60) t.history.splice(0, 20);
437
- appendCardEvent("moved", t, session, t.status, "done");
441
+ appendCardEvent("moved", t, by, t.status, "done");
438
442
  t.status = "done"; t.updated = now();
439
443
  return true;
440
444
  }
445
+ // A bus session id is per (host, project), so ONE assignee can own SEVERAL open focus cards — one
446
+ // per live Claude session in that project. Close every one of them when the peer goes away; the
447
+ // old single-card `find` left the rest sitting in `doing` forever.
448
+ function closeFocus(session) {
449
+ let closed = false;
450
+ for (const t of state.tasks) {
451
+ if (t.source === "session" && t.assignee === session && t.status !== "done") closed = closeFocusCard(t, session) || closed;
452
+ }
453
+ return closed;
454
+ }
455
+ // A git card and a focus card meet on the same bus id (`${hostId()}:${project}`), which is what the
456
+ // backfill posts as its assignee. One bus id can own several open focus cards now, so close the
457
+ // most recently active one — the session that just committed is the one that most recently spoke.
458
+ const COMMIT_FOCUS_WINDOW_MS = Number(process.env.RELAY_COMMIT_FOCUS_MS || 10 * 60 * 1000);
459
+ function linkCommitToFocus(commitCard, by) {
460
+ // A HISTORICAL backfill (`--since "14 days ago"`) posts dozens of old commits at once and must
461
+ // never close whatever a session happens to be doing today. Only a fresh commit closes a focus.
462
+ if (Math.abs(now() - (commitCard.ts || 0)) > COMMIT_FOCUS_WINDOW_MS) return false;
463
+ const owner = commitCard.assignee || by || "";
464
+ if (!owner) return false;
465
+ const proj = canon(commitCard.project || "");
466
+ const open = state.tasks
467
+ .filter(x => x.source === "session" && x.status !== "done" && x.assignee === owner && canon(x.project) === proj)
468
+ .sort((a, b2) => (b2.updated || 0) - (a.updated || 0));
469
+ const focus = open[0];
470
+ if (!focus) return false;
471
+ focus.commitCard = commitCard.id; // the two halves point at each other, so the
472
+ commitCard.focusCard = focus.id; // card drawer can walk either way
473
+ (focus.history ||= []).push({ from: focus.status, to: "done", by: owner, ts: now(), note: `closed by commit — ${String(commitCard.title || "").slice(0, 80)}` });
474
+ if (focus.history.length > 60) focus.history.splice(0, 20);
475
+ appendCardEvent("moved", focus, owner, focus.status, "done");
476
+ focus.status = "done"; focus.updated = now();
477
+ appendEvent("focus", focus.project, owner, { taskId: focus.id, closedBy: commitCard.id, reason: "commit" });
478
+ return true;
479
+ }
441
480
  function prunePeers() {
442
481
  const cutoff = now() - PEER_TTL_MS;
443
482
  let removed = false;
@@ -469,13 +508,18 @@ function cardOwnerOnline(t, cutoff) {
469
508
  function reapStaleCards() {
470
509
  const onCut = now() - ONLINE_MS;
471
510
  const focusCut = now() - FOCUS_OFFLINE_MS;
511
+ const idleCut = now() - FOCUS_IDLE_MS;
472
512
  const graceCut = now() - REAP_GRACE_MS;
473
513
  let changed = false;
474
514
  for (const t of state.tasks) {
475
515
  if (t.status === "done" || t.status === "stale") continue;
476
516
  if (t.source === "session") { // (a) focus cards → done when session offline
477
517
  const p = state.peers[t.assignee];
478
- if (!p || (p.lastSeen || 0) < focusCut) { if (closeFocus(t.assignee)) changed = true; }
518
+ const peerGone = !p || (p.lastSeen || 0) < focusCut;
519
+ // …or when THIS card has gone quiet for a very long time, which is the only signal available
520
+ // for a dead Claude session whose bus identity a living sibling keeps warm.
521
+ const longIdle = (t.updated || t.ts || 0) < idleCut;
522
+ if (peerGone || longIdle) { if (closeFocusCard(t, peerGone ? t.assignee : "reaper")) changed = true; }
479
523
  continue;
480
524
  }
481
525
  if ((t.status === "doing" || t.status === "testing") // (b) offline-owner work cards → stale
@@ -1459,6 +1503,11 @@ const server = http.createServer(async (req, res) => {
1459
1503
  if (b.source === "cc-subagent") { t._fp = subFp(b.title); if (b.agentType) t._atype = String(b.agentType).slice(0, 40); if (b.agentId) t._aid = String(b.agentId).slice(0, 80); if (b.parent) t.parent = String(b.parent).slice(0, 120); t.count = 1; if (t.status === "doing") { t._everStarted = true; t._inflight = 1; } }
1460
1504
  state.tasks.push(t); if (state.tasks.length > 2000) state.tasks.splice(0, 500);
1461
1505
  appendCardEvent("created", t, b.by, null, st0);
1506
+ // A COMMIT closes the focus. A focus card says "this session is working on X right now"; the
1507
+ // commit is X arriving, so the card that was rolling forever now completes with the commit
1508
+ // attached to it — the board finally shows a finished unit of work instead of an open card
1509
+ // whose title keeps changing. The next prompt opens a fresh one.
1510
+ if (b.source === "git" && st0 === "done") linkCommitToFocus(t, b.by);
1462
1511
  dirty = true; return json(res, 200, { ok: true, task: t });
1463
1512
  }
1464
1513
  if (req.method === "POST" && P === "/task/update") { // move/edit a card
@@ -1556,20 +1605,38 @@ const server = http.createServer(async (req, res) => {
1556
1605
  const session = String(b.session || b.by || "").slice(0, 120);
1557
1606
  const project = canon(String(b.project || "").slice(0, 80));
1558
1607
  const title = String(b.title || "").replace(/\s+/g, " ").trim().slice(0, 200);
1608
+ // `cc` = the Claude Code session UUID (hooks/prompt-focus.mjs passes session_id). It is the
1609
+ // ONLY per-session key on the board: `assignee` is a bus id, which is per host+project, so
1610
+ // two Claude sessions in one project used to fight over a single rolling card — and every
1611
+ // sub-agent card, whose `parent` is that same UUID, had nothing to join to (measured over
1612
+ // 431 live cards, joining on the bus id resolved 0 of them). With `cc` stored here, a
1613
+ // sub-agent nests under the session that actually spawned it.
1614
+ const cc = String(b.cc || "").slice(0, 120);
1559
1615
  if (!session || !project || !title) return json(res, 400, { error: "session, project, title required" });
1560
1616
  touch(session, undefined, project, undefined, auth);
1561
- let t = state.tasks.find(x => x.source === "session" && x.assignee === session && canon(x.project) === project && x.status !== "done");
1617
+ // Match on cc when the client sends one but never let a cc-bearing prompt adopt a card
1618
+ // from a DIFFERENT session. A client too old to send cc keeps the original assignee match.
1619
+ let t = cc
1620
+ ? state.tasks.find(x => x.source === "session" && x.cc === cc && canon(x.project) === project && x.status !== "done")
1621
+ || state.tasks.find(x => x.source === "session" && !x.cc && x.assignee === session && canon(x.project) === project && x.status !== "done")
1622
+ : state.tasks.find(x => x.source === "session" && x.assignee === session && canon(x.project) === project && x.status !== "done");
1562
1623
  if (t) {
1563
1624
  if (t.title !== title) { // refocus: re-title in place + record the shift (keeps the trail)
1564
1625
  (t.history ||= []).push({ from: t.status, to: t.status, by: session, ts: now(), note: title.slice(0, 90) });
1565
1626
  if (t.history.length > 60) t.history.splice(0, 20);
1627
+ // A rolling card's narrative describes the OLD focus. The board renders `summary ||
1628
+ // title`, so leaving it would let a stale one-liner shadow what the session is doing
1629
+ // right now — the summarizer (or bin/focus-title.mjs) writes a fresh one.
1630
+ if (t.summary) t.summary = "";
1566
1631
  t.title = title; appendCardEvent("updated", t, session, null, null);
1567
1632
  appendEvent("focus", project, session, { taskId: t.id, title, shift: true });
1568
1633
  }
1634
+ if (cc && !t.cc) t.cc = cc; // an in-flight card from an older client gets its key
1569
1635
  t.status = "doing"; t.updated = now();
1570
1636
  } else {
1571
1637
  t = { id: ++state.taskSeq, project, title, assignee: session, status: "doing", source: "session",
1572
1638
  difficulty: "", model: "", deps: [], by: session, ts: now(), updated: now(),
1639
+ cc: cc || undefined,
1573
1640
  history: [{ to: "doing", by: session, ts: now() }] };
1574
1641
  state.tasks.push(t); appendCardEvent("created", t, session, null, "doing");
1575
1642
  appendEvent("focus", project, session, { taskId: t.id, title, shift: false });
@@ -1579,7 +1646,11 @@ const server = http.createServer(async (req, res) => {
1579
1646
  }
1580
1647
  if (req.method === "GET" && P === "/focus") { // the session's open focus card (for sub-agent nesting)
1581
1648
  const session = String(q.session || "");
1582
- const t = state.tasks.find(x => x.source === "session" && x.assignee === session && x.status !== "done");
1649
+ const cc = String(q.cc || "");
1650
+ // ?cc= is the precise lookup (one Claude session); ?session= stays the coarse one.
1651
+ const t = cc
1652
+ ? state.tasks.find(x => x.source === "session" && x.cc === cc && x.status !== "done")
1653
+ : state.tasks.find(x => x.source === "session" && x.assignee === session && x.status !== "done");
1583
1654
  if (t && !canRead(auth, t.project || "")) return json(res, 404, { id: null, task: null });
1584
1655
  return json(res, 200, { id: t ? t.id : null, task: t || null });
1585
1656
  }
@@ -0,0 +1,130 @@
1
+ // trantor — hub split-brain detection.
2
+ //
3
+ // A project lives on exactly ONE hub (TDD §12.1). When that stops being true nothing errors: the
4
+ // crew registers on hub A, the orchestrator and the board read hub B, every seat reports healthy,
5
+ // and the work simply lands somewhere nobody is looking. It cost two full diagnosis sessions in
6
+ // crebral-health (2026-08-14 and again 2026-08-18) before anyone thought to compare the two hubs.
7
+ // Nothing on the machine was wrong; the two halves just never met.
8
+ //
9
+ // So detection is a cross-check, not a health check: probe every hub this machine knows about, ask
10
+ // each one who is LIVE on it, and compare that against the pins in ~/.agent-bus/config.json.
11
+ //
12
+ // THE TRAP THIS LIB EXISTS TO AVOID: an unsigned read of an enforce hub answers
13
+ // `{"error":"signature required"}` with HTTP 401, and `(body.peers || [])` turns that refusal into
14
+ // a confident empty list — a hub full of agents reads as deserted. That nearly got called a fleet
15
+ // outage. probeHub() therefore reports a REASON, never a silent empty roster, and analyze() carries
16
+ // unreadable hubs through to the caller as `blind` so a partial answer can never be printed as a
17
+ // clean bill of health.
18
+ import { sfetch } from "./signed-fetch.mjs";
19
+
20
+ /** Compare hub URLs the way a human would: trailing slash and loopback spelling are not identity. */
21
+ export function normalizeHub(url) {
22
+ let u = String(url || "").trim().replace(/\/+$/, "");
23
+ return u.replace(/^http:\/\/localhost(?=[:/]|$)/i, "http://127.0.0.1");
24
+ }
25
+ export const isLocalHub = (url) => /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(?=[:/]|$)/i.test(String(url || ""));
26
+
27
+ /** Every hub this machine could be talking to: the global default, and every per-project pin. */
28
+ export function hubsFromConfig(config = {}, defaultUrl = "http://127.0.0.1:4477") {
29
+ const seen = new Map();
30
+ const add = (url, source) => {
31
+ const u = normalizeHub(url);
32
+ if (!/^https?:\/\//.test(u)) return;
33
+ if (!seen.has(u)) seen.set(u, { url: u, sources: [] });
34
+ seen.get(u).sources.push(source);
35
+ };
36
+ add(config.url || defaultUrl, "config.url");
37
+ for (const [project, url] of Object.entries(config.hubs || {})) add(url, `pin:${project}`);
38
+ return [...seen.values()];
39
+ }
40
+
41
+ /** Ask one hub who is live on it. Never conflates "refused us" with "nobody home". */
42
+ export async function probeHub(url, identity, { timeoutMs = 6000, fetchImpl = null } = {}) {
43
+ const u = normalizeHub(url);
44
+ const base = { url: u, ok: false, reason: "", authMode: "", hubVersion: "", peers: [] };
45
+ let res, text;
46
+ try {
47
+ const doFetch = fetchImpl || ((p, o, id) => sfetch(p, o, id));
48
+ res = await doFetch(`${u}/peers`, { signal: AbortSignal.timeout(timeoutMs) }, identity);
49
+ text = await res.text();
50
+ } catch (e) {
51
+ return { ...base, reason: `unreachable (${e?.message || e})` };
52
+ }
53
+ let body;
54
+ try { body = JSON.parse(text); } catch { return { ...base, reason: `unreadable response (HTTP ${res.status})` }; }
55
+ // The refusal case, spelled out. Both shapes matter: an enforce hub 401s with {error}, and a
56
+ // scope-limited identity can get a 200 whose roster is filtered — only the first is a blind spot.
57
+ if (!res.ok || body?.error) {
58
+ const why = body?.error || `HTTP ${res.status}`;
59
+ return { ...base, reason: /signature|unauthor|forbidden|401|403/i.test(why) ? `not authorized (${why})` : `refused (${why})` };
60
+ }
61
+ if (!Array.isArray(body?.peers)) return { ...base, reason: "no peer roster in the response" };
62
+ return { url: u, ok: true, reason: "", authMode: body.authMode || "", hubVersion: body.hubVersion || "",
63
+ peers: body.peers.map(p => ({ session: p.session, project: p.project || "", online: !!p.online, lastSeen: p.lastSeen || 0, llm: p.llm || "" })) };
64
+ }
65
+
66
+ /**
67
+ * Cross-check live presence against the pins.
68
+ *
69
+ * @param probes probeHub() results, one per hub
70
+ * @param config the raw ~/.agent-bus/config.json
71
+ * @returns {{findings: Array, blind: Array, checked: number}}
72
+ */
73
+ export function analyze(probes = [], config = {}, defaultUrl = "http://127.0.0.1:4477") {
74
+ const pins = Object.fromEntries(Object.entries(config.hubs || {}).map(([p, u]) => [p, normalizeHub(u)]));
75
+ const fallback = normalizeHub(config.url || defaultUrl);
76
+ const readable = probes.filter(p => p.ok);
77
+ const blind = probes.filter(p => !p.ok).map(p => ({ url: p.url, reason: p.reason }));
78
+
79
+ // project -> hubs it is LIVE on
80
+ const liveOn = new Map();
81
+ for (const probe of readable) {
82
+ for (const peer of probe.peers) {
83
+ if (!peer.online || !peer.project) continue;
84
+ if (!liveOn.has(peer.project)) liveOn.set(peer.project, new Map());
85
+ const byHub = liveOn.get(peer.project);
86
+ if (!byHub.has(probe.url)) byHub.set(probe.url, []);
87
+ byHub.get(probe.url).push(peer.session);
88
+ }
89
+ }
90
+
91
+ const findings = [];
92
+ for (const [project, byHub] of [...liveOn.entries()].sort()) {
93
+ const hubs = [...byHub.keys()];
94
+ const pin = pins[project] || "";
95
+ const expected = pin || fallback;
96
+ if (hubs.length > 1) {
97
+ findings.push({ kind: "split", project, severity: "critical", hubs, expected,
98
+ sessions: Object.fromEntries([...byHub].map(([h, s]) => [h, s])),
99
+ message: `${project} has LIVE sessions on ${hubs.length} hubs at once — ${hubs.map(h => `${h} (${byHub.get(h).join(", ")})`).join(" and ")}. They cannot see each other's cards or messages.`,
100
+ fix: pin
101
+ ? `everything must be on ${pin}: restart the sessions on the other hub (crew: trantor down && trantor up · Claude sessions: restart them)`
102
+ : `pin it first — trantor hub set ${project} <url> — then restart every session so they all follow the pin` });
103
+ } else if (pin && hubs[0] !== pin) {
104
+ findings.push({ kind: "off-pin", project, severity: "critical", hubs, expected: pin,
105
+ sessions: Object.fromEntries([...byHub].map(([h, s]) => [h, s])),
106
+ message: `${project} is pinned to ${pin} but its live sessions (${byHub.get(hubs[0]).join(", ")}) are on ${hubs[0]} — their work records where nobody is reading.`,
107
+ fix: `restart them so they pick up the pin (crew: trantor down && trantor up · Claude sessions: restart them)` });
108
+ } else if (!pin && !isLocalHub(hubs[0])) {
109
+ findings.push({ kind: "unpinned-remote", project, severity: "warn", hubs, expected: fallback,
110
+ sessions: Object.fromEntries([...byHub].map(([h, s]) => [h, s])),
111
+ message: `${project} is live on ${hubs[0]} but has no pin — the next session started here falls back to ${fallback} and splits the project in two.`,
112
+ fix: `trantor hub set ${project} ${hubs[0]}` });
113
+ }
114
+ }
115
+ // A pin aimed at a hub we could not read is its own fault: sessions will route there and go quiet.
116
+ for (const [project, url] of Object.entries(pins)) {
117
+ const dead = blind.find(b => b.url === url);
118
+ if (dead) findings.push({ kind: "pin-unreachable", project, severity: "critical", hubs: [], expected: url,
119
+ message: `${project} is pinned to ${url}, which this machine cannot read (${dead.reason}) — sessions on it will look alive and record nothing here.`,
120
+ fix: `check the hub is up and this machine is enrolled on it, or re-pin: trantor hub set ${project} <url>` });
121
+ }
122
+ return { findings, blind, checked: readable.length };
123
+ }
124
+
125
+ /** Probe + analyze in one call. Hubs are probed concurrently — a dead one must not stall the rest. */
126
+ export async function scan(config = {}, identity, { defaultUrl = "http://127.0.0.1:4477", timeoutMs = 6000 } = {}) {
127
+ const hubs = hubsFromConfig(config, defaultUrl);
128
+ const probes = await Promise.all(hubs.map(h => probeHub(h.url, identity, { timeoutMs })));
129
+ return { ...analyze(probes, config, defaultUrl), probes };
130
+ }
package/mcp.mjs CHANGED
@@ -99,10 +99,12 @@ async function seedCursor() {
99
99
  // mints a random id at boot — its lifetime ≈ the session's. The endorsed subkey it keys signs all
100
100
  // traffic; the durable identity keeps enrollment and attribution.
101
101
  const INSTANCE_ID = `mcp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
102
- async function api(method, path, payload) {
102
+ async function api(method, path, payload, { timeoutMs } = {}) {
103
+ // PROJECT explicitly, never the client's cwd fallback: this server's project is fixed at boot,
104
+ // and letting the hub be re-derived per call is how a session ends up writing to two hubs.
103
105
  const r = method.toUpperCase() === "GET"
104
- ? await signedGet(path, { session: SESSION, instance: INSTANCE_ID })
105
- : await signedPost(path, payload, { session: SESSION, instance: INSTANCE_ID });
106
+ ? await signedGet(path, { session: SESSION, instance: INSTANCE_ID, project: PROJECT, timeoutMs })
107
+ : await signedPost(path, payload, { session: SESSION, instance: INSTANCE_ID, project: PROJECT, timeoutMs });
106
108
  if (!r.ok) throw new Error(`hub ${r.status} on ${path}`);
107
109
  return r.json;
108
110
  }
@@ -310,7 +312,11 @@ server.tool("relay_wait", "Block up to `timeout` seconds waiting for the next me
310
312
  // resolves inline on every current client instead of being shipped to the background.
311
313
  const w = Math.min(timeout ?? 25, 110);
312
314
  await seedCursor();
313
- const { messages, cursor: c } = await api("GET", `/poll?session=${encodeURIComponent(SESSION)}&since=${cursor}&wait=${w}`);
315
+ // The client-side deadline must OUTLIVE the hub's hold. Since reads moved onto the shared
316
+ // signed client, this call inherited its 1.5s default — so every long-poll that had no message
317
+ // already waiting was aborted at 1.5s and surfaced as "hub 0 on /poll". Parking was dead: the
318
+ // tool erred on every quiet wait and only ever "worked" when a message beat the abort.
319
+ const { messages, cursor: c } = await api("GET", `/poll?session=${encodeURIComponent(SESSION)}&since=${cursor}&wait=${w}`, undefined, { timeoutMs: (w + 15) * 1000 });
314
320
  cursor = c;
315
321
  return { content: [{ type: "text", text: messages.length ? messages.map(fmt).join("\n") : "(timed out, no message)" }] };
316
322
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.69",
3
+ "version": "0.17.72",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -11,7 +11,7 @@
11
11
  "pg": "^8.22.0"
12
12
  },
13
13
  "scripts": {
14
- "test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-inbox-delivery.mjs && node test-hub-routing.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
14
+ "test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-splitbrain.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-inbox-delivery.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
15
15
  },
16
16
  "description": "The hub-world for AI agent crews \u2014 orchestrate Claude Code, Codex, GLM, Kimi, DeepSeek & any OpenRouter model as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
17
17
  "files": [
@@ -54,4 +54,4 @@
54
54
  "engines": {
55
55
  "node": ">=18"
56
56
  }
57
- }
57
+ }