trantor 0.18.108 → 0.18.109

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.108",
3
+ "version": "0.18.109",
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": {
@@ -265,11 +265,11 @@ const CLI = {
265
265
  // the token-scrooge .env so an existing OPENROUTER_API_KEY authenticates with no extra wiring.
266
266
  openrouter: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
267
267
  next: `opencode run --dir {DIR} -s {SID}{M} "$(cat {P})"`, mflag: " -m ", pinned: true, env: join(homedir(), ".token-scrooge", ".env") },
268
- claude: { first: `claude{M} -p "$(cat {P})" --dangerously-skip-permissions`,
269
- next: `claude -c{M} -p "$(cat {P})" --dangerously-skip-permissions`,
268
+ claude: { first: `claude{M} -p "$(cat {P})" --permission-mode bypassPermissions --dangerously-skip-permissions`,
269
+ next: `claude -c{M} -p "$(cat {P})" --permission-mode bypassPermissions --dangerously-skip-permissions`,
270
270
  // TDD §4.6: a state step is a fresh `claude -p` (no `-c`) carrying the assembled prefix, held
271
271
  // to the TurnResult grammar by --json-schema; test/state/test-runner-state.mjs pins the strings.
272
- stateNext: `claude{M} -p "$(cat {P})" --dangerously-skip-permissions --output-format json --json-schema "$(cat {S})"`,
272
+ stateNext: `claude{M} -p "$(cat {P})" --permission-mode bypassPermissions --dangerously-skip-permissions --output-format json --json-schema "$(cat {S})"`,
273
273
  mflag: " --model " },
274
274
  // DeepSeek Harness: every turn is a fresh session (headless has no resume), so the seat leans on
275
275
  // the wake prompt and the board. `trantor connect` builds ~/.dsh/profiles/trantor; no model flag.
@@ -60,18 +60,41 @@ export function tokenFromEnvironment(text, socketPath) {
60
60
  return /(?:^|\s)CLAUDE_CODE_MESSAGING_TOKEN=(\S+)/.exec(text)?.[1] || "";
61
61
  }
62
62
 
63
- export function resolveRecipient(recipient, { bus = busDir(), localHost = hostId(), command = run, socketDir = "/tmp/cc-socks" } = {}) {
64
- if (!recipient.startsWith(`${localHost}:`)) return null;
63
+ // #12092: CC 2.1.248 may bind under a private per-user directory instead of the default one, so the
64
+ // socket path comes from the target's own CLAUDE_CODE_MESSAGING_SOCKET. This is the same text read as above.
65
+ export function messagingFromEnvironment(text) {
66
+ const socket = /(?:^|\s)CLAUDE_CODE_MESSAGING_SOCKET=(\S+)/.exec(text)?.[1] || "";
67
+ const token = /(?:^|\s)CLAUDE_CODE_MESSAGING_TOKEN=(\S+)/.exec(text)?.[1] || "";
68
+ return { socket: socket.startsWith("/") && !socket.split("/").includes("..") ? socket : "", token };
69
+ }
70
+
71
+ // #12092: where CC binds when the env var cannot be trusted (read from the CC 2.1.304 binary): the
72
+ // default <XDG_RUNTIME_DIR or tmp>/cc-socks, and the per-uid fallback <tmp>/cc-socks-<uid> used when
73
+ // the default is refused or the path is over 103 bytes. macOS /tmp is /private/tmp, so both spellings.
74
+ export function fallbackSocketDirs({ env = process.env, uid = process.getuid?.() ?? 0 } = {}) {
75
+ const dirs = [];
76
+ if (env.XDG_RUNTIME_DIR) dirs.push(join(env.XDG_RUNTIME_DIR, "cc-socks"));
77
+ for (const base of ["/tmp", "/private/tmp"]) dirs.push(join(base, "cc-socks"), join(base, `cc-socks-${uid}`));
78
+ return [...new Set(dirs)];
79
+ }
80
+
81
+ // #12092: a structured verdict so an unreachable session carries a reason; resolveRecipient keeps
82
+ // the old null-or-target contract. `quiet` marks ordinary skips (remote, busy, ambiguous) from
83
+ // reportable ones (the session is local and idle but has no usable socket).
84
+ export function resolveRecipientDetailed(recipient, { bus = busDir(), localHost = hostId(), command = run, socketDir } = {}) {
85
+ const skip = reason => ({ ok: false, quiet: true, reason });
86
+ const unreachable = reason => ({ ok: false, quiet: false, reason });
87
+ if (!recipient.startsWith(`${localHost}:`)) return skip("not a local recipient");
65
88
  const project = recipient.slice(localHost.length + 1);
66
89
  const mapped = read(join(bus, "orch-sessions.txt")).split("\n").find(line => line.split("\t")[0] === project)?.split("\t")[1]?.trim();
67
90
  let agents = [];
68
91
  try { agents = JSON.parse(command("herdr", ["agent", "list"])).result.agents; } catch { /* #7429: map remains available without herdr. */ }
69
92
  const panes = recipientPanes(project, mapped, agents);
70
- if (panes.length > 1) return null;
93
+ if (panes.length > 1) return skip("more than one candidate pane");
71
94
  const pane = panes[0];
72
- if (["working", "busy"].includes(pane?.agent_status)) return null;
95
+ if (["working", "busy"].includes(pane?.agent_status)) return skip("busy");
73
96
  const sid = pane?.agent_session?.value || mapped;
74
- if (!sid || !/^[\w-]+$/.test(sid)) return null;
97
+ if (!sid || !/^[\w-]+$/.test(sid)) return skip("no session id");
75
98
  const rows = processRows(command("ps", ["-axo", "pid=,ppid=,command="]));
76
99
  let matches = rows.filter(row => claudeProcess(row.command) && new RegExp(`(?:--session-id|--resume|-r)\\s+${sid}(?:\\s|$)`).test(row.command));
77
100
  if (!matches.length && pane) {
@@ -79,36 +102,56 @@ export function resolveRecipient(recipient, { bus = busDir(), localHost = hostId
79
102
  const info = JSON.parse(command("herdr", ["pane", "process-info", "--pane", pane.pane_id])).result.process_info;
80
103
  const pids = new Set(info.foreground_processes.filter(p => /^(claude|claude.exe)$/.test(p.name)).map(p => p.pid));
81
104
  matches = rows.filter(row => pids.has(row.pid) && claudeProcess(row.command));
82
- } catch { return null; }
105
+ } catch { return skip("claude process not found via herdr"); }
83
106
  }
84
- if (matches.length !== 1) return null;
107
+ if (matches.length !== 1) return skip(matches.length ? "more than one claude process" : "claude process not found");
85
108
  const pid = matches[0].pid;
86
- const socketPath = join(socketDir, `${pid}.sock`);
87
- if (!existsSync(socketPath)) return null;
88
- // #7429: preserve ps's default columns for env; descendants must name this exact socket.
109
+ // #7429: preserve ps's default columns for env. Only the target's own process tree is read.
110
+ let socket = ""; let token = "";
89
111
  for (const envPid of descendants(pid, rows)) {
90
- let token = "";
91
- try { token = tokenFromEnvironment(command("ps", ["eww", "-p", String(envPid)]), socketPath); } catch { continue; }
92
- if (token) return { pid, sid, token, socketPath, pollStamp: ledgerPaths(recipient, sid, bus).pollStamp };
112
+ let found;
113
+ try { found = messagingFromEnvironment(command("ps", ["eww", "-p", String(envPid)])); } catch { continue; }
114
+ if (found.token && (found.socket || !socket)) { ({ socket, token } = found); if (socket) break; }
93
115
  }
94
- return null;
116
+ // #12092: CC 2.1.290 --restricted sessions (and any session with messaging off) never open a socket.
117
+ if (!token) return unreachable(`no CLAUDE_CODE_MESSAGING_TOKEN in claude pid ${pid}'s environment (--restricted or cross-session messaging off); no socket to nudge`);
118
+ const dirs = socketDir ? [].concat(socketDir) : fallbackSocketDirs();
119
+ const tried = [socket, ...dirs.map(dir => join(dir, `${pid}.sock`))].filter(Boolean);
120
+ const socketPath = tried.find(path => existsSync(path));
121
+ if (!socketPath) return unreachable(`claude pid ${pid} has a messaging token but no socket exists at ${tried.join(" or ")}`);
122
+ return { ok: true, target: { pid, sid, token, socketPath, pollStamp: ledgerPaths(recipient, sid, bus).pollStamp } };
95
123
  }
96
124
 
97
- export function postNudge({ socketPath, token, sid }, ids, timeoutMs = 1500) {
125
+ export function resolveRecipient(recipient, options = {}) {
126
+ const verdict = resolveRecipientDetailed(recipient, options);
127
+ return verdict.ok ? verdict.target : null;
128
+ }
129
+
130
+ // #12092: the inbox drops a silent connection after 30s, so write both newline-ended lines at once,
131
+ // half-close and time out fast. It never replies here (held/refused go to the sender's own inbox).
132
+ export function postNudgeDetailed({ socketPath, token, sid }, ids, timeoutMs = 1500) {
98
133
  const content = `<cross-session-message from="trantor:wake">\nYour Trantor bus inbox has unread message ids ${ids.map(id => `#${id}`).join(", ")}. Read them with relay_inbox and reply over the bus with relay_send. This nudge carries no message content; the signed bus messages are the source of truth.\n</cross-session-message>`;
99
134
  const lines = [{ type: "auth", token }, { type: "user", session_id: sid, message: { role: "user", content } }];
100
135
  return new Promise(resolve => {
101
136
  const socket = createConnection(socketPath);
102
- let sent = false;
103
- const finish = ok => { socket.destroy(); resolve(ok); };
104
- socket.setTimeout(timeoutMs, () => finish(false));
105
- socket.on("error", () => finish(false));
137
+ let sent = false; let settled = false; let failure = "";
138
+ const finish = (ok, reason) => {
139
+ if (settled) return;
140
+ settled = true; clearTimeout(hard); socket.destroy(); resolve({ ok, reason });
141
+ };
142
+ const hard = setTimeout(() => finish(false, sent ? "no close from the inbox after the write" : "connect or write timed out"), timeoutMs);
143
+ hard.unref();
144
+ socket.on("error", error => { failure = error.code || error.message; finish(false, `socket error ${failure}`); });
106
145
  socket.on("connect", () => socket.end(lines.map(line => JSON.stringify(line)).join("\n") + "\n", () => { sent = true; }));
107
- socket.on("close", hadError => finish(sent && !hadError));
146
+ socket.on("close", hadError => finish(sent && !hadError, sent ? "" : "closed before the write finished"));
108
147
  });
109
148
  }
110
149
 
111
- export async function wakeOnce({ api, bus = busDir(), resolver = recipient => resolveRecipient(recipient, { bus }), verifyMs = 10000, now = Date.now(), attempted = new Set(), cursor = { id: 0, ts: 0 }, log = m => console.error(m) }) {
150
+ export async function postNudge(target, ids, timeoutMs) {
151
+ return (await postNudgeDetailed(target, ids, timeoutMs)).ok;
152
+ }
153
+
154
+ export async function wakeOnce({ api, bus = busDir(), resolver = recipient => { const verdict = resolveRecipientDetailed(recipient, { bus }); return verdict.ok ? verdict.target : verdict.quiet ? null : { unreachable: verdict.reason }; }, verifyMs = 10000, now = Date.now(), attempted = new Set(), cursor = { id: 0, ts: 0 }, log = m => console.error(m) }) {
112
155
  const feed = await api(`/events?type=message&by=hub%3Aduty&since=${cursor.id}${cursor.id ? "" : "&limit=2000"}`);
113
156
  if (Number.isFinite(feed.latest) && feed.latest < cursor.id) {
114
157
  cursor.id = 0; cursor.ts = 0;
@@ -121,7 +164,12 @@ export async function wakeOnce({ api, bus = busDir(), resolver = recipient => re
121
164
  .map(event => ({ from: event.by, text: event.text, ts: event.ts }));
122
165
  const resolved = new Map();
123
166
  for (const { recipient } of dutyEscalations(messages)) {
124
- if (!resolved.has(recipient)) resolved.set(recipient, resolver(recipient));
167
+ if (resolved.has(recipient)) continue;
168
+ const target = resolver(recipient);
169
+ // #12092: a local idle session with no usable socket is reported, never dropped silently; the
170
+ // cursor has moved past this alert so it is not retried, and duty's own triage path stays open.
171
+ if (target?.unreachable) log(`wake-nudge: ${recipient} unreachable: ${target.unreachable}; leaving it to the duty seat`);
172
+ resolved.set(recipient, target?.unreachable ? null : target);
125
173
  }
126
174
  const reachable = messages.filter(message => {
127
175
  const item = dutyEscalations([message])[0];
@@ -153,7 +201,8 @@ export async function wakeOnce({ api, bus = busDir(), resolver = recipient => re
153
201
  const local = resolved.get(target.recipient);
154
202
  const before = Number(read(local.pollStamp));
155
203
  const postedAt = Date.now();
156
- if (!await postNudge(local, target.ids)) return;
204
+ const post = await postNudgeDetailed(local, target.ids);
205
+ if (!post.ok) { log(`wake-nudge: ${target.recipient} socket post failed: ${post.reason}`); return; }
157
206
  while (Date.now() - postedAt < verifyMs) {
158
207
  const stamp = Number(read(local.pollStamp));
159
208
  if (stamp > before && stamp >= postedAt) {
@@ -162,6 +211,9 @@ export async function wakeOnce({ api, bus = busDir(), resolver = recipient => re
162
211
  }
163
212
  await sleep(100);
164
213
  }
214
+ // #12092: written and closed, but the session never polled. The inbox gives no verdict on this
215
+ // connection, so held (bypassPermissions receiver, crossSessionInbound hold), refused and dropped look alike.
216
+ log(`wake-nudge: ${target.recipient} posted but not polled within ${verifyMs}ms: held, refused or dropped by the session (no verdict on this socket); duty keeps the triage path`);
165
217
  }));
166
218
  // #11355: a tick that ends with unverified sends releases their stamps — the ledger must
167
219
  // mirror the claim release below, or a failed send would silence the duty path for the TTL.
@@ -9,11 +9,15 @@ import { join, relative, resolve } from "node:path";
9
9
  import { homedir } from "node:os";
10
10
  import { relayUrl, sessionContext, signedPost } from "./lib/api.mjs";
11
11
  import { writeOverseerLevel } from "./lib/overseer-level-cache.mjs";
12
+ import { failOpenAfter } from "./lib/fail-open.mjs";
12
13
  import { overlapsFor } from "../lib/remote-overlap.mjs";
13
14
 
14
15
  const FETCH_TIMEOUT_MS = Number(process.env.RELAY_CLAIM_TIMEOUT_MS || 900);
15
16
  const RECLAIM_MS = Number(process.env.RELAY_RECLAIM_MS || 60 * 1000);
16
17
 
18
+ // 2.1.299: a gating hook that cannot answer in time is refused, which would block the edit.
19
+ failOpenAfter(Number(process.env.RELAY_CLAIM_WATCHDOG_MS || 6000));
20
+
17
21
  const allow = () => { process.stdout.write("{}"); process.exit(0); };
18
22
 
19
23
  function readStdin() {
@@ -2,6 +2,7 @@
2
2
  import { gitCheckoutRoot } from "./lib/git-checkout.mjs";
3
3
  import { relative, resolve } from "node:path";
4
4
  import { relayUrl, sessionContext, signedPost } from "./lib/api.mjs";
5
+ import { failOpenAfter, readStdinWithin } from "./lib/fail-open.mjs";
5
6
  import { readOverseerLevel, writeOverseerLevel } from "./lib/overseer-level-cache.mjs";
6
7
 
7
8
  function deny(reason) {
@@ -15,9 +16,12 @@ function unavailable() {
15
16
  process.stdout.write("{}");
16
17
  }
17
18
 
19
+ // 2.1.299: a gating hook that cannot answer in time is refused, which would block the edit. The hub
20
+ // call below is capped at 2.5s; this backstop answers "no decision" if anything else hangs.
21
+ failOpenAfter(Number(process.env.RELAY_HOLD_WATCHDOG_MS || 5000));
22
+
18
23
  try {
19
- let raw = "";
20
- for await (const chunk of process.stdin) raw += chunk;
24
+ const raw = await readStdinWithin(1000);
21
25
  const input = JSON.parse(raw || "{}");
22
26
  const path = input.tool_name === "NotebookEdit" ? input.tool_input?.notebook_path : input.tool_input?.file_path;
23
27
  const ctx = sessionContext(input.cwd);
@@ -47,3 +51,5 @@ try {
47
51
  } catch {
48
52
  unavailable();
49
53
  }
54
+ // The hook has answered. A host that left stdin open must not keep the process (and a second watchdog answer) alive.
55
+ process.exit(0);
package/hooks/hooks.json CHANGED
@@ -51,7 +51,8 @@
51
51
  "hooks": [
52
52
  {
53
53
  "type": "command",
54
- "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/subagent-start.mjs"
54
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/subagent-start.mjs",
55
+ "timeout": 10
55
56
  }
56
57
  ]
57
58
  },
@@ -60,7 +61,8 @@
60
61
  "hooks": [
61
62
  {
62
63
  "type": "command",
63
- "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/file-claim.mjs"
64
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/file-claim.mjs",
65
+ "timeout": 15
64
66
  }
65
67
  ]
66
68
  },
@@ -69,7 +71,8 @@
69
71
  "hooks": [
70
72
  {
71
73
  "type": "command",
72
- "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ask-sidecar.mjs"
74
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ask-sidecar.mjs",
75
+ "timeout": 10
73
76
  }
74
77
  ]
75
78
  },
@@ -78,7 +81,8 @@
78
81
  "hooks": [
79
82
  {
80
83
  "type": "command",
81
- "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ask-sidecar.mjs"
84
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ask-sidecar.mjs",
85
+ "timeout": 10
82
86
  }
83
87
  ]
84
88
  },
@@ -87,7 +91,8 @@
87
91
  "hooks": [
88
92
  {
89
93
  "type": "command",
90
- "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/file-hold.mjs"
94
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/file-hold.mjs",
95
+ "timeout": 10
91
96
  }
92
97
  ]
93
98
  }
@@ -98,7 +103,20 @@
98
103
  "hooks": [
99
104
  {
100
105
  "type": "command",
101
- "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ask-sidecar.mjs"
106
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ask-sidecar.mjs",
107
+ "timeout": 10
108
+ }
109
+ ]
110
+ }
111
+ ],
112
+ "PostModelSwitch": [
113
+ {
114
+ "matcher": "",
115
+ "hooks": [
116
+ {
117
+ "type": "command",
118
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/model-switch.mjs",
119
+ "timeout": 10
102
120
  }
103
121
  ]
104
122
  }
@@ -6,6 +6,7 @@ import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
6
6
  // Transcripts grow to MBs; the final assistant message is at the END at Stop time, so a tail window
7
7
  // is enough. 128 KiB covers a long closing reply; anything older is not the turn that just ended.
8
8
  const TAIL_BYTES = 128 * 1024;
9
+ const WIDE_TAIL_BYTES = 2 * 1024 * 1024;
9
10
 
10
11
  /** The verdict zone is the LAST paragraph. A "?" buried mid-reply — a regex like `a?b`, a
11
12
  * rhetorical aside, a question the turn itself went on to answer — is not the session waiting on
@@ -25,25 +26,35 @@ export function lastAssistantText(transcriptPath) {
25
26
  if (!transcriptPath || !existsSync(transcriptPath)) return "";
26
27
  const size = statSync(transcriptPath).size;
27
28
  if (!size) return "";
28
- const want = Math.min(size, TAIL_BYTES);
29
- const f = openSync(transcriptPath, "r");
30
- let tail;
31
- try {
32
- const buf = Buffer.alloc(want);
33
- readSync(f, buf, 0, want, size - want);
34
- tail = buf.toString("utf8");
35
- } finally { closeSync(f); }
36
- const lines = tail.split("\n");
37
- if (want < size) lines.shift(); // the window can open mid-line; drop the partial
38
- let text = "";
39
- for (const line of lines) {
40
- let entry;
41
- try { entry = JSON.parse(line); } catch { continue; }
42
- if (entry?.type !== "assistant") continue;
43
- const parts = Array.isArray(entry.message?.content) ? entry.message.content : [];
44
- const joined = parts.filter(p => p?.type === "text").map(p => String(p.text ?? "")).join("\n").trim();
45
- if (joined) text = joined;
29
+ // CC 2.1.300 writes each API request's system prompt and tools into the transcript: one such row
30
+ // can fill the whole 128 KiB window. Only a window that held NO assistant row at all widens (once);
31
+ // a tool-only final turn still reads as "", never as an older turn's prose.
32
+ for (const limit of [TAIL_BYTES, WIDE_TAIL_BYTES]) {
33
+ const want = Math.min(size, limit);
34
+ const f = openSync(transcriptPath, "r");
35
+ let tail;
36
+ try {
37
+ const buf = Buffer.alloc(want);
38
+ readSync(f, buf, 0, want, size - want);
39
+ tail = buf.toString("utf8");
40
+ } finally { closeSync(f); }
41
+ const lines = tail.split("\n");
42
+ if (want < size) lines.shift(); // the window can open mid-line; drop the partial
43
+ let text = "";
44
+ let sawAssistant = false;
45
+ for (const line of lines) {
46
+ // Only a line naming "assistant" is worth a parse; request rows are huge and never one.
47
+ if (!line.includes('"assistant"')) continue;
48
+ let entry;
49
+ try { entry = JSON.parse(line); } catch { continue; }
50
+ if (entry?.type !== "assistant") continue;
51
+ sawAssistant = true;
52
+ const parts = Array.isArray(entry.message?.content) ? entry.message.content : [];
53
+ const joined = parts.filter(p => p?.type === "text").map(p => String(p.text ?? "")).join("\n").trim();
54
+ if (joined) text = joined;
55
+ }
56
+ if (sawAssistant || want >= size) return text;
46
57
  }
47
- return text;
58
+ return "";
48
59
  } catch { return ""; }
49
60
  }
@@ -0,0 +1,42 @@
1
+ // Pure decoders for the cheap signals Claude Code puts in hook and status line JSON, so the hooks
2
+ // stay thin and the field names live in one place (docs/SYSTEM-CONTRACT.md section 9).
3
+
4
+ const num = (v) => (v !== null && v !== "" && Number.isFinite(Number(v)) ? Number(v) : null);
5
+
6
+ // Status line `prompt_cache` object (CC 2.1.251+) reduced to what the board shows; null when absent.
7
+ export function promptCacheOf(j) {
8
+ const p = j?.prompt_cache;
9
+ if (!p || Array.isArray(p) || !(p instanceof Object)) return null;
10
+ const cause = p.last_miss_cause?.causes;
11
+ return {
12
+ warm: p.warm === true,
13
+ hitRatio: num(p.hit_ratio),
14
+ requests: num(p.requests),
15
+ misses: num(p.misses),
16
+ ttl: p.ttl === "1h" || p.ttl === "5m" ? p.ttl : null,
17
+ recacheTokensIfCold: num(p.recache_tokens_if_cold),
18
+ lastMissCause: Array.isArray(cause) && cause.length ? String(cause[0]).slice(0, 40) : null,
19
+ };
20
+ }
21
+
22
+ // One line for a resumed SessionStart (CC 2.1.251+): how stale the session is and what re-caching
23
+ // it costs. Empty string unless this is a resume/fork and the staleness fields are present.
24
+ export function staleResumeLine(input) {
25
+ if (input?.source !== "resume" && input?.source !== "fork") return "";
26
+ const secs = num(input.seconds_since_last_response);
27
+ if (secs === null) return "";
28
+ const age = secs >= 3600 ? `${(secs / 3600).toFixed(1)}h` : secs >= 60 ? `${Math.round(secs / 60)}m` : `${Math.round(secs)}s`;
29
+ const usd = num(input.estimated_cache_write_usd);
30
+ const toks = num(input.context_tokens);
31
+ const cold = input.prompt_cache_likely_expired === true;
32
+ const cost = usd !== null ? `, re-caching ${toks !== null ? `${Math.round(toks / 1000)}k tokens ` : ""}costs about $${usd.toFixed(2)}` : "";
33
+ return `Resumed session is ${age} stale, prompt cache ${cold ? "likely expired" : "probably still warm"}${cost}; a fresh session may be cheaper.`;
34
+ }
35
+
36
+ // PostModelSwitch input (CC 2.1.251+) to the /register body that tells the hub the session's model
37
+ // right now, instead of waiting for the next heartbeat to read it off the transcript. Null if no model.
38
+ export function modelSwitchRegister(input, { session, project, llm = "claude" }) {
39
+ const model = String(input?.to_model || "").trim();
40
+ if (!model) return null;
41
+ return { session, project, llm, model: model.slice(0, 60) };
42
+ }
@@ -0,0 +1,22 @@
1
+ // CC 2.1.299 refuses an action when its gating hook cannot answer in time, so ours must always
2
+ // answer inside the hooks.json timeout. This backstop prints "no decision" if something hangs past
3
+ // `ms`; the timer is unref'd, so a hook that finishes normally never reaches it.
4
+ export function failOpenAfter(ms, answer = "{}") {
5
+ const t = setTimeout(() => {
6
+ try { process.stdout.write(answer); } catch {}
7
+ process.exit(0);
8
+ }, ms);
9
+ t.unref();
10
+ return t;
11
+ }
12
+
13
+ // stdin that gives up: a host that never closes the pipe must not hold the hook past its budget.
14
+ export function readStdinWithin(ms) {
15
+ return new Promise(res => {
16
+ let d = ""; process.stdin.setEncoding("utf8");
17
+ process.stdin.on("data", c => { d += c; });
18
+ process.stdin.on("end", () => res(d));
19
+ process.stdin.on("error", () => res(d));
20
+ setTimeout(() => res(d), ms).unref();
21
+ });
22
+ }
@@ -41,24 +41,30 @@ export function relayUrl(conf = readConfig()) {
41
41
  // Read only the tail of the (potentially huge, append-only) transcript and find
42
42
  // the most recent assistant turn's usage. Current context tokens ≈ input +
43
43
  // cache_read + cache_creation (the cached prompt IS part of the window).
44
- export function contextUsage(transcriptPath, conf = readConfig()) {
45
- if (!transcriptPath || !existsSync(transcriptPath)) return null;
46
- let buf = "";
44
+
45
+ // CC 2.1.300 also writes each API request's system prompt and tools into the transcript: huge rows
46
+ // with no usage. A long row is parsed only if it names "usage" or the compaction marker.
47
+ const BIG_ROW_BYTES = 64 * 1024;
48
+ const TAIL_BYTES_STEPS = [1_500_000, 6_000_000]; // a tail full of request rows widens once
49
+
50
+ function tailText(transcriptPath, want) {
51
+ let fd;
47
52
  try {
48
- const fd = openSync(transcriptPath, "r");
49
- try {
50
- const size = fstatSync(fd).size;
51
- const tail = Math.min(size, 1_500_000); // last ~1.5MB is plenty for recent turns
52
- const b = Buffer.alloc(tail);
53
- readSync(fd, b, 0, tail, size - tail);
54
- buf = b.toString("utf8");
55
- } finally { closeSync(fd); }
56
- } catch { return null; }
57
-
58
- const lines = buf.split("\n").filter(Boolean);
53
+ fd = openSync(transcriptPath, "r");
54
+ const size = fstatSync(fd).size;
55
+ const tail = Math.min(size, want);
56
+ const b = Buffer.alloc(tail);
57
+ readSync(fd, b, 0, tail, size - tail);
58
+ return { text: b.toString("utf8"), whole: tail >= size };
59
+ } catch { return null; } finally { if (fd !== undefined) { try { closeSync(fd); } catch {} } }
60
+ }
61
+
62
+ function usageRows(text) {
59
63
  const rows = [];
60
64
  let model = "";
61
- for (const line of lines) {
65
+ for (const line of text.split("\n")) {
66
+ if (!line) continue;
67
+ if (line.length > BIG_ROW_BYTES && !line.includes("\"usage\"") && !line.includes("compact_boundary")) continue;
62
68
  let r; try { r = JSON.parse(line); } catch { continue; }
63
69
  if (r?.type === "system" && r?.subtype === "compact_boundary") { rows.push("compact"); continue; }
64
70
  const u = r?.message?.usage;
@@ -68,6 +74,19 @@ export function contextUsage(transcriptPath, conf = readConfig()) {
68
74
  rows.push(tokens);
69
75
  model = r.message.model || model;
70
76
  }
77
+ return { rows, model };
78
+ }
79
+
80
+ export function contextUsage(transcriptPath, conf = readConfig()) {
81
+ if (!transcriptPath || !existsSync(transcriptPath)) return null;
82
+ let found = { rows: [], model: "" };
83
+ for (const want of TAIL_BYTES_STEPS) {
84
+ const t = tailText(transcriptPath, want);
85
+ if (!t) return null;
86
+ found = usageRows(t.text);
87
+ if (found.rows.length || t.whole) break;
88
+ }
89
+ const { rows, model } = found;
71
90
  const tokens = guardContextTokens(rows);
72
91
  if (tokens == null) return null;
73
92
  const window = resolveWindow(model, conf);
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ // trantor PostModelSwitch hook — Claude Code reports the session's new model (to_model). The
3
+ // heartbeat only learns it from the transcript tail, a minute and a turn late, so this tells the
4
+ // hub right away and the peer row (and anything pricing off it) carries the model that is running.
5
+ // Prints nothing: plain stdout on this event becomes model context. Fail-silent, exits 0.
6
+ import { homedir } from "node:os";
7
+ import { sessionContext, signedPost } from "./lib/api.mjs";
8
+ import { modelSwitchRegister } from "./lib/cc-signals.mjs";
9
+
10
+ setTimeout(() => process.exit(0), 4000).unref(); // watchdog: never hold up the switch
11
+
12
+ function readStdin() {
13
+ return new Promise(res => {
14
+ let d = ""; process.stdin.setEncoding("utf8");
15
+ process.stdin.on("data", c => { d += c; });
16
+ process.stdin.on("end", () => res(d));
17
+ setTimeout(() => res(d), 300);
18
+ });
19
+ }
20
+
21
+ try {
22
+ const input = JSON.parse((await readStdin()) || "{}");
23
+ const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
24
+ // Same rule as heartbeat.mjs: a bare home-directory session is not project work.
25
+ if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && cwd === homedir()) process.exit(0);
26
+ const { session, project } = sessionContext(cwd);
27
+ const llm = process.env.RELAY_LLM || (process.env.RELAY_AGENT ? process.env.RELAY_AGENT.replace(/-orch$/, "") : "claude");
28
+ const body = modelSwitchRegister(input, { session, project, llm });
29
+ if (body) {
30
+ await signedPost("/register", body, { session, timeoutMs: 2500 });
31
+ process.stderr.write(`[trantor] model switch ${input.from_model || "?"} -> ${body.model} (${input.source || "?"})\n`);
32
+ }
33
+ } catch (e) {
34
+ process.stderr.write(`[trantor] model-switch error: ${e?.message || e}\n`);
35
+ }
36
+ process.exit(0);
@@ -31,7 +31,13 @@ try {
31
31
  // Spawn a fresh session UNLESS the heartbeat early-warning already did so for this
32
32
  // window (shared guard). The handoff file is always refreshed above regardless.
33
33
  const cur = contextUsage(transcript, conf)?.tokens || 0;
34
- if (alreadyHandedOff(sessionId, cur)) {
34
+ // CC 2.1.299 compacts an idle 200K+ session on its own before the prompt cache expires. The
35
+ // changelog does not say whether PreCompact fires for it or what `trigger` it carries. Nobody is
36
+ // at the keyboard then, so only the two known triggers may open a fresh session or a prompt; any
37
+ // other value keeps the handoff file fresh and stops there.
38
+ if (trigger !== "auto" && trigger !== "manual") {
39
+ process.stderr.write(`[trantor] compaction trigger "${trigger}" is not auto/manual (idle compaction?) — handoff refreshed only, no fresh session\n`);
40
+ } else if (alreadyHandedOff(sessionId, cur)) {
35
41
  process.stderr.write(`[trantor] fresh session already spawned for this window — handoff refreshed only\n`);
36
42
  } else if (maybeSpawn(projectDir, conf)) {
37
43
  markHandedOff(sessionId, cur);
@@ -37,6 +37,14 @@ const HARNESS_MARKERS = [
37
37
  "<command-name", "<environment_context", "[SYSTEM NOTIFICATION",
38
38
  ];
39
39
  const isHarnessInjection = (t) => HARNESS_MARKERS.some(m => t.startsWith(m));
40
+ // CC 2.1.299 runs this hook BEFORE a slash command expands, so it sees the raw `/foo args`.
41
+ // A bare command is never a focus; with arguments the focus is the arguments. Anchored on
42
+ // `/name` then whitespace or end, so an absolute path ("/Users/x/y") is not a command.
43
+ const SLASH_COMMAND = /^\/[A-Za-z][\w:.-]*(?:\s+([\s\S]*))?$/;
44
+ function stripSlashCommand(text) {
45
+ const m = SLASH_COMMAND.exec(text);
46
+ return m ? (m[1] || "").trim() : text;
47
+ }
40
48
  function titleFrom(prompt) {
41
49
  let s = String(prompt || "").replace(/\s+/g, " ").trim();
42
50
  // strip a leading politeness/imperative wrapper so the card reads as the WORK, not "can you please…"
@@ -80,7 +88,7 @@ try {
80
88
  // don't card home-dir sessions (matches sessionstart's phantom-project guard)
81
89
  if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && cwd === homedir()) { emitAndExit(); }
82
90
  const trimmed = prompt.replace(/\s+/g, " ").trim();
83
- const text = unwrapPasted(trimmed); // a paste's wrapper is transport; judge the words inside
91
+ const text = stripSlashCommand(unwrapPasted(trimmed)); // a paste's wrapper is transport; a raw /command is not the work
84
92
  // skip empties, tiny continuations, and pure acks — they're not a new focus
85
93
  if (!text || text.length < 12 || ACK.test(text)) { emitAndExit(); }
86
94
  // The Accounts ask drill launches a real Claude session, so its scripted prompt traverses this
@@ -17,6 +17,7 @@ import { getJSON, signedGet, signedPost, loadIdentity } from "./lib/api.mjs";
17
17
  import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
18
18
  import { ensureEnrolled } from "../lib/enroll.mjs";
19
19
  import { memoryStartLine } from "./lib/memory-start.mjs";
20
+ import { staleResumeLine } from "./lib/cc-signals.mjs";
20
21
 
21
22
  // The build doctrine (docs/BUILD-DOCTRINE.md) in short form, one line per rule, injected for every
22
23
  // project's ORCHESTRATOR at boot (#6452): orchestrators that never opened the doc missed the ruling.
@@ -294,6 +295,14 @@ try {
294
295
  } catch {}
295
296
  }
296
297
 
298
+ // Claude Code hands a resume its staleness and re-cache estimate: one line so the model and the
299
+ // operator can pick resume vs fresh. Nothing when the fields are absent.
300
+ const staleLine = staleResumeLine(stdinObj);
301
+ if (staleLine) {
302
+ additionalContext += `<trantor-resume-staleness>${sanitize(staleLine)}</trantor-resume-staleness>\n`;
303
+ process.stderr.write(`[trantor] ${staleLine}\n`);
304
+ }
305
+
297
306
  // CATCH-UP: a project is a DURABLE, continuous lane — not a session. Before doing
298
307
  // anything, this session reconciles with the living board: what's been built, what's
299
308
  // in flight, what's queued, plus the latest commits. So a fresh window resumes the
@@ -6,6 +6,7 @@
6
6
  import { readFileSync, writeFileSync, statSync, mkdirSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { homedir } from "node:os";
9
+ import { promptCacheOf } from "./lib/cc-signals.mjs";
9
10
 
10
11
  const FLOOR_MS = 15_000;
11
12
 
@@ -14,11 +15,12 @@ async function main() {
14
15
  for await (const c of process.stdin) raw += c;
15
16
  const j = JSON.parse(raw);
16
17
  const rl = j.rate_limits;
18
+ const promptCache = promptCacheOf(j);
17
19
  // SAFETY: rate_limits is the Claude statusline envelope decoded by JSON.parse above; the check
18
20
  // separates "tick carries a rate_limits envelope" (any object, even field-less — it refreshes
19
21
  // the stamp below) from "no envelope" (primitives/null — a free exit).
20
22
  // oxlint-disable-next-line anti-slop/no-runtime-typeof
21
- if (!rl || typeof rl !== "object") return; // most ticks carry none — free exit
23
+ if ((!rl || typeof rl !== "object") && !promptCache) return; // most ticks carry neither — free exit
22
24
  const sid = String(j.session_id || j.sessionId || "nosession").replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 80);
23
25
  const dir = join(homedir(), ".agent-bus");
24
26
  const stamp = join(dir, `usage-claude-${sid}.stamp`);
@@ -27,17 +29,19 @@ async function main() {
27
29
 
28
30
  const payload = {
29
31
  configDir: process.env.CLAUDE_CONFIG_DIR || null,
30
- fiveHour: rl.five_hour ?? rl.fiveHour ?? null,
31
- sevenDay: rl.seven_day ?? rl.sevenDay ?? null,
32
+ fiveHour: rl?.five_hour ?? rl?.fiveHour ?? null,
33
+ sevenDay: rl?.seven_day ?? rl?.sevenDay ?? null,
32
34
  // The fable/model-scoped window normally arrives via the OAuth poller, but accept the
33
35
  // statusline-shaped variants too — schema drift degrades instead of going dark.
34
- fable: rl.fable_weekly ?? rl.fable ?? null,
36
+ fable: rl?.fable_weekly ?? rl?.fable ?? null,
35
37
  };
36
- if (!payload.fiveHour && !payload.sevenDay && !payload.fable) return;
38
+ const hasWindows = !!(payload.fiveHour || payload.sevenDay || payload.fable);
39
+ if (!hasWindows && !promptCache) return;
40
+ if (promptCache) payload.promptCache = promptCache;
37
41
  // PRIMARY: the local live cache — lib/balances.mjs merges it and skips the OAuth poll while
38
42
  // it is fresh (Orca's isLiveClaudeUsageFresh, docs/RESEARCH-orca-usage.md §2). This is the
39
43
  // path the app's footer actually reads (it shells the local CLI, not the hub).
40
- try { writeFileSync(join(dir, "usage-claude-live.json"), JSON.stringify({ ts: Date.now(), ...payload })); } catch {}
44
+ if (hasWindows) try { writeFileSync(join(dir, "usage-claude-live.json"), JSON.stringify({ ts: Date.now(), ...payload })); } catch {}
41
45
  // SECONDARY, best-effort: the hub copy, for dashboard surfaces that read hub state.
42
46
  const { signedPost } = await import("./lib/api.mjs");
43
47
  const { hostId } = await import("../lib/project.mjs");
@@ -5,6 +5,11 @@
5
5
  // keyed by agent_id if none exists). Registering both never double-posts. Fail-silent throughout.
6
6
  import { resolveProject, hostId } from "../lib/project.mjs";
7
7
  import { signedPost } from "./lib/api.mjs";
8
+ import { failOpenAfter } from "./lib/fail-open.mjs";
9
+
10
+ // PreToolUse (Task|Agent) gates the dispatch: CC 2.1.299 refuses it if this hook cannot answer in time.
11
+ failOpenAfter(Number(process.env.RELAY_SUBAGENT_WATCHDOG_MS || 5000));
12
+ const HUB_TIMEOUT_MS = 3000;
8
13
 
9
14
  function readStdin() {
10
15
  return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
@@ -29,7 +34,7 @@ try {
29
34
  project, enrich: true, agentType, agentId, parent,
30
35
  by: `${hostId()}:${project}`,
31
36
  source: "cc-subagent", costKind: "subagent-notional", phase: "sub-agents",
32
- });
37
+ }, { timeoutMs: HUB_TIMEOUT_MS });
33
38
  }
34
39
  } else {
35
40
  // CREATE path (PreToolUse Task|Agent) — dispatch time, has the prompt → good title.
@@ -46,7 +51,7 @@ try {
46
51
  parent: String(input.session_id || "").slice(0, 120),
47
52
  assignee: `${agentType}:${project}`, by: `${hostId()}:${project}`,
48
53
  source: "cc-subagent", costKind: "subagent-notional", phase: "sub-agents",
49
- });
54
+ }, { timeoutMs: HUB_TIMEOUT_MS });
50
55
  }
51
56
  } catch (e) {
52
57
  process.stderr.write(`[trantor] subagent-start error: ${e?.message || e}\n`);
@@ -414,7 +414,15 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
414
414
  ? { name, usedPct: Math.round(Number(w.used_percentage ?? w.utilization)), resetsAt: w.resets_at ?? null } : null;
415
415
  const wins = [["fiveHour", "5h"], ["sevenDay", "7d"], ["fable", "Fable"]]
416
416
  .map(([k, n]) => win(b[k], n)).filter(Boolean);
417
- if (!wins.length) return json(res, 400, { error: "no usable windows" });
417
+ // The statusline's prompt_cache stats ride along (or alone) and land on the same Claude entry.
418
+ const pcIn = b.promptCache;
419
+ const pcNum = (v) => (Number.isFinite(v) ? v : null);
420
+ const pc = (pcIn && pcIn instanceof Object && !Array.isArray(pcIn)) ? {
421
+ warm: pcIn.warm === true, hitRatio: pcNum(pcIn.hitRatio), requests: pcNum(pcIn.requests), misses: pcNum(pcIn.misses),
422
+ ttl: pcIn.ttl === "1h" || pcIn.ttl === "5m" ? pcIn.ttl : null, recacheTokensIfCold: pcNum(pcIn.recacheTokensIfCold),
423
+ lastMissCause: pcIn.lastMissCause ? String(pcIn.lastMissCause).slice(0, 40) : null,
424
+ } : null;
425
+ if (!wins.length && !pc) return json(res, 400, { error: "no usable windows" });
418
426
  const patchSnaps = [(state.balances ||= { ts: 0, by: "", entries: [] })];
419
427
  const machine = String(b.machine || "").trim().slice(0, 60);
420
428
  if (machine) patchSnaps.push(((state.balancesByMachine ||= {})[machine] ||= { ts: now(), by: "", entries: [], profile: {} }));
@@ -425,12 +433,14 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
425
433
  };
426
434
  const e = claudeEntry(patchSnaps[patchSnaps.length - 1]); // dedupe against the most specific copy
427
435
  // Same-value posts inside 30s are dropped (the statusline ticks ~3x/sec while streaming).
428
- const sig = JSON.stringify(wins);
429
- if (e._liveSig === sig && now() - (e.liveTs || 0) < 30_000) return json(res, 200, { ok: true, deduped: true });
436
+ const sig = JSON.stringify([wins, pc]);
437
+ if (wins.length && e._liveSig === sig && now() - (e.liveTs || 0) < 30_000) return json(res, 200, { ok: true, deduped: true });
430
438
  for (const snap of patchSnaps) {
431
439
  const t = claudeEntry(snap);
432
440
  for (const w of wins) { const cur = (t.windows ||= []).find(x => x.name === w.name); if (cur) Object.assign(cur, w); else t.windows.push({ ...w }); }
433
- t.ok = true; t.liveTs = now(); t._liveSig = sig; t.liveSource = "statusline";
441
+ // Only live windows stamp liveTs: a cache-only post must not make the OAuth poller skip Claude.
442
+ if (wins.length) { t.ok = true; t.liveTs = now(); t._liveSig = sig; t.liveSource = "statusline"; }
443
+ if (pc) t.promptCache = { ...pc, ts: now() };
434
444
  }
435
445
  markDirty();
436
446
  return json(res, 200, { ok: true, windows: wins.length });
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "trantor-band",
3
+ "version": "0.1.0",
4
+ "description": "Pilot: a line above the Claude Code prompt with live context fill and the project's handoff state, read from Trantor's files. Off unless TRANTOR_MOD=1."
5
+ }
@@ -0,0 +1,72 @@
1
+ // Pure helpers for the trantor-band pilot mod (#12091). No I/O and no Node imports on purpose:
2
+ // the mod runs in Claude Code's sandboxed module environment, and the tests import this file directly.
3
+
4
+ // The session id comes from Claude Code, but it names a file, so keep it to a safe alphabet.
5
+ export function safeId(id) {
6
+ return String(id ?? "").replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120);
7
+ }
8
+
9
+ // Context fill as a fraction, from $.session.usage().context. Null until the first API response.
10
+ export function contextFraction(context) {
11
+ const tokens = Number(context?.tokens);
12
+ const window = Number(context?.window);
13
+ if (context?.tokens == null || !Number.isFinite(tokens) || !Number.isFinite(window) || window <= 0 || tokens < 0) return null;
14
+ return Math.min(1, tokens / window);
15
+ }
16
+
17
+ export function contextPercent(context) {
18
+ const frac = contextFraction(context);
19
+ return frac === null ? null : Math.round(frac * 100);
20
+ }
21
+
22
+ // The file the pilot leaves for Trantor's own code to read later.
23
+ export function contextRecord(context, ts) {
24
+ const frac = contextFraction(context);
25
+ if (frac === null) return null;
26
+ return { ts, tokens: Number(context.tokens), window: Number(context.window), frac: Math.round(frac * 10000) / 10000 };
27
+ }
28
+
29
+ // The project id a checkout records in .trantor/project.json, mirroring lib/project.mjs readProjectId.
30
+ export function projectFromMarker(text) {
31
+ try {
32
+ const id = JSON.parse(text)?.id;
33
+ return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(String(id ?? "")) && !String(id).includes("..") ? String(id) : "";
34
+ } catch { return ""; }
35
+ }
36
+
37
+ export function basename(path) {
38
+ return String(path ?? "").replace(/\/+$/, "").split("/").pop() || "";
39
+ }
40
+
41
+ // The newest <project>-<unix seconds>.json among a directory listing, or "" (the name handoff.mjs writes).
42
+ export function newestHandoffName(names, project) {
43
+ if (!project) return "";
44
+ const prefix = `${project}-`;
45
+ let best = "";
46
+ let bestTs = -1;
47
+ for (const name of names || []) {
48
+ if (!name.startsWith(prefix)) continue;
49
+ const rest = name.slice(prefix.length);
50
+ if (!/^\d+\.json$/.test(rest)) continue;
51
+ const ts = Number(rest.slice(0, -5));
52
+ if (ts > bestTs) { bestTs = ts; best = name; }
53
+ }
54
+ return best;
55
+ }
56
+
57
+ // "pending" (written, nobody has it), "claimed" (a fresh session holds an unexpired claim), or ""
58
+ // when it is consumed or unreadable. Mirrors hooks/lib/handoff-claims.mjs: consumed, claim.expiresAt.
59
+ export function handoffState(text, nowSeconds) {
60
+ let record;
61
+ try { record = JSON.parse(text); } catch { return ""; }
62
+ if (!record || record.consumed) return "";
63
+ return Number(record.claim?.expiresAt) > nowSeconds ? "claimed" : "pending";
64
+ }
65
+
66
+ // The band's one line. Parts that are unknown are left out; an empty result means draw nothing.
67
+ export function bandText({ percent, handoff }) {
68
+ const parts = [];
69
+ if (Number.isFinite(percent)) parts.push(`ctx ${percent}%`);
70
+ if (handoff) parts.push(`handoff ${handoff}`);
71
+ return parts.join(" · ");
72
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "modules": [
3
+ "./trantor-band.tsx"
4
+ ]
5
+ }
@@ -0,0 +1,83 @@
1
+ // Read-only pilot band (#12091): "ctx 34% · handoff pending" above the prompt, OFF unless TRANTOR_MOD=1.
2
+ // It never calls the hub (no auth surface) and any error draws nothing.
3
+ // The one write is ~/.agent-bus/mod-context/<session>.json.
4
+ import type { EngineInterface, Register } from 'claude-code'
5
+
6
+ import {
7
+ bandText, basename, contextPercent, contextRecord, handoffState, newestHandoffName, projectFromMarker, safeId,
8
+ } from './band-lib.mjs'
9
+
10
+ type Snapshot = { percent: number | null; handoff: string; at: number }
11
+
12
+ const REFRESH_MS = 15_000
13
+
14
+ // A reload starts these over; they are only a cache of what the files and the engine can say again.
15
+ let snap: Snapshot = { percent: null, handoff: '', at: 0 }
16
+
17
+ // AGENT_BUS_DIR mirrors hooks/lib/handoff.mjs; HOME is the fallback.
18
+ async function busDir($: EngineInterface): Promise<string> {
19
+ const override = await $.env.get('AGENT_BUS_DIR')
20
+ if (override) return override
21
+ const home = await $.env.get('HOME')
22
+ return home ? `${home}/.agent-bus` : ''
23
+ }
24
+
25
+ async function readHandoff($: EngineInterface, bus: string): Promise<string> {
26
+ const root = await $.session.root()
27
+ let project = ''
28
+ try { project = projectFromMarker(await $.fs.read(`${root}/.trantor/project.json`)) } catch {}
29
+ if (!project) project = basename(root)
30
+ const dir = `${bus}/handoffs`
31
+ const entries = await $.fs.list(dir)
32
+ const name = newestHandoffName(entries.map(entry => entry.name), project)
33
+ if (!name) return ''
34
+ return handoffState(await $.fs.read(`${dir}/${name}`), Math.floor((await $.clock.now()) / 1000))
35
+ }
36
+
37
+ // One refresh: the live context from the native API, the handoff file, and the context JSON.
38
+ async function refresh($: EngineInterface, writeContext: boolean): Promise<void> {
39
+ try {
40
+ if ((await $.env.get('TRANTOR_MOD')) !== '1') return
41
+ const bus = await busDir($)
42
+ const usage = await $.session.usage()
43
+ const now = await $.clock.now()
44
+ let handoff = ''
45
+ try { if (bus) handoff = await readHandoff($, bus) } catch {}
46
+ snap = { percent: contextPercent(usage.context), handoff, at: now }
47
+ if (writeContext && bus) {
48
+ const record = contextRecord(usage.context, now)
49
+ const id = safeId(await $.session.id())
50
+ if (record && id) await $.fs.write(`${bus}/mod-context/${id}.json`, JSON.stringify(record))
51
+ }
52
+ } catch {}
53
+ }
54
+
55
+ export const register: Register = on => {
56
+ on('session.start', async ($, e, next) => {
57
+ await refresh($, false)
58
+ return next(e)
59
+ })
60
+
61
+ on('turn.complete', async ($, e, next) => {
62
+ await refresh($, true)
63
+ try { $.ui.invalidate('ui.render') } catch {}
64
+ return next(e)
65
+ })
66
+
67
+ on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
68
+ try {
69
+ if ((await $.env.get('TRANTOR_MOD')) !== '1' || e.props.hasSurvey) return next(e)
70
+ if ((await $.clock.now()) - snap.at > REFRESH_MS) await refresh($, false)
71
+ const text = bandText(snap)
72
+ if (!text) return next(e)
73
+ const { Box, Text } = $.ui.resolve(e)
74
+ return (
75
+ <Box>
76
+ <Text dimColor>{text}</Text>
77
+ </Box>
78
+ )
79
+ } catch {
80
+ return next(e)
81
+ }
82
+ })
83
+ }
@@ -0,0 +1,70 @@
1
+ // The engine's own headless harness for mods: `claude plugin test mods/trantor-band`.
2
+ import { expect, mock, test } from 'claude-code/testing'
3
+
4
+ // The stand-ins below only need the fields the mod reads; the full engine types are not the point.
5
+ const PROPS: any = {
6
+ hasSurvey: false,
7
+ isWorking: false,
8
+ maxRows: 10,
9
+ bodyColumns: 80,
10
+ scroll: { offset: 0, bodyRows: 10 },
11
+ view: {},
12
+ }
13
+
14
+ const USAGE: any = {
15
+ startedAt: 0,
16
+ context: { tokens: 34_000, window: 100_000, percent: 34 },
17
+ rateLimits: [],
18
+ }
19
+
20
+ const FILES: any = [
21
+ { name: 'demo-100.json', kind: 'file', size: 1, mtimeMs: 0, isLink: false },
22
+ { name: 'demo-200.json', kind: 'file', size: 1, mtimeMs: 0, isLink: false },
23
+ ]
24
+
25
+ // What the engine would draw there when the mod passes: a plain Text.
26
+ const engineBand = ($: any, e: any): any => {
27
+ const { Text } = $.ui.resolve(e)
28
+ return h(Text, null, 'engine band')
29
+ }
30
+
31
+ const mountBand = ($: any): Promise<any> =>
32
+ $.ui.mount({ plugin: 'trantor', surface: 'terminal', component: 'AbovePrompt', props: PROPS })
33
+
34
+ test('the band passes through to the engine band while TRANTOR_MOD is unset', async ($, on) => {
35
+ mock.env(on, {})
36
+ on('session.usage', () => ({ value: USAGE }))
37
+ on('ui.render', engineBand)
38
+ const ui = await mountBand($)
39
+ expect((await ui.find({ type: 'Text' }))?.text).toBe('engine band')
40
+ await ui.unmount()
41
+ })
42
+
43
+ test('the band shows the live context fill when TRANTOR_MOD=1', async ($, on) => {
44
+ mock.env(on, { TRANTOR_MOD: '1', HOME: '/h' })
45
+ mock.clock(on, { now: 1_000_000 })
46
+ on('session.usage', () => ({ value: USAGE }))
47
+ on('session.root', () => ({ value: '/work/demo' }))
48
+ on('fs.read', () => ({ deny: 'ENOENT' }))
49
+ on('fs.list', () => ({ deny: 'ENOENT' }))
50
+ on('ui.render', engineBand)
51
+ const ui = await mountBand($)
52
+ expect((await ui.find({ type: 'Text', text: /ctx 34%/ }))?.text).toContain('ctx 34%')
53
+ await ui.unmount()
54
+ })
55
+
56
+ test('the band names the newest unconsumed handoff of the project', async ($, on) => {
57
+ mock.env(on, { TRANTOR_MOD: '1', HOME: '/h' })
58
+ mock.clock(on, { now: 1_000_000 })
59
+ on('session.usage', () => ({ value: USAGE }))
60
+ on('session.root', () => ({ value: '/work/demo' }))
61
+ on('fs.list', () => ({ value: FILES }))
62
+ on('fs.read', (_$: any, e: any) => {
63
+ if (e.path.endsWith('project.json')) return { deny: 'ENOENT' }
64
+ return { value: e.path.endsWith('demo-200.json') ? '{"consumed":false}' : '{"consumed":true}' }
65
+ })
66
+ on('ui.render', engineBand)
67
+ const ui = await mountBand($)
68
+ expect((await ui.find({ type: 'Text', text: /handoff/ }))?.text).toContain('ctx 34% · handoff pending')
69
+ await ui.unmount()
70
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.108",
3
+ "version": "0.18.109",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -22,6 +22,7 @@
22
22
  "ui.html",
23
23
  "bin/",
24
24
  "hooks/",
25
+ "mods/",
25
26
  "lib/",
26
27
  "skills/",
27
28
  "deploy/",