trantor 0.18.20 → 0.18.22

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,7 +1,7 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.20",
4
- "description": "Trantor \u2014 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)",
3
+ "version": "0.18.22",
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": {
7
7
  "command": "node",
package/README.md CHANGED
@@ -364,6 +364,11 @@ model-authored handoff instead of superseding it; the successor is injected a ca
364
364
  being spoken to; and a session hosted in a Workspace pane is replaced in place by a detached
365
365
  driver — the same chain the app's [Hand off now] button runs.
366
366
 
367
+ Waking works the same way from the other end: hover a sleeping project in the app's sidebar and
368
+ **Wake** hosts its session as an in-app pane — `trantor open` under the hood, opened in the
369
+ project's own checkout wherever it is called from, handoff-beats-resume, and the same kickoff
370
+ prompt so the woken session catches up (handoff, board, memory) and recaps on its own.
371
+
367
372
  Why crews never exhaust the orchestrator: bus messages are **by reference** (~70 tokens),
368
373
  work products stay in each agent's own context — the orchestrator burns at coordination
369
374
  rate, not work rate.
package/bin/crew.sh CHANGED
@@ -194,6 +194,24 @@ _herdr_close_pane() { [ "$DRY" = "1" ] && { echo "[dry] herdr pane close $1";
194
194
  # So the project gets ONE claude session id, chosen by us and remembered. First open starts claude
195
195
  # under it; every later open resumes it. Discovering the id afterwards would be guesswork, and
196
196
  # `--continue` would grab whatever ran last in this directory, which may be a different window.
197
+ # A NAMED project must open in ITS checkout, wherever the caller stands (2026-08-31: the app ran
198
+ # `trantor open crebral-health` from the Tauri process cwd and claude booted THERE — a trust
199
+ # prompt for a folder the operator never chose, transcripts under the wrong slug, no project
200
+ # memory, ACTIVE NOW blind). Resolution mirrors the app's project_dir: $TRANTOR_DEV_ROOT
201
+ # (default ~/development)/<name>. Unknown name from an unrelated cwd → refuse loudly rather
202
+ # than open somewhere silly.
203
+ _orch_resolve_dir() { # $1=cwd $2=project-arg → dir to open in (stdout); fails when unresolvable
204
+ local herebase; herebase="$(basename "$(git -C "$1" rev-parse --show-toplevel 2>/dev/null || echo "$1")")"
205
+ if [ -z "$2" ] || [ "$2" = "$herebase" ]; then printf '%s' "$1"; return 0; fi
206
+ local devroot="${TRANTOR_DEV_ROOT:-$HOME/development}"
207
+ if [ -d "$devroot/$2" ]; then
208
+ echo "— opening $2 in its checkout: $devroot/$2 —" >&2
209
+ printf '%s' "$devroot/$2"; return 0
210
+ fi
211
+ echo "trantor open: '$2' has no checkout at $devroot/$2 and this is '$herebase' — cd into the project first" >&2
212
+ return 1
213
+ }
214
+
197
215
  _orch_sid() { # $1=project → the project's session uuid, minting one on first use
198
216
  local f="${STATE%/*}/orch-sessions.txt" p sid
199
217
  if [ -f "$f" ]; then
@@ -244,26 +262,31 @@ _orch_cmd() { # $1=dir $2=sid
244
262
  }
245
263
 
246
264
  # A recorded thread that HANDED OFF has ended: resuming it replays a dead conversation while the
247
- # handoff waits for a successor (the 2026-08-27 seam — "Trantor resumes the wrong thread"). When the
248
- # newest unconsumed handoff for the project was written BY the recorded session, open must start a
249
- # FRESH id instead; the sessionstart hook then claims the baton and records the fresh id in
250
- # orch-sessions.txt (single writer for the map: the hook + adopt — this function writes nothing).
265
+ # handoff waits for a successor (the 2026-08-27 seam — "Trantor resumes the wrong thread").
266
+ # ANY unconsumed handoff for the project means a successor is OWED a fresh window — open must
267
+ # start a FRESH id so the sessionstart hook claims the baton (it then records the fresh id in
268
+ # orch-sessions.txt; single writer: the hook + adopt — this function writes nothing).
269
+ # The old predicate required the handoff to be written BY the recorded session (session_id match)
270
+ # — but MANUAL handoffs carry no session_id at all, so on 2026-08-31 the reboot flow resumed the
271
+ # same maxed-out conversation TWICE while its handoff sat unclaimed, and the injected recap
272
+ # banner made each resume LOOK like a clean takeover. Who wrote it doesn't matter; that it is
273
+ # unclaimed does.
251
274
  _orch_takeover_sid() { # $1=project $2=recorded-sid → prints the sid open should use
252
275
  local fresh
253
276
  if node -e '
254
277
  const fs=require("fs"),path=require("path"),os=require("os");
255
278
  const dir=path.join(process.env.AGENT_BUS_DIR||process.env.RELAY_DATA_DIR||path.join(os.homedir(),".agent-bus"),"handoffs");
256
- const [proj,sid]=process.argv.slice(1);
279
+ const [proj]=process.argv.slice(1);
257
280
  try{
258
281
  const re=new RegExp("^"+proj.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"-(\\d+)\\.json$");
259
282
  const files=fs.readdirSync(dir).map(f=>{const m=re.exec(f);return m?{f,s:Number(m[1])}:null}).filter(Boolean).sort((a,b)=>b.s-a.s);
260
283
  for(const {f} of files){
261
284
  const r=JSON.parse(fs.readFileSync(path.join(dir,f),"utf8"));
262
285
  if(r.consumed) continue;
263
- process.exit(r.session_id&&r.session_id===sid?0:1); // newest UNCONSUMED decides
286
+ process.exit(0); // newest UNCONSUMED exists → a successor is owed a fresh window
264
287
  }
265
288
  }catch(e){}
266
- process.exit(1);' "$1" "$2" 2>/dev/null; then
289
+ process.exit(1);' "$1" 2>/dev/null; then
267
290
  fresh="$(uuidgen 2>/dev/null | tr 'A-Z' 'a-z')"
268
291
  [ -n "$fresh" ] && { echo "— recorded session $2 handed off: starting fresh as $fresh to claim it —" >&2; printf '%s' "$fresh"; return 0; }
269
292
  fi
@@ -596,6 +619,7 @@ open_orchestrator() {
596
619
  --*) echo "trantor open: unknown flag '$a'"; usage_open; return 1 ;;
597
620
  *) PROJ="$a" ;;
598
621
  esac; done
622
+ DIR="$(_orch_resolve_dir "$DIR" "$PROJ")" || exit 1
599
623
  command -v herdr >/dev/null 2>&1 || { echo "trantor open needs herdr (the pane host) — install: curl -fsSL https://herdr.dev/install.sh | sh"; exit 1; }
600
624
  local wsid="" orch="" live_ids="" live_names="" pair="" line fresh=0
601
625
  local sid; sid="$(_orch_sid "$PROJ")" || { echo "trantor open: could not mint a session id (uuidgen missing?)" >&2; exit 1; }
@@ -630,6 +654,8 @@ open_orchestrator() {
630
654
  if _herdr pane rename "$orch" "orchestrator · $PROJ" >/dev/null 2>&1; then
631
655
  if _herdr_pane_has_agent "$orch"; then
632
656
  echo "herdr:${wsid:-?}/$orch"
657
+ # CONTRACT: the app's orchestrator_open (desktop terminal.rs) matches this exact phrase
658
+ # to SKIP its kickoff prompt — a reattach is a live conversation, never to be typed into.
633
659
  echo "— orchestrator already hosted: reattached to herdr:${wsid:-?}/$orch —" >&2
634
660
  return 0
635
661
  fi
@@ -441,6 +441,11 @@ try {
441
441
  // The hold LAPSES (default 30m) so an unclaimed baton never strands every other session.
442
442
  // Any other handoff keeps first-fresh-session-wins.
443
443
  const isCompact = source === "compact";
444
+ // A RESUMED session must never claim a handoff (2026-08-31, twice in one afternoon): it
445
+ // already carries its whole history, so claiming injects the takeover banner into the same
446
+ // maxed-out context — the recap LOOKS right while the window never reset, which is worse
447
+ // than failing loudly. The successor is whatever fresh session `trantor open` starts.
448
+ const isResume = source === "resume";
444
449
  const orchEnv = process.env.TRANTOR_ORCH || "";
445
450
  const isOrchPane = !!orchEnv && (orchEnv === "1" || orchEnv === project); // project-matched: a child claude in another dir must not inherit the badge
446
451
  const orchSid = readOrchSession(project);
@@ -450,12 +455,12 @@ try {
450
455
  const ageMs = peek ? Date.now() - (Number(peek.stamp) || 0) * 1000 : 0;
451
456
  const held = orchOrigin && !isOrchPane && !isCompact && ageMs < holdMs;
452
457
  const handoff = !peek ? null
453
- : (isCompact || held) ? peek
458
+ : (isCompact || held || isResume) ? peek
454
459
  : loadPendingHandoff(basename(projectDir), {
455
460
  claim: true,
456
461
  freshSession: { session_id: stdinObj.session_id || "", transcript_path: stdinObj.transcript_path || "" },
457
462
  });
458
- const claimed = !!handoff && !isCompact && !held;
463
+ const claimed = !!handoff && !isCompact && !held && !isResume;
459
464
  // Follow the thread: claiming the orchestrator's baton makes THIS session the orchestrator
460
465
  // thread, so the map `trantor open` resumes and the app's chat reads moves with it. The pane
461
466
  // also records itself on every fresh start — that keeps the map honest even if a future
@@ -468,6 +473,13 @@ try {
468
473
  additionalContext += `<trantor-handoff-held id="${sanitize(handoff.id)}" from="${sanitize(handoff.machine)}">\n`;
469
474
  additionalContext += `🔒 A handoff from this project's ORCHESTRATOR thread is pending, and it is being HELD for the Trantor orchestrator pane (\`trantor open\` claims it on start). This session did NOT claim it and does not have its content — do not act as the successor. If the user wants THIS session to take over instead: \`trantor adopt\`, then restart this session. Unclaimed, the hold lapses in ~${mins} minute(s) and the handoff becomes first-come.\n`;
470
475
  additionalContext += `</trantor-handoff-held>\n`;
476
+ } else if (handoff && isResume) {
477
+ // No takeover banner here — injecting it into a resumed session is exactly the failure this
478
+ // guard exists for: the recap reads like a clean handoff while the context never reset.
479
+ process.stderr.write(`[trantor] pending handoff ${handoff.id} NOT claimed — this session was RESUMED, not started fresh\n`);
480
+ additionalContext += `<trantor-handoff-unclaimed id="${sanitize(handoff.id)}" reason="resumed-session">\n`;
481
+ additionalContext += `⚠️ An unclaimed handoff (${sanitize(handoff.id)}) is waiting for this project, but THIS session was RESUMED (\`--resume\`) — it already carries its own history and is NOT the successor. Do not act on the handoff. Tell the user plainly: a fresh session must claim it — \`trantor open\` starts one. If they want THIS resumed session to continue instead, they can ignore the handoff or clear it.\n`;
482
+ additionalContext += `</trantor-handoff-unclaimed>\n`;
471
483
  } else if (handoff) {
472
484
  process.stderr.write(`[trantor] ${isCompact ? "showing (not claiming, compact)" : "loaded"} pending handoff ${handoff.id}\n`);
473
485
  // Baton claimed → supersede every OTHER instance of this durable identity (instance-keys
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ // USAGE v2 — the Claude statusline sidechannel (docs/RESEARCH-orca-usage.md §1.1).
3
+ // Claude Code >=2.1.80 pipes a JSON blob (with `rate_limits`) into the statusLine command on
4
+ // every turn, piggybacked on the Messages API response — live usage that costs zero API budget.
5
+ // This forwarder reads that stdin, POSTs the windows to the hub's /usage/claude (signed), and
6
+ // prints NOTHING: it is designed to be tee'd ahead of the operator's real statusline command,
7
+ // never to be one. Every failure is swallowed — a usage forwarder must never break a statusline.
8
+ //
9
+ // Floor: one POST per session per 15s (stamp file) — the statusline ticks ~3x/sec while
10
+ // streaming, and the hub dedupes same-value posts inside 30s anyway.
11
+ import { readFileSync, writeFileSync, statSync, mkdirSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { homedir } from "node:os";
14
+
15
+ const FLOOR_MS = 15_000;
16
+
17
+ async function main() {
18
+ let raw = "";
19
+ for await (const c of process.stdin) raw += c;
20
+ const j = JSON.parse(raw);
21
+ const rl = j.rate_limits;
22
+ if (!rl || typeof rl !== "object") return; // most ticks carry none — free exit
23
+ const sid = String(j.session_id || j.sessionId || "nosession").replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 80);
24
+ const dir = join(homedir(), ".agent-bus");
25
+ const stamp = join(dir, `usage-claude-${sid}.stamp`);
26
+ try { if (Date.now() - statSync(stamp).mtimeMs < FLOOR_MS) return; } catch {}
27
+ try { mkdirSync(dir, { recursive: true }); writeFileSync(stamp, ""); } catch {}
28
+
29
+ const payload = {
30
+ configDir: process.env.CLAUDE_CONFIG_DIR || null,
31
+ fiveHour: rl.five_hour ?? rl.fiveHour ?? null,
32
+ sevenDay: rl.seven_day ?? rl.sevenDay ?? null,
33
+ // The fable/model-scoped window normally arrives via the OAuth poller, but accept the
34
+ // statusline-shaped variants too — schema drift degrades instead of going dark.
35
+ fable: rl.fable_weekly ?? rl.fable ?? null,
36
+ };
37
+ if (!payload.fiveHour && !payload.sevenDay && !payload.fable) return;
38
+ // PRIMARY: the local live cache — lib/balances.mjs merges it and skips the OAuth poll while
39
+ // it is fresh (Orca's isLiveClaudeUsageFresh, docs/RESEARCH-orca-usage.md §2). This is the
40
+ // path the app's footer actually reads (it shells the local CLI, not the hub).
41
+ try { writeFileSync(join(dir, "usage-claude-live.json"), JSON.stringify({ ts: Date.now(), ...payload })); } catch {}
42
+ // SECONDARY, best-effort: the hub copy, for dashboard surfaces that read hub state.
43
+ const { signedPost } = await import("./lib/api.mjs");
44
+ await signedPost("/usage/claude", payload, { timeoutMs: 2500 });
45
+ }
46
+
47
+ main().catch(() => {}).finally(() => process.exit(0));
package/hub.mjs CHANGED
@@ -1642,6 +1642,29 @@ const server = http.createServer(async (req, res) => {
1642
1642
  if (ts >= (state.balances?.ts || 0)) { state.balances = { ts, by: String(b.by || "").slice(0, 120), entries }; dirty = true; }
1643
1643
  return json(res, 200, { ok: true });
1644
1644
  }
1645
+ // USAGE v2: the Claude statusline sidechannel. Claude Code >=2.1.80 pipes rate_limits into
1646
+ // the statusLine command on every turn; hooks/statusline.mjs forwards it here (floored 15s
1647
+ // client-side). The live windows PATCH the cached balances snapshot — free usage between
1648
+ // `trantor balances` runs, and the poller can skip Claude while liveTs is fresh (Orca's
1649
+ // lesson, docs/RESEARCH-orca-usage.md §1.1: the OAuth endpoint 429s under polling).
1650
+ if (req.method === "POST" && P === "/usage/claude") {
1651
+ const b = await body(req);
1652
+ const win = (w, name) => (w && (w.used_percentage ?? w.utilization) != null)
1653
+ ? { name, usedPct: Math.round(Number(w.used_percentage ?? w.utilization)), resetsAt: w.resets_at ?? null } : null;
1654
+ const wins = [["fiveHour", "5h"], ["sevenDay", "7d"], ["fable", "Fable"]]
1655
+ .map(([k, n]) => win(b[k], n)).filter(Boolean);
1656
+ if (!wins.length) return json(res, 400, { error: "no usable windows" });
1657
+ state.balances ||= { ts: 0, by: "", entries: [] };
1658
+ let e = state.balances.entries.find(x => x.provider === "claude");
1659
+ if (!e) { e = { provider: "claude", label: "Claude", kind: "windows", ok: true, windows: [] }; state.balances.entries.push(e); }
1660
+ // Same-value posts inside 30s are dropped (the statusline ticks ~3x/sec while streaming).
1661
+ const sig = JSON.stringify(wins);
1662
+ if (e._liveSig === sig && now() - (e.liveTs || 0) < 30_000) return json(res, 200, { ok: true, deduped: true });
1663
+ for (const w of wins) { const cur = (e.windows ||= []).find(x => x.name === w.name); if (cur) Object.assign(cur, w); else e.windows.push(w); }
1664
+ e.ok = true; e.liveTs = now(); e._liveSig = sig; e.liveSource = "statusline";
1665
+ dirty = true;
1666
+ return json(res, 200, { ok: true, windows: wins.length });
1667
+ }
1645
1668
  if (req.method === "GET" && P === "/balances") {
1646
1669
  let cfg = {}; try { cfg = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "config.json"), "utf8")); } catch {}
1647
1670
  const low = { USD: 5, CNY: 35, EUR: 5, ...(cfg.lowBalance || {}) };
package/lib/balances.mjs CHANGED
@@ -11,6 +11,10 @@
11
11
  // ARCHITECTURE NOTE: the hub runs under launchd with a minimal env (no keys), so it can't fetch these
12
12
  // itself. The env-having clients fetch + POST /balances; the hub caches + serves the dashboard.
13
13
 
14
+ import { readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { homedir } from "node:os";
17
+
14
18
  const TIMEOUT = 8000;
15
19
 
16
20
  async function getJSON(url, key, extraHeaders = {}) {
@@ -40,13 +44,40 @@ export const ADAPTERS = [
40
44
  provider: "claude", label: "Claude", kind: "windows", match: ["claude", "anthropic"], envKeys: [],
41
45
  keyless: true,
42
46
  async fetch() {
47
+ // USAGE v2: the statusline sidechannel (hooks/statusline.mjs) keeps a live local cache —
48
+ // Claude Code pipes rate_limits to the statusLine every turn, free. When the OAuth
49
+ // endpoint fails (it 429s under polling — Orca's lesson, docs/RESEARCH-orca-usage.md
50
+ // §1.1), a fresh live capture answers instead of an error row. OAuth stays primary
51
+ // because only it carries the model-scoped (Fable) window.
52
+ const live = (() => {
53
+ try {
54
+ const l = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "usage-claude-live.json"), "utf8"));
55
+ return l && Date.now() - (l.ts || 0) < 15 * 60 * 1000 ? l : null;
56
+ } catch { return null; }
57
+ })();
58
+ const liveWin = (w, name) => (w && (w.used_percentage ?? w.utilization) != null)
59
+ ? { name, usedPct: Math.round(Number(w.used_percentage ?? w.utilization)), resetsAt: w.resets_at || null, locked: null }
60
+ : null;
61
+ const liveWindows = () => [liveWin(live?.fiveHour, "5h"), liveWin(live?.sevenDay, "7d")].filter(Boolean);
43
62
  const tok = await claudeOAuthToken();
44
- if (!tok) throw new Error("no Claude Code OAuth token found");
45
- const r = await fetch("https://api.anthropic.com/api/oauth/usage", {
46
- headers: { authorization: `Bearer ${tok}`, "anthropic-beta": "oauth-2025-04-20" },
47
- signal: AbortSignal.timeout(8000),
48
- });
49
- if (!r.ok) throw new Error(`usage endpoint ${r.status}`);
63
+ if (!tok) {
64
+ if (live && liveWindows().length) return { windows: liveWindows(), live: true };
65
+ throw new Error("no Claude Code OAuth token found");
66
+ }
67
+ let r;
68
+ try {
69
+ r = await fetch("https://api.anthropic.com/api/oauth/usage", {
70
+ headers: { authorization: `Bearer ${tok}`, "anthropic-beta": "oauth-2025-04-20" },
71
+ signal: AbortSignal.timeout(8000),
72
+ });
73
+ } catch (e) {
74
+ if (live && liveWindows().length) return { windows: liveWindows(), live: true };
75
+ throw e;
76
+ }
77
+ if (!r.ok) {
78
+ if (live && liveWindows().length) return { windows: liveWindows(), live: true };
79
+ throw new Error(`usage endpoint ${r.status}`);
80
+ }
50
81
  const d = await r.json();
51
82
  const win = (w, name) => (w && w.utilization != null)
52
83
  ? { name, usedPct: Math.round(w.utilization), resetsAt: w.resets_at || null, locked: w.locked_reason || null }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.20",
3
+ "version": "0.18.22",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -11,9 +11,9 @@
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-subagent-cost.mjs && node test-inbox.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-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 && 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-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-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-baton-latest.mjs && node test-balances.mjs && node test-usage-live.mjs && node test-subagent-cost.mjs && node test-inbox.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-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 && 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-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-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
15
15
  },
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).",
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": [
18
18
  "hub.mjs",
19
19
  "mcp.mjs",