trantor 0.18.107 → 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.107",
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": {
@@ -39,13 +39,13 @@ import {
39
39
  } from "../lib/duty-nudges.mjs";
40
40
  import { reserveNudgeIds, releaseNudgeIds } from "../lib/nudge-ledger.mjs";
41
41
  import { dutyRecipientResolver } from "../lib/duty-recipient.mjs";
42
- import { cliEffortFlag, contextWindow } from "../lib/model-catalog.mjs";
42
+ import { cliEffortFlag, contextWindow, effortForTurn } from "../lib/model-catalog.mjs";
43
43
  import {
44
44
  BREAKER_WINDOW, STATE_ENV, TURN_RESULT_SCHEMA,
45
45
  breakerVerdict, describeTurn, hasJsonSchemaFlag, parseEnvelope, renderCardTail, runStep,
46
46
  } from "../lib/state/driver.mjs";
47
47
  import { costLine } from "../lib/state/cost.mjs";
48
- import { resolveStateFlags } from "../lib/state/flags.mjs";
48
+ import { resolveStateFlags, stateFeatureOn } from "../lib/state/flags.mjs";
49
49
 
50
50
  import { awaitSwapRelease } from "./crew/stage.mjs";
51
51
 
@@ -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.
@@ -660,7 +660,8 @@ const stateFlags = resolveStateFlags();
660
660
  for (const [name, flag] of Object.entries(stateFlags)) {
661
661
  if (flag.value && process.env[name] === undefined) process.env[name] = flag.value;
662
662
  }
663
- const STATE_FLAG_ON = process.env[STATE_ENV] === "1";
663
+ // TRANTOR_STATE=0 is the master kill switch (#12070): it beats an ASSEMBLE=1 from any layer.
664
+ const STATE_FLAG_ON = stateFeatureOn(STATE_ENV, stateFlags, ["1"]);
664
665
  const STATE_SCHEMA_FILE = join(homedir(), ".agent-bus", `state-schema-${PERSONA}-${PROJ}.json`);
665
666
  const STATE_MODE = (() => {
666
667
  if (!STATE_FLAG_ON) return false;
@@ -837,12 +838,13 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
837
838
  // #10197: the card status snapshot the turn is judged against at its end — a move to
838
839
  // testing/done under the turn is delivery even without a commit. null = the hub read failed:
839
840
  // the delivery check stands down rather than claim no-delivery on a transport error.
840
- let cardStatusBefore = null, cardLogBefore = [];
841
+ let cardStatusBefore = null, cardLogBefore = [], cardDifficulty = "";
841
842
  if (!opts.state && sessionCard > 0) {
842
843
  try {
843
844
  const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${sessionCard}`);
844
845
  cardStatusBefore = task ? String(task.status || "") : null;
845
846
  cardLogBefore = task?.log || [];
847
+ cardDifficulty = task?.difficulty || "";
846
848
  } catch { cardStatusBefore = null; }
847
849
  }
848
850
  // #9778: the docker snapshot the turn is judged against — containers the CLI spawns are recorded
@@ -857,7 +859,9 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
857
859
  // The flagged row (TDD §4.6). `{S}` exists only in `stateNext`, so the replaceAll below is a
858
860
  // no-op on every other path — which is what "flag off = byte-identical" has to mean.
859
861
  if (opts.state && cli.stateNext) cmd = cli.stateNext;
860
- const mfrag = (MODEL && cli.mflag ? `${cli.mflag}${MODEL}` : "") + EFFORT_FLAG.flag;
862
+ // Effort follows the bound card's difficulty each turn; kickoff, pulse and unreadable cards keep the launch effort.
863
+ const turnEffort = effortForTurn({ agent: DRIVER, model: MODEL, launchEffort: EFFORT, cardDifficulty });
864
+ const mfrag = (MODEL && cli.mflag ? `${cli.mflag}${MODEL}` : "") + turnEffort.flag;
861
865
  cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid).replaceAll("{DIR}", TURN_DIR)
862
866
  .replaceAll("{S}", STATE_SCHEMA_FILE);
863
867
  // PRECEDENCE: each file is PREPENDED, so the list is iterated in written order, highest priority
@@ -869,7 +873,8 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
869
873
  cmd = withEnvFiles(withSecretExports(seatSignalPrefix(TURN_DIR) + cmd, Object.keys(secrets)), envs);
870
874
  log(`turn starting (${isFirst ? "fresh session" : "resume"})${MODEL ? ` · model=${MODEL}` : ""}`);
871
875
  // #7777: the one effort line — which effort parameters were set, or that the model is uncatalogued.
872
- if (EFFORT && EFFORT_FLAG.text) log(EFFORT_FLAG.text);
876
+ if (turnEffort.changed) log(`effort: ${turnEffort.effort.difficulty} for #${sessionCard} (launch ${EFFORT?.difficulty || "none"}) — ${turnEffort.text}`);
877
+ else if (EFFORT && EFFORT_FLAG.text) log(EFFORT_FLAG.text);
873
878
  // #11559: the one compaction line — only where the block actually rides the spawn.
874
879
  if (cli.pinned && OC_COMPACTION) log(ocCompactionLogLine(OC_COMPACTION_WINDOW));
875
880
  cmuxStatus("building", "#4a90d9", "hammer", { priority: 50 }); herdrAgent("working");
package/bin/doctor.mjs CHANGED
@@ -14,6 +14,7 @@ import { sfetchJson } from "../lib/signed-fetch.mjs";
14
14
  import { scan } from "../lib/splitbrain.mjs";
15
15
  import { resolveSecrets, envFileSecrets, backendFor } from "../lib/secrets.mjs";
16
16
  import { resolveStateFlags } from "../lib/state/flags.mjs";
17
+ import { probeClaudeIntegration, HERDR_CLAUDE_INSTALL } from "../lib/herdr-integration.mjs";
17
18
 
18
19
  const H = homedir();
19
20
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -182,6 +183,17 @@ else {
182
183
  : warn("plugin not installed", "claude plugin marketplace add sashabogi/trantor && claude plugin install trantor");
183
184
  }
184
185
 
186
+ // herdr's own claude integration reports the session id at SessionStart; without it the desktop app
187
+ // cannot tell which session a pane is running. Read-only probe, and an odd answer is "unknown".
188
+ section("herdr (session identity)");
189
+ {
190
+ const h = probeClaudeIntegration();
191
+ if (h.state === "absent") note("herdr: skipped (not installed)");
192
+ else if (h.state === "installed") ok(`herdr claude integration installed (${h.detail})`);
193
+ else if (h.state === "unknown") note(`herdr claude integration: unknown (${h.detail})`);
194
+ else warn(h.state === "outdated" ? `herdr claude integration is outdated (${h.detail})` : "herdr claude integration is not installed", HERDR_CLAUDE_INSTALL);
195
+ }
196
+
185
197
  // crew CLIs: installed / wired / authenticated
186
198
  section("crew CLIs (install any subset — seats follow the work)");
187
199
  const CLIS = [
@@ -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
+ }
@@ -18,6 +18,7 @@ import { resolveProject, orchSessionsPath, hostId } from "../../lib/project.mjs"
18
18
  // attachState async on a path that is deliberately synchronous.
19
19
  import { statePath, readState } from "../../lib/state/store.mjs";
20
20
  import { stateError } from "../../lib/state/schema.mjs";
21
+ import { resolveStateFlags, stateFeatureOn } from "../../lib/state/flags.mjs";
21
22
  import { deriveState } from "../../lib/state/derive.mjs";
22
23
 
23
24
  // Writer and reader MUST resolve the same directory — see lib/project.mjs busDir(). This used to
@@ -40,24 +41,30 @@ export function relayUrl(conf = readConfig()) {
40
41
  // Read only the tail of the (potentially huge, append-only) transcript and find
41
42
  // the most recent assistant turn's usage. Current context tokens ≈ input +
42
43
  // cache_read + cache_creation (the cached prompt IS part of the window).
43
- export function contextUsage(transcriptPath, conf = readConfig()) {
44
- if (!transcriptPath || !existsSync(transcriptPath)) return null;
45
- 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;
46
52
  try {
47
- const fd = openSync(transcriptPath, "r");
48
- try {
49
- const size = fstatSync(fd).size;
50
- const tail = Math.min(size, 1_500_000); // last ~1.5MB is plenty for recent turns
51
- const b = Buffer.alloc(tail);
52
- readSync(fd, b, 0, tail, size - tail);
53
- buf = b.toString("utf8");
54
- } finally { closeSync(fd); }
55
- } catch { return null; }
56
-
57
- 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) {
58
63
  const rows = [];
59
64
  let model = "";
60
- 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;
61
68
  let r; try { r = JSON.parse(line); } catch { continue; }
62
69
  if (r?.type === "system" && r?.subtype === "compact_boundary") { rows.push("compact"); continue; }
63
70
  const u = r?.message?.usage;
@@ -67,6 +74,19 @@ export function contextUsage(transcriptPath, conf = readConfig()) {
67
74
  rows.push(tokens);
68
75
  model = r.message.model || model;
69
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;
70
90
  const tokens = guardContextTokens(rows);
71
91
  if (tokens == null) return null;
72
92
  const window = resolveWindow(model, conf);
@@ -444,9 +464,11 @@ export function capSummary(text, cap = 4096) {
444
464
  // `state` rides beside it, validated on write and NEVER capped, so capSummary cannot eat a member (#6528).
445
465
 
446
466
  /** Dark by default. The prose path is untouched either way; this flag only decides whether the
447
- * structured field is built and rendered (TDD §4.5, "Fallback"). */
448
- export function stateHandoffEnabled(env = process.env) {
449
- return ["1", "true", "on", "yes"].includes(String(env.TRANTOR_STATE_HANDOFF || "").toLowerCase());
467
+ * structured field is built and rendered (TDD §4.5, "Fallback"). The hooks never source
468
+ * ~/.agent-bus/.env, so the flag resolves like the runner's: process env first, that file second
469
+ * (#12069), and a TRANTOR_STATE=0 master switch turns it off (#12070). */
470
+ export function stateHandoffEnabled(env = process.env, file) {
471
+ return stateFeatureOn("TRANTOR_STATE_HANDOFF", resolveStateFlags(env, file));
450
472
  }
451
473
 
452
474
  /** The bus id of the seat writing this handoff — the same resolution sessionstart.mjs uses, so a
@@ -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,28 @@
1
+ // Is herdr's own claude integration installed? Session identity in the desktop app relies on it
2
+ // reporting the session at SessionStart (pane.report_agent_session), and nothing else checks it.
3
+ import { spawnSync } from "node:child_process";
4
+
5
+ export const HERDR_CLAUDE_INSTALL = "herdr integration install claude";
6
+
7
+ /** Decide from `herdr integration status` output (pure; `ran` false = herdr would not start).
8
+ * Returns { state: absent | installed | outdated | missing | unknown, detail }; an unrecognised
9
+ * shape is "unknown", never an error. */
10
+ export function claudeIntegrationState({ ran, status = 0, stdout = "" } = {}) {
11
+ if (!ran) return { state: "absent", detail: "herdr is not installed" };
12
+ if (status !== 0) return { state: "unknown", detail: `herdr integration status exited ${status}` };
13
+ const line = String(stdout).split("\n").map((l) => l.trim()).find((l) => l.startsWith("claude:"));
14
+ if (!line) return { state: "unknown", detail: "no claude line in the herdr integration status output" };
15
+ const rest = line.slice("claude:".length).trim();
16
+ if (/^not installed\b/i.test(rest)) return { state: "missing", detail: rest };
17
+ if (/^outdated\b/i.test(rest)) return { state: "outdated", detail: rest };
18
+ if (/^current\b/i.test(rest)) return { state: "installed", detail: rest };
19
+ return { state: "unknown", detail: rest };
20
+ }
21
+
22
+ /** Run the read-only probe. A missing binary, a timeout or a crash all fold into the decision. */
23
+ export function probeClaudeIntegration(run = spawnSync) {
24
+ const r = run("herdr", ["integration", "status"], { encoding: "utf8", timeout: 5000 });
25
+ if (r.error?.code === "ENOENT") return claudeIntegrationState({ ran: false });
26
+ if (r.error) return { state: "unknown", detail: String(r.error.message || r.error) };
27
+ return claudeIntegrationState({ ran: true, status: r.status ?? 1, stdout: r.stdout });
28
+ }
@@ -119,3 +119,17 @@ export function cliEffortFlag(agent, effort) {
119
119
  }
120
120
  return { flag: "", text: `effort(${difficulty}): ${model} in catalog — no per-request effort parameters (provider default)` };
121
121
  }
122
+
123
+ // effortForTurn: the effort a single turn runs at. A bound card's easy/medium/hard difficulty wins
124
+ // over the launch effort; anything else (no card, unknown value, CLI with no flag) keeps launch.
125
+ export function effortForTurn({ agent, model, launchEffort, cardDifficulty }, cat = loadCatalog()) {
126
+ const launch = { effort: launchEffort || null, ...(launchEffort ? cliEffortFlag(agent, launchEffort) : { flag: "", text: "" }) };
127
+ const d = String(cardDifficulty || "").toLowerCase();
128
+ if (!["easy", "medium", "hard"].includes(d) || d === launchEffort?.difficulty) return { ...launch, changed: false };
129
+ try {
130
+ const effort = resolveEffort(agent, model || launchEffort?.model || "", d, cat);
131
+ const cli = cliEffortFlag(agent, effort);
132
+ if (!cli.flag) return { ...launch, changed: false };
133
+ return { effort, ...cli, changed: true };
134
+ } catch { return { ...launch, changed: false }; }
135
+ }
@@ -14,9 +14,15 @@ export const STATE_ENV = "TRANTOR_STATE_ASSEMBLE";
14
14
  // resolution cannot drift apart.
15
15
  export const STATE_FLAGS = ["TRANTOR_STATE", "TRANTOR_STATE_ASSEMBLE", "TRANTOR_STATE_HANDOFF", "TRANTOR_STATE_GATE"];
16
16
 
17
+ /** The crew .env. AGENT_BUS_DIR (already how tests and the handoff dir are redirected) moves it too,
18
+ * so a run pointed at a scratch bus never reads the operator's real file. */
19
+ export function defaultFlagsFile(env = process.env) {
20
+ return join(env.AGENT_BUS_DIR || join(homedir(), ".agent-bus"), ".env");
21
+ }
22
+
17
23
  /** `{ [name]: { value, layer } }` per flag; layer is "process env", "~/.agent-bus/.env (crew)" or
18
24
  * "unset". Pure — env in, no writes — so the doctor reports the same answer the runner got. */
19
- export function resolveStateFlags(env = process.env, file = join(homedir(), ".agent-bus", ".env")) {
25
+ export function resolveStateFlags(env = process.env, file = defaultFlagsFile(env)) {
20
26
  const fromFile = parseEnvFile(file); // a missing file parses to {}
21
27
  return Object.fromEntries(STATE_FLAGS.map((name) => {
22
28
  if (env[name] !== undefined) return [name, { value: env[name], layer: "process env" }];
@@ -24,3 +30,18 @@ export function resolveStateFlags(env = process.env, file = join(homedir(), ".ag
24
30
  return [name, { value: "", layer: "unset" }];
25
31
  }));
26
32
  }
33
+
34
+ const OFF_VALUES = ["0", "false", "off", "no"];
35
+
36
+ /** True when TRANTOR_STATE resolved to an explicit off value: the master kill switch. Unset or any
37
+ * other value leaves the sub-flags in charge. */
38
+ export function stateMasterOff(resolved) {
39
+ return OFF_VALUES.includes(String(resolved?.TRANTOR_STATE?.value ?? "").trim().toLowerCase());
40
+ }
41
+
42
+ /** Whether one state sub-feature (e.g. TRANTOR_STATE_HANDOFF) is on: the master switch wins, then
43
+ * the sub-flag's own value. `on` lists the values that count as on (ASSEMBLE accepts only "1"). */
44
+ export function stateFeatureOn(name, resolved, on = ["1", "true", "on", "yes"]) {
45
+ if (stateMasterOff(resolved)) return false;
46
+ return on.includes(String(resolved?.[name]?.value ?? "").trim().toLowerCase());
47
+ }
@@ -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.107",
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/",