trantor 0.18.108 → 0.18.109
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/crew-runner.mjs +3 -3
- 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/routes/admin.mjs +14 -4
- 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.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": {
|
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/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);
|
|
@@ -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);
|
package/hooks/precompact.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/hooks/prompt-focus.mjs
CHANGED
|
@@ -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;
|
|
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
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -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
|
package/hooks/statusline.mjs
CHANGED
|
@@ -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;
|
|
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
|
|
31
|
-
sevenDay: rl
|
|
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
|
|
36
|
+
fable: rl?.fable_weekly ?? rl?.fable ?? null,
|
|
35
37
|
};
|
|
36
|
-
|
|
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");
|
package/hooks/subagent-start.mjs
CHANGED
|
@@ -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`);
|
package/hub/routes/admin.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,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,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