trantor 0.18.108 → 0.18.110

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.110",
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": {
package/README.md CHANGED
@@ -417,9 +417,16 @@ rate, not work rate.
417
417
  ```
418
418
  trantor setup | doctor | connect | profile | provider | models
419
419
  | new <name> | up <agents…> | swap <old> <new> | down | seat-why <agent> | ui | advise | hub | watch
420
- | adopt <project> | reconcile | duty | orchestrate | patrol | app | backfill | init-hooks
420
+ | adopt <project> | reconcile | duty | orchestrate | patrol | app | backfill | init-hooks | pair
421
421
  ```
422
422
 
423
+ - **`trantor pair`** pairs the iPhone and iPad companion app (`ios/`, an operator console for
424
+ fleet decisions). It mints a single-use, ten-minute invite with the `approver` role (decides
425
+ holds, proposals and gates; never deletes projects or mints invites) and prints a
426
+ `trantor://enroll` link. `--sim` opens it in the booted simulator, `--json` prints it for
427
+ scripts, and `--role owner` widens the scope. The desktop app's Settings shows the same invite
428
+ as a QR code. It signs as the operator's owner identity (`TRANTOR_IDENTITY`, default `sasha@mac`).
429
+
423
430
  - **`trantor new <name>`** — project genesis in one command: makes the dir under your dev root
424
431
  (`TRANTOR_DEV_ROOT` or `~/development`), git on main (or `--from <git-url>`, or `--adopt` an
425
432
  existing folder), installs the auto-card hook, posts the brief to the hub, and opens the first
package/bin/cli.mjs CHANGED
@@ -187,6 +187,7 @@ switch (cmd) {
187
187
  else console.error(`Invite failed: ${j.error || r.statusText}`);
188
188
  break;
189
189
  }
190
+ case "pair": run("bin/pair.mjs"); break;
190
191
  case "enroll": {
191
192
  const token = args[0];
192
193
  if (!token) { console.error("usage: trantor enroll <token>"); process.exit(1); }
@@ -256,6 +257,7 @@ switch (cmd) {
256
257
  trantor down tear the crew down (kills processes, closes windows, no dialogs)
257
258
  trantor prune drop dead crew-window tracking rows (ghost workspaces/panes) without spawning anything
258
259
  trantor ui open the live dashboard (board + flow views)
260
+ trantor pair pair a phone: mint a trantor://enroll link for the companion app — [--role owner] [--sim] [--json]
259
261
  trantor app the DESKTOP app: status | install | update — pulls the latest DMG from GitHub Releases
260
262
  trantor catchup "where are we?" — the continuous board + git, with a synthesized brief
261
263
  trantor agents what this session's sub-agents did (task · returned? · files written · survived on disk) — [<sessionId>] [--json]
@@ -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.
package/bin/pair.mjs ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ // trantor pair — mint a phone-pairing invite and print it as a trantor://enroll link.
3
+ // Same owner signing as `trantor invite`, but scoped for the companion app: every project,
4
+ // the approver role, single use, ten minutes. The phone scans the QR (desktop Settings) or
5
+ // opens the link (this command, --sim) and enrolls itself.
6
+ import { join, dirname } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { spawnSync } from "node:child_process";
9
+
10
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
11
+ const args = process.argv.slice(2);
12
+ const asJson = args.includes("--json");
13
+ const openSim = args.includes("--sim");
14
+ const roleIdx = args.indexOf("--role");
15
+ const role = roleIdx >= 0 ? (args[roleIdx + 1] || "") : "approver";
16
+ if (!role || role.startsWith("--")) {
17
+ console.error("usage: trantor pair [--role owner] [--sim] [--json]");
18
+ process.exit(1);
19
+ }
20
+
21
+ const { load, signRequest } = await import(join(ROOT, "lib/identity.mjs"));
22
+ const { resolveHubInfo } = await import(join(ROOT, "lib/project.mjs"));
23
+
24
+ const hub = resolveHubInfo().url.replace(/\/+$/, "");
25
+ // The operator's owner key, the same one the desktop app signs with (hub.rs owner_identity).
26
+ // Loaded, never minted: a fresh key is unknown to the hub and every invite would 401.
27
+ const owner = process.env.TRANTOR_IDENTITY || "sasha@mac";
28
+ const id = load(owner);
29
+ if (!id) { console.error(`error: no key for the owner identity "${owner}". Set TRANTOR_IDENTITY, or run \`trantor keys status\``); process.exit(1); }
30
+
31
+ const payload = { scopes: [{ project: "*", role }], ttlSec: 600, kind: "human" };
32
+ const body = JSON.stringify(payload);
33
+ const sig = signRequest(id, { method: "POST", path: "/invite", body });
34
+ let r;
35
+ try {
36
+ r = await fetch(`${hub}/invite`, { method: "POST", headers: { "content-type": "application/json", ...sig }, body });
37
+ } catch (e) {
38
+ console.error(`error: hub unreachable at ${hub} — ${e.message}`);
39
+ process.exit(1);
40
+ }
41
+ const j = await r.json().catch(() => ({}));
42
+
43
+ // The hub may predate the approver role (#12113): an explicit 400 names it, an older hub is
44
+ // quieter — cleanScope folds unknown roles down to read and echoes the folded scope. Either
45
+ // way a link minted here would pair the phone into near-nothing, so refuse it instead.
46
+ const echoed = Array.isArray(j.scopes) ? j.scopes[0]?.role : undefined;
47
+ const roleRefused = !r.ok
48
+ ? /role|approver/i.test(String(j.error || "")) || r.status === 400
49
+ : echoed !== role;
50
+ if (roleRefused) {
51
+ const why = r.ok
52
+ ? `this hub does not know the "${role}" role yet — it answered with "${echoed}", which would pair the phone near-read-only`
53
+ : `hub refused the invite: ${j.error || r.statusText}`;
54
+ if (asJson) console.log(JSON.stringify({ error: why, hint: "retry with --role owner" }));
55
+ else console.error(`Pairing refused: ${why}\nRetry with --role owner.`);
56
+ process.exit(1);
57
+ }
58
+ if (!r.ok) {
59
+ if (asJson) console.log(JSON.stringify({ error: j.error || r.statusText }));
60
+ else console.error(`Pairing failed: ${j.error || r.statusText}`);
61
+ process.exit(1);
62
+ }
63
+
64
+ const link = `trantor://enroll?hub=${encodeURIComponent(hub)}&token=${j.token}`;
65
+ const expiresAt = Number(j.expiresAt) || Date.now() + 600000;
66
+ const mins = Math.max(0, Math.round((expiresAt - Date.now()) / 60000));
67
+
68
+ if (asJson) {
69
+ console.log(JSON.stringify({ link, token: j.token, hub, expiresAt }));
70
+ } else {
71
+ console.log(`Pair link (single use):\n${link}\nExpires in ${mins} minute${mins === 1 ? "" : "s"}.`);
72
+ }
73
+
74
+ if (openSim) {
75
+ if (process.platform !== "darwin") {
76
+ console.error("--sim needs macOS (xcrun simctl)");
77
+ process.exit(1);
78
+ }
79
+ const sim = spawnSync("xcrun", ["simctl", "openurl", "booted", link], { stdio: "pipe" });
80
+ if (sim.status !== 0) {
81
+ console.error(`simctl could not open the link: ${String(sim.stderr).trim() || "no booted simulator?"}`);
82
+ process.exit(1);
83
+ }
84
+ if (!asJson) console.log("Opened in the booted simulator.");
85
+ }
@@ -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);