trantor 0.18.20 → 0.18.21

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.20",
3
+ "version": "0.18.21",
4
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)",
5
5
  "mcpServers": {
6
6
  "relay": {
package/bin/crew.sh CHANGED
@@ -244,26 +244,31 @@ _orch_cmd() { # $1=dir $2=sid
244
244
  }
245
245
 
246
246
  # 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).
247
+ # handoff waits for a successor (the 2026-08-27 seam — "Trantor resumes the wrong thread").
248
+ # ANY unconsumed handoff for the project means a successor is OWED a fresh window — open must
249
+ # start a FRESH id so the sessionstart hook claims the baton (it then records the fresh id in
250
+ # orch-sessions.txt; single writer: the hook + adopt — this function writes nothing).
251
+ # The old predicate required the handoff to be written BY the recorded session (session_id match)
252
+ # — but MANUAL handoffs carry no session_id at all, so on 2026-08-31 the reboot flow resumed the
253
+ # same maxed-out conversation TWICE while its handoff sat unclaimed, and the injected recap
254
+ # banner made each resume LOOK like a clean takeover. Who wrote it doesn't matter; that it is
255
+ # unclaimed does.
251
256
  _orch_takeover_sid() { # $1=project $2=recorded-sid → prints the sid open should use
252
257
  local fresh
253
258
  if node -e '
254
259
  const fs=require("fs"),path=require("path"),os=require("os");
255
260
  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);
261
+ const [proj]=process.argv.slice(1);
257
262
  try{
258
263
  const re=new RegExp("^"+proj.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")+"-(\\d+)\\.json$");
259
264
  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
265
  for(const {f} of files){
261
266
  const r=JSON.parse(fs.readFileSync(path.join(dir,f),"utf8"));
262
267
  if(r.consumed) continue;
263
- process.exit(r.session_id&&r.session_id===sid?0:1); // newest UNCONSUMED decides
268
+ process.exit(0); // newest UNCONSUMED exists → a successor is owed a fresh window
264
269
  }
265
270
  }catch(e){}
266
- process.exit(1);' "$1" "$2" 2>/dev/null; then
271
+ process.exit(1);' "$1" 2>/dev/null; then
267
272
  fresh="$(uuidgen 2>/dev/null | tr 'A-Z' 'a-z')"
268
273
  [ -n "$fresh" ] && { echo "— recorded session $2 handed off: starting fresh as $fresh to claim it —" >&2; printf '%s' "$fresh"; return 0; }
269
274
  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.21",
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-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
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": [