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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +8 -1
- package/bin/cli.mjs +2 -0
- package/bin/crew-runner.mjs +3 -3
- package/bin/pair.mjs +85 -0
- package/bin/wake-nudge.mjs +75 -23
- package/hooks/file-claim.mjs +4 -0
- package/hooks/file-hold.mjs +8 -2
- package/hooks/hooks.json +24 -6
- package/hooks/lib/await-operator.mjs +30 -19
- package/hooks/lib/cc-signals.mjs +42 -0
- package/hooks/lib/fail-open.mjs +22 -0
- package/hooks/lib/handoff.mjs +34 -15
- package/hooks/model-switch.mjs +36 -0
- package/hooks/precompact.mjs +7 -1
- package/hooks/prompt-focus.mjs +9 -1
- package/hooks/sessionstart.mjs +9 -0
- package/hooks/statusline.mjs +10 -6
- package/hooks/subagent-start.mjs +7 -2
- package/hub/auth.mjs +7 -4
- package/hub/events.mjs +19 -32
- package/hub/notifier.mjs +171 -0
- package/hub/routes/admin.mjs +15 -5
- package/hub/routes/cards.mjs +3 -2
- package/hub/routes/devices.mjs +55 -0
- package/hub/routes/messages.mjs +8 -2
- package/hub/store.mjs +2 -1
- package/hub.mjs +5 -2
- package/lib/store-contract.mjs +13 -0
- package/lib/store-pg.mjs +37 -2
- package/mods/trantor-band/.claude-plugin/plugin.json +5 -0
- package/mods/trantor-band/hooks/band-lib.mjs +72 -0
- package/mods/trantor-band/hooks/hooks.json +5 -0
- package/mods/trantor-band/hooks/trantor-band.tsx +83 -0
- package/mods/trantor-band/trantor-band.test.ts +70 -0
- package/package.json +2 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
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]
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -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
|
+
}
|
package/bin/wake-nudge.mjs
CHANGED
|
@@ -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
|
-
|
|
64
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
105
|
+
} catch { return skip("claude process not found via herdr"); }
|
|
83
106
|
}
|
|
84
|
-
if (matches.length !== 1) return
|
|
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
|
-
|
|
87
|
-
|
|
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
|
|
91
|
-
try {
|
|
92
|
-
if (token)
|
|
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
|
-
|
|
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
|
|
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 => {
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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.
|
package/hooks/file-claim.mjs
CHANGED
|
@@ -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() {
|
package/hooks/file-hold.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
tail
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
+
}
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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);
|