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