trantor 0.18.38 → 0.18.40

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.38",
3
+ "version": "0.18.40",
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": {
@@ -539,6 +539,15 @@ let inFollowUp = false;
539
539
  let sessionCard = 0;
540
540
 
541
541
  let sid = "";
542
+ // #6206: the watchdog is DETACHED, so a runner that dies without ending it leaves an orphan
543
+ // sleeping toward a false alarm against whatever runner comes next (22 found on 2026-09-03).
544
+ // Every exit path therefore kills it, and the stamp carries this runner's instance id so any
545
+ // survivor that outlives the kill still refuses to speak for a runner it never belonged to.
546
+ const RUNNER_ID = `${process.pid}.${Date.now()}`;
547
+ let WD_CHILD = null;
548
+ function killWatchdog() { if (WD_CHILD) { try { WD_CHILD.kill("SIGTERM"); } catch {} WD_CHILD = null; } }
549
+ process.on("exit", killWatchdog);
550
+
542
551
  async function runTurn(prompt, isFirst, trigger = "kickoff") {
543
552
  TURN++; banner(trigger);
544
553
  const t0 = Date.now();
@@ -593,17 +602,25 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
593
602
  const inner = cli.sid ? `${cmd} | tee /dev/stderr` : `${cmd} | ${SCRUB} --tee ${ERRF}`;
594
603
  // #5684: runTurn is spawnSync, so the runner cannot watch its own turn — a DETACHED watchdog
595
604
  // does. Armed by a stamp file, disarmed when the turn ends (stamp removed below); a turn past
596
- // the window with no ERRF growth earns ONE direct stall report to the foreman, never a kill.
597
- const WD_MS = Number(process.env.TRANTOR_TURN_WATCHDOG_MS || 15 * 60 * 1000);
605
+ // the window with no activity (transcript, worktree, or stderr #6206: stdout silence alone
606
+ // is never a stall) earns ONE direct stall report to the foreman, never a kill.
607
+ // #6206: the window's floor is 10 minutes and is never derived from TRANTOR_TURN_MAX_MS —
608
+ // a 1-minute alarm is a false alarm by construction. The env override exists for drills.
609
+ const WD_MS = Number(process.env.TRANTOR_TURN_WATCHDOG_MS) || 10 * 60 * 1000;
598
610
  const STAMPF = join(homedir(), ".agent-bus", `turnstamp-${AGENT}-${PROJ}.json`);
611
+ // #6206: where the CLI appends its session transcript (claude's project dir; other CLIs may
612
+ // not have one — the watchdog treats a missing dir as a quiet channel). Also exported to the
613
+ // CLI's env so a drill's fake CLI can write transcript lines the watchdog will see.
614
+ const TRANSCRIPT_DIR = join(homedir(), ".claude", "projects", TURN_DIR.replace(/[^a-zA-Z0-9]/g, "-"));
599
615
  // Written by the shell's own time box (below) and read back here — the only honest signal that
600
616
  // the turn was CUT rather than that the CLI failed on its own. Cleared before every turn.
601
617
  const CUTF = join(homedir(), ".agent-bus", `turncut-${AGENT}-${PROJ}`);
602
618
  try { unlinkSync(CUTF); } catch {}
603
619
  try {
604
- writeFileSync(STAMPF, JSON.stringify({ turn: TURN, startedAt: Date.now() }));
605
- const wd = spawn(process.execPath, [join(import.meta.dirname, "turn-watchdog.mjs"), STAMPF, ERRF, String(WD_MS), SESSION, PROJ, HUB],
620
+ writeFileSync(STAMPF, JSON.stringify({ turn: TURN, startedAt: Date.now(), runner: RUNNER_ID }));
621
+ const wd = spawn(process.execPath, [join(import.meta.dirname, "turn-watchdog.mjs"), STAMPF, ERRF, String(WD_MS), SESSION, PROJ, HUB, TRANSCRIPT_DIR, TURN_DIR],
606
622
  { detached: true, stdio: "ignore" });
623
+ WD_CHILD = wd;
607
624
  wd.unref();
608
625
  } catch {}
609
626
  // Preserve the CLI's exit before waiting for the stderr process substitution. Without the
@@ -663,7 +680,10 @@ exit $turn_exit`;
663
680
  // Observed on the duty seat: handoff records at 17:24 and 18:59 on 2026-08-24, and two stray
664
681
  // `claude` processes in ~/.agent-bus/trantor-duty started at 17:24:57 and 18:59:50, still
665
682
  // sitting there days later. To the operator that reads as "why are there two duty agents".
666
- TRANTOR_NO_HANDOFF_SPAWN: "1", TRANTOR_NO_BATON_SPAWN: "1" },
683
+ TRANTOR_NO_HANDOFF_SPAWN: "1", TRANTOR_NO_BATON_SPAWN: "1",
684
+ // #6206: the seat's transcript dir — a real CLI ignores it, a drill's fake CLI writes
685
+ // its transcript lines there so the watchdog sees the liveness a real claude shows.
686
+ TRANTOR_TRANSCRIPT_DIR: TRANSCRIPT_DIR },
667
687
  maxBuffer: 16 * 1024 * 1024,
668
688
  };
669
689
  // A BACKSTOP only, deliberately later than the shell's own box: if bash itself wedges, node
@@ -671,7 +691,8 @@ exit $turn_exit`;
671
691
  // is the point: the shell kills while the tree is still walkable, node cannot.
672
692
  if (TURN_MAX_MS) { spawnOpts.timeout = TURN_MAX_MS + 30000; spawnOpts.killSignal = "SIGKILL"; }
673
693
  const r = spawnSync("/bin/bash", ["-c", shell], spawnOpts);
674
- try { unlinkSync(STAMPF); } catch {} // turn over — disarm the watchdog
694
+ killWatchdog(); // #6206: turn over — the watchdog dies NOW, it does not sleep on
695
+ try { unlinkSync(STAMPF); } catch {} // disarm any survivor: the stamp is gone
675
696
  // The shell's box leaves the marker; the backstop leaves an ETIMEDOUT. Either way the turn was
676
697
  // cut, not merely failed.
677
698
  const boxed = existsSync(CUTF);
package/bin/crew.sh CHANGED
@@ -201,7 +201,7 @@ let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const r=(JSON.parse(
201
201
  process.stdout.write((((r.workspace||{}).workspace_id)||"")+"\t"+(((r.root_pane||{}).pane_id)||""))}catch(e){}})'
202
202
  }
203
203
  _herdr_split() { # $1=pane id ("" = UI-focused pane) $2=right|down $3=cwd (optional) → new pane id
204
- local a=(pane split); [ -n "$1" ] && a+=("$1"); a+=(--direction "$2" --no-focus); [ -n "$3" ] && a+=(--cwd "$3")
204
+ local a=(pane split); [ -n "$1" ] && a+=("$1"); a+=(--direction "$2" --no-focus); [ -n "${3:-}" ] && a+=(--cwd "$3")
205
205
  _herdr "${a[@]}" 2>/dev/null | node -e '
206
206
  let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const r=(JSON.parse(d.slice(d.search(/[\[{]/))).result)||{};
207
207
  process.stdout.write(((r.pane||{}).pane_id)||"")}catch(e){}})'
@@ -1,36 +1,92 @@
1
1
  #!/usr/bin/env node
2
- // Turn watchdog (#5684). runTurn is spawnSync — the runner cannot watch its own turn — so this
3
- // DETACHED helper does: armed at turn start, disarmed by turn end (the stamp file vanishes or
4
- // its turn number moves on). A turn that runs past the window with NO output growth earns ONE
5
- // direct stall report to the foreman (episode, never a timer storm), and the turn is never
6
- // killed — reporting is the whole job. The operator's 2026-08-31 complaint is the incident:
7
- // seats sat visibly dead in their panes while every signal channel stayed quiet.
2
+ // Turn watchdog (#5684, reworked #6206). runTurn is spawnSync — the runner cannot watch its own
3
+ // turn — so this DETACHED helper does: armed at turn start, disarmed by turn end. A turn past
4
+ // the window with NO new activity earns ONE direct stall report to the foreman (episode, never a
5
+ // timer storm), and the turn is never killed reporting is the whole job.
8
6
  //
9
- // node bin/turn-watchdog.mjs <stampFile> <errFile> <windowMs> <session> <project> <hubUrl>
10
- import { readFileSync, existsSync, statSync } from "node:fs";
7
+ // #6206: stdout silence is NOT a stall — `claude -p` prints nothing until the turn ends by
8
+ // design, so a seat editing five files was reported STALLED while its transcript advanced.
9
+ // Liveness is new activity in the seat's transcript (the CLI's session file), its worktree, or
10
+ // stderr growth; silence on ALL of them for a whole window is the only thing reported. And a
11
+ // watchdog never speaks for a runner it does not belong to: the stamp carries the runner's
12
+ // instance id, so a survivor of a replaced runner exits on mismatch or runner death instead of
13
+ // re-matching the NEW runner's turn number (the 09:47 false alarm was exactly that orphan).
14
+ //
15
+ // node bin/turn-watchdog.mjs <stampFile> <errFile> <windowMs> <session> <project> <hubUrl> <transcriptDir> <workDir>
16
+ import { readFileSync, statSync, readdirSync } from "node:fs";
17
+ import { join } from "node:path";
11
18
  import { hostId } from "../lib/project.mjs";
12
19
  import { signedPost } from "../hooks/lib/api.mjs";
13
20
 
14
- const [stampFile, errFile, windowMsRaw, session, project, hub] = process.argv.slice(2);
15
- const windowMs = Number(windowMsRaw) || 15 * 60 * 1000; // no floor: drills pass tiny windows, and one report per turn caps the damage anyway
21
+ const [stampFile, errFile, windowMsRaw, session, project, hub, transcriptDir = "", workDir = ""] = process.argv.slice(2);
22
+ // SAFETY: the 10-minute floor lives in crew-runner.mjs (the default when TRANTOR_TURN_WATCHDOG_MS
23
+ // is unset); this fallback only covers a missing argument. Drills pass tiny windows on purpose.
24
+ const windowMs = Number(windowMsRaw) || 10 * 60 * 1000;
16
25
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
17
26
 
18
27
  const readStamp = () => { try { return JSON.parse(readFileSync(stampFile, "utf8")); } catch { return null; } };
19
28
  const errSize = () => { try { return statSync(errFile).size; } catch { return 0; } };
20
29
 
30
+ // Newest mtime under a directory, .git/node_modules skipped, entry-capped so a big tree cannot
31
+ // wedge a detached helper. A missing directory scores 0 (a codex seat has no claude transcript
32
+ // dir) — liveness needs only ONE channel to move, absence of some channels is fine.
33
+ const SCAN_CAP = 20000;
34
+ function newestMtime(dir) {
35
+ let best = 0, seen = 0;
36
+ const walk = (d) => {
37
+ if (seen > SCAN_CAP) return;
38
+ let rows = [];
39
+ try { rows = readdirSync(d, { withFileTypes: true }); } catch { return; }
40
+ for (const e of rows) {
41
+ if (++seen > SCAN_CAP || e.name === ".git" || e.name === "node_modules") continue;
42
+ const p = join(d, e.name);
43
+ if (e.isDirectory()) walk(p);
44
+ else { try { best = Math.max(best, statSync(p).mtimeMs); } catch {} }
45
+ }
46
+ };
47
+ walk(dir);
48
+ return best;
49
+ }
50
+
21
51
  const armed = readStamp();
22
52
  if (!armed) process.exit(0);
23
- let baseline = errSize();
53
+ // The runner instance that armed us (pid + boot ts, so a recycled pid cannot impersonate it).
54
+ // A stamp without a runner id (only possible mid-upgrade) skips the liveness check; the id
55
+ // match in the loop still guards it.
56
+ const runnerPid = Number(String(armed.runner || "").split(".")[0]) || 0;
57
+ const runnerAlive = () => {
58
+ if (!runnerPid) return true;
59
+ try { process.kill(runnerPid, 0); return true; } catch { return false; }
60
+ };
61
+
62
+ // One observation of every liveness channel: stderr growth, transcript mtime, worktree mtime.
63
+ const ago = (t) => (t > 0 ? `${Math.max(1, Math.round((Date.now() - t) / 60000))}m ago` : "never");
64
+ const describeLast = (b) => {
65
+ const parts = [];
66
+ if (b.wk) parts.push(`worktree ${ago(b.wk)}`);
67
+ if (b.tr) parts.push(`transcript ${ago(b.tr)}`);
68
+ return parts.length ? parts.join(", ") : "nothing";
69
+ };
70
+ let baseErr = errSize();
71
+ const armedAt = armed.startedAt || Date.now();
72
+ const SLACK = 2000; // timestamp granularity + scheduler drift under load
24
73
 
25
74
  for (;;) {
26
75
  await sleep(windowMs);
27
76
  const s = readStamp();
28
- if (!s || s.turn !== armed.turn) process.exit(0); // turn ended nothing to say
29
- const size = errSize();
30
- if (size > baseline + 200) { baseline = size; continue; } // producing output: working, re-arm
31
- const mins = Math.round((Date.now() - (s.startedAt || Date.now())) / 60000);
77
+ if (!s || s.turn !== armed.turn || (armed.runner && s.runner !== armed.runner)) process.exit(0); // turn ended, or a NEWER runner owns the stamp now
78
+ if (!runnerAlive()) process.exit(0); // our runner is gone — never speak for it
79
+ // Activity that counts: anything changed DURING the turn (after arm) and within the window
80
+ // the checklist semantics verbatim: "no new activity for the window". Pre-turn files never
81
+ // count (they predate arm), and absolute freshness cannot drift the way a probe-to-probe
82
+ // delta does when the seat writes coarsely or the machine loads (the +1130ms false alarm).
83
+ const freshCut = Math.max(armedAt, Date.now() - windowMs - SLACK);
84
+ const tr = transcriptDir ? newestMtime(transcriptDir) : 0;
85
+ const wk = workDir ? newestMtime(workDir) : 0;
86
+ if (errSize() > baseErr + 200 || tr > freshCut || wk > freshCut) continue; // producing work: alive, re-arm
87
+ const mins = Math.round((Date.now() - armedAt) / 60000);
32
88
  const orch = `${hostId()}:${project}`;
33
- const text = `⏱ ${session} turn STALLED — running ${mins}m with no output (turn ${s.turn}). Not killed; check its pane, or \`trantor swap\`.`;
89
+ const text = `⏱ ${session} turn STALLED — running ${mins}m with no activity (turn ${s.turn}; last seen: ${describeLast({ tr, wk })}, stderr ${baseErr > 0 ? `${baseErr}B` : "silent"}). Not killed; check its pane, or \`trantor swap\`.`;
34
90
  // Direct = wake. The foreman first; if this seat IS the foreman's own runner, say it to all.
35
91
  const to = orch === session ? "all" : orch;
36
92
  try { await signedPost(`${hub}/send`, { from: session, to, text, project }, { session }); } catch {}
@@ -15,8 +15,9 @@ import { resolveProject, hostId, resolveHubInfo, knownProjects, nonSeatReason, h
15
15
  import { formatSubagentManifest } from "../lib/subagent-manifest.mjs";
16
16
  import { updateAvailable, maybeNotifyDesktop, readConfig } from "./lib/update-check.mjs";
17
17
  import { maybeCheckBalances } from "./lib/balance-check.mjs";
18
- import { getJSON, signedGet, signedPost } from "./lib/api.mjs";
18
+ import { getJSON, signedGet, signedPost, loadIdentity } from "./lib/api.mjs";
19
19
  import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
20
+ import { ensureEnrolled } from "../lib/enroll.mjs";
20
21
 
21
22
  // Load the most recent UNCONSUMED handoff for this project (written by precompact.mjs
22
23
  // / the heartbeat early-warning). `claim` marks it consumed so exactly one session
@@ -221,6 +222,33 @@ try {
221
222
  // instead of silently routing to the default.
222
223
  const { url, via: hubVia } = resolveHubInfo(project);
223
224
 
225
+ // Self-enrol on an enforce hub BEFORE the first read/write, the way bin/crew-runner.mjs does for
226
+ // crew seats (#6270). A session started via `trantor open` (or any Claude session not spawned by
227
+ // the runner) never went through that path: hooks/lib/api.mjs's own ensureEnrolled POSTs /enroll
228
+ // with no invite token, which a non-loopback enforce hub refuses — and refuses SILENTLY, because
229
+ // that helper is fail-open by design. The identity then sits registered-looking (whoami works,
230
+ // it has a keypair) but unknown to the hub, and every read 401s for as long as nobody notices
231
+ // (crebral-com sat like this for 20 minutes). lib/enroll.mjs's ensureEnrolled is the real fix: it
232
+ // mints a one-shot invite with the OPERATOR's owner key and spends it as this identity, exactly
233
+ // like a crew seat enrols itself. Never silent on failure — the reason lands in both the user
234
+ // banner and the model's context, not just a stderr line nobody reads.
235
+ const orchIdentity = loadIdentity(session);
236
+ const enrolment = orchIdentity
237
+ ? await ensureEnrolled(url, orchIdentity, project).catch((e) => ({ ok: false, reason: "error:" + String(e?.message || e).slice(0, 40) }))
238
+ : { ok: false, reason: "no-identity" };
239
+ if (!enrolment.ok && enrolment.reason !== "hub-unreachable") {
240
+ const O = "\x1b[1;38;5;208m", R = "\x1b[0m";
241
+ const line = `🟠 ${O}Not enrolled on ${url}${R} (${enrolment.reason}) — reads/writes may 401. `
242
+ + `Fix: set an owner key (RELAY_OWNER_IDENTITY or ~/.agent-bus/config.json ownerIdentity) that can mint invites on this hub.`;
243
+ userBanner = userBanner ? `${userBanner}\n${line}` : line;
244
+ additionalContext += `<trantor-not-enrolled hub="${sanitize(url)}" reason="${sanitize(enrolment.reason)}">\n`;
245
+ additionalContext += `⚠️ **This session's identity (\`${sanitize(session)}\`) is NOT enrolled on \`${sanitize(url)}\`** (reason: ${sanitize(enrolment.reason)}). `;
246
+ additionalContext += `On an enforce hub this means board reads/writes will 401 until enrolment succeeds. This is not "the hub is down" — the hub is answering, it just does not recognize this identity yet. `;
247
+ additionalContext += `Tell the user: the operator's owner key needs to be able to mint invites on this hub (RELAY_OWNER_IDENTITY env or \`ownerIdentity\` in ~/.agent-bus/config.json), then restart this session.\n`;
248
+ additionalContext += `</trantor-not-enrolled>\n`;
249
+ process.stderr.write(`[trantor] WARNING: ${session} not enrolled on ${url} (${enrolment.reason})\n`);
250
+ }
251
+
224
252
  // register self + post an initial presence status (no LLM turn — instant for others to read).
225
253
  // kind "orch" when `trantor open` badged THIS session as the project's orchestrator pane
226
254
  // (#6075): the peer row's kind is the hub's own record of what a session is — the overseer's
@@ -229,7 +257,9 @@ try {
229
257
  // carries no name, so it stamps nothing. Absent kind is preserved by /register, so the MCP's
230
258
  // kindless heartbeats never erase this.
231
259
  const orchBadge = process.env.TRANTOR_ORCH || "";
232
- await jpost(`${url}/register`, { session, project, status: `active in ${project}`, ...(orchBadge === project ? { kind: "orch" } : {}) }, session).catch(() => {});
260
+ const registerBody = { session, project, status: `active in ${project}` };
261
+ if (orchBadge === project) registerBody.kind = "orch";
262
+ await jpost(`${url}/register`, registerBody, session).catch(() => {});
233
263
 
234
264
  // fetch roster of OTHER online sessions
235
265
  let peers = [], hubAnswered = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.38",
3
+ "version": "0.18.40",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -11,7 +11,7 @@
11
11
  "zod": "^4.4.3"
12
12
  },
13
13
  "scripts": {
14
- "test": "node bin/slop-gate.mjs --surface desktop/src && node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-crew-completion.mjs && node test-contracts.mjs && node test-autonomy.mjs && node test-integrate.mjs && node test-handoff.mjs && node test-handoff-summarizer.mjs && node test-baton-turn-boundary.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-baton-latest.mjs && node test-balances.mjs && node test-usage-live.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-cursor-rewind.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-cardlog.mjs && node test-checklist.mjs && node test-relay-note.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-persist-safety.mjs && node test-message-re.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-duty-dark.mjs && node test-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-crew-env.mjs && node test-desktop-transport.mjs && node test-crew-worktree.mjs && node test-crew-redact.mjs && node test-crew-classify.mjs && bash test-crew-herdr.sh && npm --prefix desktop run test --silent && 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-provider-cli.mjs && node test-crew-model-defaults.mjs && node test-inbox-delivery.mjs && node test-identity-drift.mjs && node test-inbox-staleness.mjs && node test-seats.mjs && node test-seat-identity.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && node test-dsh-seat.mjs && node test-kimi-bridge.mjs && node test-kimi-events.mjs && python3 engine/test-routing.py && node test-new.mjs && node test-prd-review.mjs && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
14
+ "test": "node bin/slop-gate.mjs --surface desktop/src && node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-crew-completion.mjs && node test-contracts.mjs && node test-autonomy.mjs && node test-integrate.mjs && node test-handoff.mjs && node test-handoff-summarizer.mjs && node test-baton-turn-boundary.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-enroll.mjs && node test-baton-latest.mjs && node test-balances.mjs && node test-usage-live.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-cursor-rewind.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-cardlog.mjs && node test-checklist.mjs && node test-relay-note.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-persist-safety.mjs && node test-message-re.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-duty-dark.mjs && node test-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-crew-env.mjs && node test-desktop-transport.mjs && node test-crew-worktree.mjs && node test-crew-redact.mjs && node test-crew-classify.mjs && bash test-crew-herdr.sh && npm --prefix desktop run test --silent && 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-provider-cli.mjs && node test-crew-model-defaults.mjs && node test-inbox-delivery.mjs && node test-identity-drift.mjs && node test-inbox-staleness.mjs && node test-seats.mjs && node test-seat-identity.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && node test-dsh-seat.mjs && node test-kimi-bridge.mjs && node test-kimi-events.mjs && python3 engine/test-routing.py && node test-new.mjs && node test-prd-review.mjs && 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 — 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": [