trantor 0.18.15 → 0.18.17
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/adopt.mjs +5 -9
- package/bin/autonomy.mjs +1 -1
- package/bin/cli.mjs +1 -0
- package/bin/drill-surface.mjs +306 -0
- package/hooks/heartbeat.mjs +9 -0
- package/hooks/lib/handoff.mjs +53 -10
- package/hooks/prompt-focus.mjs +30 -6
- package/hooks/sessionstart.mjs +15 -2
- package/hooks/stop-inbox.mjs +20 -3
- package/lib/autonomy.mjs +8 -0
- package/lib/balances.mjs +51 -9
- package/lib/project.mjs +13 -2
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.17",
|
|
4
4
|
"description": "Trantor \u2014 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/adopt.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { readdirSync, statSync, existsSync, readFileSync, writeFileSync, mkdirSy
|
|
|
15
15
|
import { execFileSync } from "node:child_process";
|
|
16
16
|
import { join, dirname } from "node:path";
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
|
-
import { resolveProject } from "../lib/project.mjs";
|
|
18
|
+
import { resolveProject, writeOrchSession } from "../lib/project.mjs";
|
|
19
19
|
|
|
20
20
|
const D = "\x1b[2m", B = "\x1b[1m", Y = "\x1b[33m", G = "\x1b[32m", R = "\x1b[0m";
|
|
21
21
|
const args = process.argv.slice(2);
|
|
@@ -74,14 +74,10 @@ if (candidates.length > 1) {
|
|
|
74
74
|
console.log(`${D}the newest is assumed to be yours; pick another with --session <id>${R}`);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
// Record it where `trantor open` looks.
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
mkdirSync(dirname(store), { recursive: true });
|
|
82
|
-
const rows = existsSync(store) ? readFileSync(store, "utf8").split("\n").filter(Boolean) : [];
|
|
83
|
-
const kept = rows.filter(r => r.split("\t")[0] !== project);
|
|
84
|
-
writeFileSync(store, [...kept, `${project}\t${chosen}`].join("\n") + "\n");
|
|
77
|
+
// Record it where `trantor open` looks. One choke point for the map (writeOrchSession) so every
|
|
78
|
+
// rewrite is attributable in orch-sessions.log — adopt is one of the map's three writers
|
|
79
|
+
// (SYSTEM-CONTRACT §4), and until 2026-08-30 it wrote the file by hand, invisibly.
|
|
80
|
+
writeOrchSession(project, chosen, "adopt");
|
|
85
81
|
console.log(`\n${G}recorded${R} ${chosen} as ${project}'s orchestrator session`);
|
|
86
82
|
|
|
87
83
|
// Two live claudes on one transcript is the one thing that must not happen: they would interleave
|
package/bin/autonomy.mjs
CHANGED
|
@@ -18,7 +18,7 @@ function projectFlag() {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
const BOOLS = ["commit", "push", "deploy", "swapDeadSeat", "retryFailedTurn"];
|
|
21
|
-
const ENUMS = { harness: ["prompt", "bypass"] };
|
|
21
|
+
const ENUMS = { harness: ["prompt", "bypass"], baton: ["ask", "auto"] };
|
|
22
22
|
|
|
23
23
|
if (cmd === "get") {
|
|
24
24
|
// Machine-readable, one value, no decoration — crew.sh reads this.
|
package/bin/cli.mjs
CHANGED
|
@@ -74,6 +74,7 @@ switch (cmd) {
|
|
|
74
74
|
case "handoff": run("bin/baton.mjs"); break;
|
|
75
75
|
case "adopt": run("bin/adopt.mjs"); break;
|
|
76
76
|
case "takeover": run("bin/takeover.mjs"); break;
|
|
77
|
+
case "drill": run("bin/drill-surface.mjs"); break;
|
|
77
78
|
case "summarize": run("bin/summarize.mjs"); break;
|
|
78
79
|
case "policy": run("bin/policy.mjs"); break;
|
|
79
80
|
case "proposals": case "proposal": run("bin/proposals.mjs"); break;
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `trantor drill` — the §7 end-to-end SEAM drill (SYSTEM-CONTRACT.md; Phase 5 of the reassembly).
|
|
3
|
+
//
|
|
4
|
+
// Unit suites passing while the seams fail is this project's most-repeated lesson. This drill
|
|
5
|
+
// runs the REAL components against each other on a throwaway project: a real herdr workspace,
|
|
6
|
+
// a real Claude session, the real socket transport the app uses, the real hooks, the real
|
|
7
|
+
// handoff machine — and asserts on evidence (transcript rows, herdr state, ledger files),
|
|
8
|
+
// never on exit codes alone.
|
|
9
|
+
//
|
|
10
|
+
// It is the ship gate for desktop/chat/handoff/crew changes: run it before every such release;
|
|
11
|
+
// a red drill does not ship. (There is no scripted release path to wire it into — the release
|
|
12
|
+
// dance is manual — so the gate is this command plus the contract that mandates it.)
|
|
13
|
+
//
|
|
14
|
+
// Flags: --keep leave the scratch world in place for inspection (prints paths).
|
|
15
|
+
|
|
16
|
+
import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
|
|
17
|
+
import { join, basename } from "node:path";
|
|
18
|
+
import { homedir, tmpdir } from "node:os";
|
|
19
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
20
|
+
import { createConnection } from "node:net";
|
|
21
|
+
|
|
22
|
+
const KEEP = process.argv.includes("--keep");
|
|
23
|
+
const G = "\x1b[32m", Rd = "\x1b[31m", Y = "\x1b[33m", D = "\x1b[2m", R = "\x1b[0m";
|
|
24
|
+
let pass = 0, fail = 0, skip = 0;
|
|
25
|
+
const PASS = (s, ev = "") => { pass++; console.log(` ${G}PASS${R} ${s}${ev ? ` ${D}${ev}${R}` : ""}`); };
|
|
26
|
+
const FAIL = (s, ev = "") => { fail++; console.log(` ${Rd}FAIL${R} ${s}${ev ? ` ${D}${ev}${R}` : ""}`); };
|
|
27
|
+
const SKIP = (s, why) => { skip++; console.log(` ${Y}SKIP${R} ${s} ${D}${why}${R}`); };
|
|
28
|
+
const step = (n) => console.log(`\n${n}`);
|
|
29
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
30
|
+
|
|
31
|
+
function herdr(args, { json = true } = {}) {
|
|
32
|
+
const out = execFileSync("herdr", args, { encoding: "utf8", timeout: 90_000 });
|
|
33
|
+
return json ? JSON.parse(out) : out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One request over herdr's socket — byte-identical to the app's transport (herdr.rs). */
|
|
37
|
+
function socketRequest(req, timeoutMs = 90_000) {
|
|
38
|
+
const sockPath = join(homedir(), ".config", "herdr", "herdr.sock");
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const s = createConnection(sockPath);
|
|
41
|
+
const t = setTimeout(() => { s.destroy(); reject(new Error("socket timeout")); }, timeoutMs);
|
|
42
|
+
let buf = "";
|
|
43
|
+
s.on("connect", () => s.write(JSON.stringify(req) + "\n"));
|
|
44
|
+
s.on("data", (d) => {
|
|
45
|
+
buf += d.toString("utf8");
|
|
46
|
+
const nl = buf.indexOf("\n");
|
|
47
|
+
if (nl >= 0) { clearTimeout(t); s.destroy(); resolve(buf.slice(0, nl)); }
|
|
48
|
+
});
|
|
49
|
+
s.on("error", (e) => { clearTimeout(t); reject(e); });
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function waitFor(desc, fn, { timeoutMs = 60_000, everyMs = 1_000 } = {}) {
|
|
54
|
+
const end = Date.now() + timeoutMs;
|
|
55
|
+
while (Date.now() < end) {
|
|
56
|
+
const v = await fn();
|
|
57
|
+
if (v) return v;
|
|
58
|
+
await sleep(everyMs);
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function transcriptDirFor(projDir) {
|
|
64
|
+
return join(homedir(), ".claude", "projects", String(projDir).replace(/[/.]/g, "-"));
|
|
65
|
+
}
|
|
66
|
+
function newestJsonl(dir) {
|
|
67
|
+
try {
|
|
68
|
+
return readdirSync(dir).filter(f => f.endsWith(".jsonl"))
|
|
69
|
+
.map(f => ({ f: join(dir, f), m: statSync(join(dir, f)).mtimeMs }))
|
|
70
|
+
.sort((a, b) => b.m - a.m)[0]?.f || null;
|
|
71
|
+
} catch { return null; }
|
|
72
|
+
}
|
|
73
|
+
function userTurnsContaining(file, needle) {
|
|
74
|
+
const hits = [];
|
|
75
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
76
|
+
if (!line) continue;
|
|
77
|
+
let r; try { r = JSON.parse(line); } catch { continue; }
|
|
78
|
+
if (r?.type !== "user") continue;
|
|
79
|
+
const c = r.message?.content;
|
|
80
|
+
const t = typeof c === "string" ? c
|
|
81
|
+
: Array.isArray(c) ? c.map(b => (b && typeof b === "object" && b.type === "text") ? b.text : "").join(" ") : "";
|
|
82
|
+
if (t.includes(needle)) hits.push(t);
|
|
83
|
+
}
|
|
84
|
+
return hits;
|
|
85
|
+
}
|
|
86
|
+
function assistantSaid(file, needle) {
|
|
87
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
88
|
+
if (!line) continue;
|
|
89
|
+
let r; try { r = JSON.parse(line); } catch { continue; }
|
|
90
|
+
if (r?.type !== "assistant") continue;
|
|
91
|
+
for (const b of r.message?.content || []) {
|
|
92
|
+
if (b && b.type === "text" && String(b.text).includes(needle)) return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
/** Start a Claude agent in a pane, answering the folder-trust dialog if it blocks startup
|
|
100
|
+
* (the P0b recovery: agent_not_ready keeps the name live; one enter accepts the fresh dir). */
|
|
101
|
+
async function startClaude(name, paneId) {
|
|
102
|
+
let blocked = false;
|
|
103
|
+
try {
|
|
104
|
+
const r = herdr(["agent", "start", name, "--kind", "claude", "--pane", paneId]);
|
|
105
|
+
if (r.result?.agent?.agent_status === "idle") return "idle";
|
|
106
|
+
blocked = true;
|
|
107
|
+
} catch { blocked = true; }
|
|
108
|
+
if (blocked) {
|
|
109
|
+
await sleep(1500);
|
|
110
|
+
try { herdr(["agent", "send-keys", name, "enter"]); } catch {}
|
|
111
|
+
const settled = await waitFor("startup dialog answered", () => {
|
|
112
|
+
try {
|
|
113
|
+
const g = herdr(["agent", "get", name]);
|
|
114
|
+
const st = g.result?.agent?.agent_status;
|
|
115
|
+
return st === "idle" ? st : null;
|
|
116
|
+
} catch { return null; }
|
|
117
|
+
}, { timeoutMs: 45_000, everyMs: 2_000 });
|
|
118
|
+
return settled || "not-ready";
|
|
119
|
+
}
|
|
120
|
+
return "not-ready";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------- S0 · version skew ----------
|
|
124
|
+
step("S0 · version skew (hooks vs CLI vs app)");
|
|
125
|
+
{
|
|
126
|
+
const cli = JSON.parse(readFileSync(join(import.meta.dirname, "..", "package.json"), "utf8")).version;
|
|
127
|
+
let plugin = "?";
|
|
128
|
+
try {
|
|
129
|
+
// The plugin cache keeps one directory per version; the newest is what sessions load.
|
|
130
|
+
const cache = join(homedir(), ".claude", "plugins", "cache", "trantor", "trantor");
|
|
131
|
+
plugin = readdirSync(cache).filter(v => /^\d+\.\d+\.\d+$/.test(v))
|
|
132
|
+
.sort((a, b) => { const A = a.split(".").map(Number), B = b.split(".").map(Number); return (A[0]-B[0]) || (A[1]-B[1]) || (A[2]-B[2]); })
|
|
133
|
+
.pop() || "?";
|
|
134
|
+
} catch {}
|
|
135
|
+
let app = "?";
|
|
136
|
+
try { app = execSync('plutil -extract CFBundleShortVersionString raw "/Applications/Trantor.app/Contents/Info.plist"', { encoding: "utf8" }).trim(); } catch {}
|
|
137
|
+
console.log(` ${D}cli ${cli} · plugin ${plugin} · app ${app}${R}`);
|
|
138
|
+
if (plugin === "?") {
|
|
139
|
+
console.log(` ${Y}WARN${R} plugin hook version unreadable — cannot rule out skew`);
|
|
140
|
+
} else if (plugin !== cli) {
|
|
141
|
+
console.log(` ${Y}WARN${R} installed plugin hooks (${plugin}) differ from this tree (${cli}) — running sessions use the PLUGIN's hooks`);
|
|
142
|
+
} else {
|
|
143
|
+
PASS("no hook/CLI version skew", `${cli}`);
|
|
144
|
+
}
|
|
145
|
+
globalThis.__skew = plugin !== "?" && plugin !== cli;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------- world ----------
|
|
149
|
+
// NOT tmpdir(): macOS tmp is a /var symlink and Claude records the /private/var realpath,
|
|
150
|
+
// so the transcript-slug lookup would miss. A dot-dir under $HOME has no such alias.
|
|
151
|
+
const world = join(homedir(), `.tt-drill-${process.pid}`);
|
|
152
|
+
const proj = join(world, "drill-proj");
|
|
153
|
+
const bus = join(world, "bus");
|
|
154
|
+
mkdirSync(proj, { recursive: true });
|
|
155
|
+
mkdirSync(join(bus, "handoffs"), { recursive: true });
|
|
156
|
+
execFileSync("git", ["init", "-q"], { cwd: proj });
|
|
157
|
+
// The drill's handoff phase exercises the AUTO chain deliberately; the shipped default is ask.
|
|
158
|
+
writeFileSync(join(bus, "autonomy.json"), JSON.stringify({ version: 1, defaults: { baton: "auto" }, projects: {} }));
|
|
159
|
+
const projectName = basename(proj);
|
|
160
|
+
const tDir = transcriptDirFor(proj);
|
|
161
|
+
|
|
162
|
+
let ws = null, pane = null;
|
|
163
|
+
const cleanup = () => {
|
|
164
|
+
if (KEEP) { console.log(`\n${D}--keep: world at ${world} · workspace ${ws?.workspace_id || "?"} left open${R}`); return; }
|
|
165
|
+
try { if (pane) herdr(["agent", "prompt", pane, "/exit"], { json: true }); } catch {}
|
|
166
|
+
try { if (ws) herdr(["workspace", "close", ws.workspace_id], { json: true }); } catch {}
|
|
167
|
+
try { rmSync(world, { recursive: true, force: true }); } catch {}
|
|
168
|
+
};
|
|
169
|
+
process.on("exit", cleanup);
|
|
170
|
+
|
|
171
|
+
// ---------- S1 · cold start ----------
|
|
172
|
+
step("S1 · cold start: workspace, clean env, agent, transcript EXISTS");
|
|
173
|
+
try {
|
|
174
|
+
const created = herdr(["workspace", "create", "--cwd", proj, "--label", "tt-drill"]);
|
|
175
|
+
ws = { workspace_id: created.result.workspace.workspace_id };
|
|
176
|
+
pane = created.result.root_pane.pane_id;
|
|
177
|
+
PASS("throwaway herdr workspace", `${ws.workspace_id} pane ${pane}`);
|
|
178
|
+
// The P0b trap, prevented at the source: a pane inheriting CLAUDE_CODE_CHILD_SESSION runs
|
|
179
|
+
// Claude with transcript saving OFF — an invisible session. Every spawn path must clear it.
|
|
180
|
+
herdr(["pane", "run", pane,
|
|
181
|
+
`unset CLAUDE_CODE_CHILD_SESSION; export AGENT_BUS_DIR=${bus} RELAY_URL=http://127.0.0.1:1 ` +
|
|
182
|
+
`TRANTOR_NO_SCROOGE=1 TRANTOR_NO_HANDOFF_SPAWN=1 TRANTOR_NO_BALANCE_CHECK=1 ` +
|
|
183
|
+
`RELAY_CONTEXT_WARN_FRAC=0.000001 RELAY_STOP_TIMEOUT_MS=300 RELAY_CONTEXT_WINDOW=1000000; echo ENV-READY`], { json: false });
|
|
184
|
+
await sleep(1500);
|
|
185
|
+
const st = await startClaude("drill", pane);
|
|
186
|
+
if (st === "idle") PASS("claude starts and settles idle (trust dialog auto-answered if shown)");
|
|
187
|
+
else FAIL("claude starts and settles idle", String(st));
|
|
188
|
+
} catch (e) {
|
|
189
|
+
FAIL("S1 world setup", String(e.message || e).slice(0, 160));
|
|
190
|
+
console.log(`\n${Rd}cannot continue without S1${R}`);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------- S2 · transport ----------
|
|
195
|
+
step("S2 · transport: whole multiline message through the app's exact socket call");
|
|
196
|
+
const MARK = `drill-${Date.now() % 100000}`;
|
|
197
|
+
{
|
|
198
|
+
const text = `-leading dash line for ${MARK}.\nSecond line with /tmp/fake.png mid-sentence.\nReply with exactly ${MARK}-OK and nothing else.`;
|
|
199
|
+
const raw = await socketRequest({ id: "trantor:agent.prompt", method: "agent.prompt", params: { target: pane, text } });
|
|
200
|
+
const resp = JSON.parse(raw);
|
|
201
|
+
if (resp.result?.type === "agent_prompted") PASS("agent.prompt accepted (no keystrokes involved)");
|
|
202
|
+
else FAIL("agent.prompt accepted", raw.slice(0, 120));
|
|
203
|
+
|
|
204
|
+
const tfile = await waitFor("transcript exists", () => newestJsonl(tDir), { timeoutMs: 10_000, everyMs: 500 });
|
|
205
|
+
if (tfile) PASS("transcript EXISTS within seconds (CLAUDE_CODE_CHILD_SESSION trap absent)", basename(tfile));
|
|
206
|
+
else FAIL("transcript EXISTS within seconds — invisible-session trap?");
|
|
207
|
+
|
|
208
|
+
if (tfile) {
|
|
209
|
+
const replied = await waitFor("reply", () => assistantSaid(tfile, `${MARK}-OK`) || null, { timeoutMs: 120_000, everyMs: 2_000 });
|
|
210
|
+
const turns = userTurnsContaining(tfile, MARK);
|
|
211
|
+
if (turns.length === 1 && turns[0].includes("\n") && turns[0].startsWith("-leading"))
|
|
212
|
+
PASS("ONE user turn, newlines intact, dash-leading text unmangled");
|
|
213
|
+
else FAIL("ONE whole user turn", `turns=${turns.length}`);
|
|
214
|
+
if (replied) PASS("the reply came back");
|
|
215
|
+
else FAIL("the reply came back");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------- S3 · identity ----------
|
|
220
|
+
step("S3 · identity: the pane itself names the session (Phase 2)");
|
|
221
|
+
let predecessorSid = null;
|
|
222
|
+
{
|
|
223
|
+
const got = herdr(["agent", "get", "drill"]);
|
|
224
|
+
const as = got.result?.agent?.agent_session;
|
|
225
|
+
const tfile = newestJsonl(tDir);
|
|
226
|
+
predecessorSid = as?.kind === "id" ? as.value : null;
|
|
227
|
+
if (predecessorSid && tfile && basename(tfile) === `${predecessorSid}.jsonl`)
|
|
228
|
+
PASS("pane report matches the live transcript", predecessorSid.slice(0, 8));
|
|
229
|
+
else if (!as) FAIL("pane reports its session — is the herdr claude integration installed?");
|
|
230
|
+
else FAIL("pane report matches the live transcript", `${as?.value?.slice(0, 8)} vs ${tfile && basename(tfile).slice(0, 8)}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------- S4 · the handoff machine ----------
|
|
234
|
+
step("S4 · handoff machine: warn → arm → fire → WRITTEN → successor claims → RECAPPED");
|
|
235
|
+
{
|
|
236
|
+
// A turn that uses a tool: the heartbeat is PostToolUse, so only tool use can arm the baton.
|
|
237
|
+
// The heartbeat is PostToolUse: only a REAL tool call can arm the baton. Models sometimes
|
|
238
|
+
// answer without the tool (observed: run 2 of 3 on 2026-08-30 — 1-in-3 prompt fragility,
|
|
239
|
+
// not a seam), so ask, verify tool use in the transcript, and re-ask up to twice.
|
|
240
|
+
const findHandoff = () => {
|
|
241
|
+
try {
|
|
242
|
+
const f = readdirSync(join(bus, "handoffs")).find(x => x.startsWith(`${projectName}-`) && x.endsWith(".json"));
|
|
243
|
+
return f ? join(bus, "handoffs", f) : null;
|
|
244
|
+
} catch { return null; }
|
|
245
|
+
};
|
|
246
|
+
let handoffFile = null;
|
|
247
|
+
for (let attempt = 1; attempt <= 3 && !handoffFile; attempt++) {
|
|
248
|
+
const raw = await socketRequest({ id: "trantor:agent.prompt", method: "agent.prompt", params: {
|
|
249
|
+
target: pane, text: `You MUST call the Bash tool now and run exactly: pwd — do not answer without calling it. Then reply with just DONE-S4-${attempt}.` } });
|
|
250
|
+
if (JSON.parse(raw).result?.type !== "agent_prompted") { FAIL("S4 prompt accepted", raw.slice(0, 100)); break; }
|
|
251
|
+
handoffFile = await waitFor("handoff written", findHandoff, { timeoutMs: 120_000, everyMs: 2_000 });
|
|
252
|
+
if (!handoffFile) console.log(` ${D}attempt ${attempt}: no handoff yet — re-asking with the tool requirement${R}`);
|
|
253
|
+
}
|
|
254
|
+
if (!handoffFile) {
|
|
255
|
+
FAIL("the armed baton fired and WROTE a handoff at the turn boundary");
|
|
256
|
+
} else {
|
|
257
|
+
const rec = JSON.parse(readFileSync(handoffFile, "utf8"));
|
|
258
|
+
const states = (rec.states || []).map(s => s.state);
|
|
259
|
+
if (states[0] === "written") PASS("§5 ledger opens with WRITTEN", `${basename(handoffFile)}`);
|
|
260
|
+
else FAIL("§5 ledger opens with WRITTEN", states.join(","));
|
|
261
|
+
|
|
262
|
+
// Successor: end the predecessor, start fresh in the SAME pane — the claim is sessionstart's.
|
|
263
|
+
try { herdr(["agent", "prompt", "drill", "/exit"]); } catch {}
|
|
264
|
+
await sleep(4000);
|
|
265
|
+
const st2 = await startClaude("drill2", pane);
|
|
266
|
+
if (st2 !== "idle") FAIL("successor claude starts", String(st2));
|
|
267
|
+
const claimed = await waitFor("claimed on ledger", () => {
|
|
268
|
+
try {
|
|
269
|
+
const r = JSON.parse(readFileSync(handoffFile, "utf8"));
|
|
270
|
+
return (r.states || []).some(s => s.state === "claimed") ? r : null;
|
|
271
|
+
} catch { return null; }
|
|
272
|
+
}, { timeoutMs: 60_000, everyMs: 2_000 });
|
|
273
|
+
if (claimed) PASS("successor CLAIMED it (sessionstart, on the ledger)", `by ${claimed.states.find(s => s.state === "claimed")?.by?.slice(0, 8)}`);
|
|
274
|
+
else FAIL("successor CLAIMED it");
|
|
275
|
+
|
|
276
|
+
const stamp = () => {
|
|
277
|
+
try { return readdirSync(join(bus, "handoffs")).find(f => f.startsWith("recap-pending-")); } catch { return null; }
|
|
278
|
+
};
|
|
279
|
+
if (stamp()) PASS("recap net armed (pending stamp exists)");
|
|
280
|
+
else FAIL("recap net armed (pending stamp exists)");
|
|
281
|
+
|
|
282
|
+
const raw2 = await socketRequest({ id: "trantor:agent.prompt", method: "agent.prompt", params: {
|
|
283
|
+
target: pane, text: "Say only: ACK-S4" } });
|
|
284
|
+
if (JSON.parse(raw2).result?.type !== "agent_prompted") FAIL("successor prompt accepted", raw2.slice(0, 100));
|
|
285
|
+
const recapped = await waitFor("recapped", () => {
|
|
286
|
+
try {
|
|
287
|
+
const r = JSON.parse(readFileSync(handoffFile, "utf8"));
|
|
288
|
+
return (r.states || []).some(s => s.state === "recapped") && !stamp() ? r : null;
|
|
289
|
+
} catch { return null; }
|
|
290
|
+
}, { timeoutMs: 120_000, everyMs: 2_000 });
|
|
291
|
+
if (recapped) PASS("first Stop recorded RECAPPED and cleared the net", recapped.states.map(s => s.state).join("→"));
|
|
292
|
+
else FAIL("first Stop recorded RECAPPED and cleared the net", stamp() ? "stamp still present" : "no recapped state");
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (globalThis.__skew && fail > 0) {
|
|
297
|
+
console.log(` ${Y}NOTE${R} S4 runs the INSTALLED plugin's hooks — with the skew above, ledger/recap failures are expected until the newer CLI is published and \`claude plugin update trantor@trantor\` runs.`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---------- S5 · takeover ----------
|
|
301
|
+
step("S5 · takeover from a Terminal session");
|
|
302
|
+
SKIP("takeover chain", "needs an interactive Terminal-window session; proven live 2026-08-28 (0.18.13 drill) — automate in drill v2");
|
|
303
|
+
|
|
304
|
+
// ---------- verdict ----------
|
|
305
|
+
console.log(`\n${fail === 0 ? G + "DRILL GREEN" : Rd + "DRILL RED"}${R} — ${pass} passed, ${fail} failed, ${skip} skipped`);
|
|
306
|
+
process.exit(fail === 0 ? 0 : 1);
|
package/hooks/heartbeat.mjs
CHANGED
|
@@ -96,6 +96,15 @@ async function maybeEarlyWarn(stdinRaw, session) {
|
|
|
96
96
|
};
|
|
97
97
|
|
|
98
98
|
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
99
|
+
// The baton dial (#5509 W2, SYSTEM-CONTRACT §5): "ask" (default) means the OPERATOR fires
|
|
100
|
+
// handoffs — the app's banner asks at this same threshold, and this hook neither arms nor
|
|
101
|
+
// auto-fires. "auto" restores the arm-here → fire-at-Stop chain below. PreCompact remains
|
|
102
|
+
// the at-the-wall backstop in both modes.
|
|
103
|
+
const { resolveAutonomy } = await import("../lib/autonomy.mjs");
|
|
104
|
+
if (resolveAutonomy(resolveProject(projectDir)).baton !== "auto") {
|
|
105
|
+
process.stderr.write(`[trantor] context ${Math.round(usage.frac * 100)}% — baton dial is 'ask': the banner offers, nothing auto-fires\n`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
99
108
|
// Detect THIS session's Terminal window NOW (the hook has the controlling tty; the detached worker
|
|
100
109
|
// won't) so the baton-close can replace this exact window once the fresh session takes over.
|
|
101
110
|
const tty = controllingTty();
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -53,18 +53,45 @@ export function contextUsage(transcriptPath, conf = readConfig()) {
|
|
|
53
53
|
} catch { return null; }
|
|
54
54
|
|
|
55
55
|
const lines = buf.split("\n").filter(Boolean);
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
const rows = [];
|
|
57
|
+
let model = "";
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
let r; try { r = JSON.parse(line); } catch { continue; }
|
|
58
60
|
const u = r?.message?.usage;
|
|
59
|
-
if (r?.type
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return { tokens, window, frac: window ? tokens / window : null, model };
|
|
65
|
-
}
|
|
61
|
+
if (r?.type !== "assistant" || !u) continue;
|
|
62
|
+
const tokens = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
|
|
63
|
+
if (tokens <= 0) continue;
|
|
64
|
+
rows.push(tokens);
|
|
65
|
+
model = r.message.model || model;
|
|
66
66
|
}
|
|
67
|
-
|
|
67
|
+
const tokens = guardContextTokens(rows);
|
|
68
|
+
if (tokens == null) return null;
|
|
69
|
+
const window = resolveWindow(model, conf);
|
|
70
|
+
return { tokens, window, frac: window ? tokens / window : null, model };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The #5572 poison guard — the SAME rule, from the SAME fixture manifest
|
|
74
|
+
// (test/fixtures/context/manifest.json), as the desktop gauge's ContextGuard: the baton must
|
|
75
|
+
// read what the gauge reads, or the banner and the heartbeat disagree. Report the last row
|
|
76
|
+
// unless it falls below 40% of the session max; then report the recent maximum (a lone
|
|
77
|
+
// collapsed row is an artifact — across 1,839 usage rows in the two incident-era transcripts,
|
|
78
|
+
// zero real drops below that floor were observed) unless the last FIVE rows all sit below the
|
|
79
|
+
// floor: a sustained new level is reality, accept it.
|
|
80
|
+
export function guardContextTokens(rows) {
|
|
81
|
+
let max = 0;
|
|
82
|
+
const recent = [];
|
|
83
|
+
for (const t of rows) {
|
|
84
|
+
if (!t || t <= 0) continue;
|
|
85
|
+
recent.push(t);
|
|
86
|
+
if (recent.length > 5) recent.shift();
|
|
87
|
+
if (t > max) max = t;
|
|
88
|
+
}
|
|
89
|
+
if (!recent.length) return null;
|
|
90
|
+
const last = recent[recent.length - 1];
|
|
91
|
+
const floor = max * 0.4;
|
|
92
|
+
if (last >= floor) return last;
|
|
93
|
+
if (recent.length === 5 && recent.every(r => r < floor)) return last;
|
|
94
|
+
return Math.max(...recent);
|
|
68
95
|
}
|
|
69
96
|
|
|
70
97
|
// The transcript logs the model WITHOUT the [1m] marker, so we cannot tell a
|
|
@@ -281,6 +308,20 @@ export function verbatimRecentTail(transcript, chars = 7000) {
|
|
|
281
308
|
}
|
|
282
309
|
|
|
283
310
|
// ---- write + announce + spawn ----------------------------------------------
|
|
311
|
+
/** Append one §5 state transition to a handoff's own file — the machine's ledger rides the
|
|
312
|
+
* record it describes (SYSTEM-CONTRACT §5): every owner of a transition already holds this
|
|
313
|
+
* file, it survives both sessions it connects, and no network is involved. Best-effort. */
|
|
314
|
+
export function appendHandoffState(id, state, by = "") {
|
|
315
|
+
try {
|
|
316
|
+
const p = join(HANDOFF_DIR, `${id}.json`);
|
|
317
|
+
const rec = JSON.parse(readFileSync(p, "utf8"));
|
|
318
|
+
if (!Array.isArray(rec.states)) rec.states = [];
|
|
319
|
+
rec.states.push({ state, ts: nowSec() || Math.floor(Date.now() / 1000), by });
|
|
320
|
+
writeFileSync(p, JSON.stringify(rec, null, 2));
|
|
321
|
+
return true;
|
|
322
|
+
} catch { return false; }
|
|
323
|
+
}
|
|
324
|
+
|
|
284
325
|
export function writeHandoff({ projectDir, sessionId, transcript, trigger, summary, force = false }) {
|
|
285
326
|
const projectName = basename(projectDir);
|
|
286
327
|
// Server-side storm guard: a session running OLD hooks (before the local markHandedOff guard) re-fires
|
|
@@ -325,6 +366,8 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
|
|
|
325
366
|
// narrative + a verbatim recent-exchange block so exact in-flight state always survives
|
|
326
367
|
summary: narrative + (tail ? `\n\n---\n## Verbatim recent exchange (exact in-flight state — continue from here)\n${tail}` : ""),
|
|
327
368
|
gitStatus, subagents, verifyGates, consumed: false,
|
|
369
|
+
// The §5 machine's ledger: every transition appends here via appendHandoffState.
|
|
370
|
+
states: [{ state: "written", ts: Number(stamp) || 0, by: sessionId || "" }],
|
|
328
371
|
};
|
|
329
372
|
const file = join(HANDOFF_DIR, `${record.id}.json`);
|
|
330
373
|
writeFileSync(file, JSON.stringify(record, null, 2));
|
package/hooks/prompt-focus.mjs
CHANGED
|
@@ -29,21 +29,46 @@ function titleFrom(prompt) {
|
|
|
29
29
|
return s.slice(0, 120);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
|
|
33
|
+
// §5 recap net (SYSTEM-CONTRACT): while this session carries a claimed-but-unrecapped handoff
|
|
34
|
+
// (the recap-pending stamp sessionstart wrote), EVERY prompt before its first Stop carries the
|
|
35
|
+
// reminder — including the stale queued message that ate the 2026-08-30 takeover. The stamp is
|
|
36
|
+
// cleared (and RECAPPED recorded) by stop-inbox at the first turn boundary.
|
|
37
|
+
import { handoffDir } from "../lib/project.mjs";
|
|
38
|
+
import { existsSync as _ex, readFileSync as _rf } from "node:fs";
|
|
39
|
+
let RECAP_CTX = "";
|
|
40
|
+
function loadRecapCtx(sessionId) {
|
|
41
|
+
try {
|
|
42
|
+
if (!sessionId) return "";
|
|
43
|
+
const p = join(handoffDir(), `recap-pending-${String(sessionId).replace(/[^A-Za-z0-9_.-]/g, "_")}.json`);
|
|
44
|
+
if (!_ex(p)) return "";
|
|
45
|
+
const rec = JSON.parse(_rf(p, "utf8"));
|
|
46
|
+
return `<system-reminder>You took over via handoff ${rec.handoffId}. If you have not yet recapped it, your reply MUST begin with the ≤3-sentence recap (task, state, next step) before anything else — including before answering this message.</system-reminder>`;
|
|
47
|
+
} catch { return ""; }
|
|
48
|
+
}
|
|
49
|
+
function emitAndExit() {
|
|
50
|
+
process.stdout.write(RECAP_CTX
|
|
51
|
+
? JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: RECAP_CTX } })
|
|
52
|
+
: "{}");
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
32
56
|
try {
|
|
33
|
-
if (process.env.TRANTOR_NO_FOCUS === "1") {
|
|
57
|
+
if (process.env.TRANTOR_NO_FOCUS === "1") { emitAndExit(); } // opt-out
|
|
34
58
|
const input = JSON.parse((await readStdin()) || "{}");
|
|
59
|
+
RECAP_CTX = loadRecapCtx(String(input.session_id || ""));
|
|
35
60
|
const prompt = String(input.prompt || "");
|
|
36
61
|
const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
37
62
|
// don't card home-dir sessions (matches sessionstart's phantom-project guard)
|
|
38
|
-
if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && cwd === homedir()) {
|
|
63
|
+
if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && cwd === homedir()) { emitAndExit(); }
|
|
39
64
|
const trimmed = prompt.replace(/\s+/g, " ").trim();
|
|
40
65
|
// skip empties, tiny continuations, and pure acks — they're not a new focus
|
|
41
|
-
if (!trimmed || trimmed.length < 12 || ACK.test(trimmed)) {
|
|
66
|
+
if (!trimmed || trimmed.length < 12 || ACK.test(trimmed)) { emitAndExit(); }
|
|
42
67
|
// HARNESS-INJECTED prompts are not a human's focus. Task notifications, hook system-reminders and
|
|
43
68
|
// protocol frames arrive through the same UserPromptSubmit channel, and carding one titled a board
|
|
44
69
|
// card "<task-notification> <task-id>bavlqfmzq</task-id>…" — pure noise a human cannot read.
|
|
45
70
|
if (/^\s*[<{[]/.test(trimmed) || /<task-notification>|<system-reminder>|<teammate-message/i.test(trimmed)) {
|
|
46
|
-
|
|
71
|
+
emitAndExit();
|
|
47
72
|
}
|
|
48
73
|
const project = resolveProject(cwd);
|
|
49
74
|
const session = process.env.RELAY_SESSION
|
|
@@ -74,5 +99,4 @@ try {
|
|
|
74
99
|
} catch (e) {
|
|
75
100
|
process.stderr.write(`[trantor] prompt-focus error: ${e?.message || e}\n`);
|
|
76
101
|
}
|
|
77
|
-
|
|
78
|
-
process.exit(0);
|
|
102
|
+
emitAndExit();
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -57,7 +57,20 @@ function loadPendingHandoff(projectName, { claim = true, freshSession = null } =
|
|
|
57
57
|
transcript_path: freshSession.transcript_path || "",
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
+
// §5 CLAIMED, on the machine's ledger (SYSTEM-CONTRACT), and the recap net armed:
|
|
61
|
+
// a recap-pending stamp names this successor. Every prompt before its first Stop
|
|
62
|
+
// carries a recap reminder (prompt-focus), and the first Stop marks RECAPPED —
|
|
63
|
+
// the 2026-08-30 failure (successor answered a stale queued message, never
|
|
64
|
+
// recapped) becomes mechanically impossible instead of hopefully avoided.
|
|
65
|
+
if (!Array.isArray(rec.states)) rec.states = [];
|
|
66
|
+
rec.states.push({ state: "claimed", ts: nowSec(), by: freshSession?.session_id || "" });
|
|
60
67
|
writeFileSync(p, JSON.stringify(rec, null, 2));
|
|
68
|
+
if (freshSession?.session_id) {
|
|
69
|
+
try {
|
|
70
|
+
writeFileSync(join(dir, `recap-pending-${String(freshSession.session_id).replace(/[^A-Za-z0-9_.-]/g, "_")}.json`),
|
|
71
|
+
JSON.stringify({ handoffId: rec.id, ts: nowSec() }));
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
61
74
|
}
|
|
62
75
|
return rec;
|
|
63
76
|
}
|
|
@@ -425,8 +438,8 @@ try {
|
|
|
425
438
|
// thread, so the map `trantor open` resumes and the app's chat reads moves with it. The pane
|
|
426
439
|
// also records itself on every fresh start — that keeps the map honest even if a future
|
|
427
440
|
// claude forks the session id on resume.
|
|
428
|
-
if (claimed && orchOrigin && stdinObj.session_id) writeOrchSession(project, String(stdinObj.session_id));
|
|
429
|
-
if (isOrchPane && !isCompact && stdinObj.session_id) writeOrchSession(project, String(stdinObj.session_id));
|
|
441
|
+
if (claimed && orchOrigin && stdinObj.session_id) writeOrchSession(project, String(stdinObj.session_id), "sessionstart-claim");
|
|
442
|
+
if (isOrchPane && !isCompact && stdinObj.session_id) writeOrchSession(project, String(stdinObj.session_id), "orch-pane-start");
|
|
430
443
|
if (handoff && held) {
|
|
431
444
|
process.stderr.write(`[trantor] pending handoff ${handoff.id} HELD for the orch pane (${Math.round(ageMs / 60000)}m old)\n`);
|
|
432
445
|
const mins = Math.max(1, Math.round((holdMs - ageMs) / 60000));
|
package/hooks/stop-inbox.mjs
CHANGED
|
@@ -21,15 +21,15 @@
|
|
|
21
21
|
// * Only claim delivery once we have actually decided to surface it (peek first). Marking a message
|
|
22
22
|
// delivered and then letting the stop through would hide it from the waker too — a silent hole.
|
|
23
23
|
// * Any error, or a hub that is down -> allow the stop. Never trap a session because of us.
|
|
24
|
-
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
24
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
|
|
25
25
|
import { join, dirname } from "node:path";
|
|
26
26
|
import { spawn } from "node:child_process";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
import { homedir } from "node:os";
|
|
29
|
-
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
29
|
+
import { resolveProject, hostId, handoffDir } from "../lib/project.mjs";
|
|
30
30
|
import { signedGet } from "./lib/api.mjs"; // signed: enforce hubs 401 unsigned reads — unsigned, T2 delivery is silently dead
|
|
31
31
|
import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
|
|
32
|
-
import { readArm, clearArm, markHandedOff } from "./lib/handoff.mjs";
|
|
32
|
+
import { readArm, clearArm, markHandedOff, appendHandoffState } from "./lib/handoff.mjs";
|
|
33
33
|
|
|
34
34
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
35
35
|
|
|
@@ -147,6 +147,23 @@ async function main() {
|
|
|
147
147
|
process.stderr.write("[trantor] turn boundary reached — firing the armed baton\n");
|
|
148
148
|
}
|
|
149
149
|
} catch {}
|
|
150
|
+
// §5 RECAPPED (SYSTEM-CONTRACT): this session's FIRST turn boundary after claiming a handoff.
|
|
151
|
+
// By Stop time an assistant reply exists, and every prompt of that first turn carried the
|
|
152
|
+
// recap reminder (prompt-focus) — so the reply had the instruction in front of it. Record the
|
|
153
|
+
// transition on the handoff's own ledger and disarm the net.
|
|
154
|
+
try {
|
|
155
|
+
const sid = String(input.session_id || "");
|
|
156
|
+
if (sid) {
|
|
157
|
+
const stampPath = join(handoffDir(), `recap-pending-${sid.replace(/[^A-Za-z0-9_.-]/g, "_")}.json`);
|
|
158
|
+
if (existsSync(stampPath)) {
|
|
159
|
+
try {
|
|
160
|
+
const stamp = JSON.parse(readFileSync(stampPath, "utf8"));
|
|
161
|
+
appendHandoffState(stamp.handoffId, "recapped", sid);
|
|
162
|
+
} catch {}
|
|
163
|
+
try { unlinkSync(stampPath); } catch {}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} catch {}
|
|
150
167
|
// Mirror the other hooks: a home-directory session isn't project work and isn't on the bus.
|
|
151
168
|
if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return allow();
|
|
152
169
|
|
package/lib/autonomy.mjs
CHANGED
|
@@ -32,9 +32,16 @@ export const DEFAULTS = Object.freeze({
|
|
|
32
32
|
deploy: false,
|
|
33
33
|
swapDeadSeat: true, // replacing an exhausted seat costs nothing and loses nothing
|
|
34
34
|
retryFailedTurn: true,
|
|
35
|
+
// Who pulls the handoff trigger at the context warn line (#5509 W2, SYSTEM-CONTRACT §5).
|
|
36
|
+
// "ask" (default): the app's banner asks; the heartbeat neither arms nor auto-fires — the
|
|
37
|
+
// 2026-08-28 silent handoff is the incident this default answers. "auto": the proven
|
|
38
|
+
// arm-at-warn → fire-at-turn-boundary chain. PreCompact remains the at-the-wall backstop
|
|
39
|
+
// in BOTH modes: dying mid-compaction is strictly worse than an unasked handoff.
|
|
40
|
+
baton: "ask", // ask | auto
|
|
35
41
|
});
|
|
36
42
|
|
|
37
43
|
const HARNESS = ["prompt", "bypass"];
|
|
44
|
+
const BATON = ["ask", "auto"];
|
|
38
45
|
|
|
39
46
|
export function loadAutonomy() {
|
|
40
47
|
const p = AUTONOMY_PATH();
|
|
@@ -68,6 +75,7 @@ export function resolveAutonomy(project, cfg = loadAutonomy()) {
|
|
|
68
75
|
deploy: !!merged.deploy,
|
|
69
76
|
swapDeadSeat: merged.swapDeadSeat !== false,
|
|
70
77
|
retryFailedTurn: merged.retryFailedTurn !== false,
|
|
78
|
+
baton: BATON.includes(merged.baton) ? merged.baton : DEFAULTS.baton,
|
|
71
79
|
};
|
|
72
80
|
if (!out.commit) out.push = false;
|
|
73
81
|
if (!out.push) out.deploy = false;
|
package/lib/balances.mjs
CHANGED
|
@@ -51,7 +51,15 @@ export const ADAPTERS = [
|
|
|
51
51
|
const win = (w, name) => (w && w.utilization != null)
|
|
52
52
|
? { name, usedPct: Math.round(w.utilization), resetsAt: w.resets_at || null, locked: w.locked_reason || null }
|
|
53
53
|
: null;
|
|
54
|
-
|
|
54
|
+
// Model-scoped weekly limits ride limits[] (kind "weekly_scoped", scope.model.display_name
|
|
55
|
+
// e.g. "Fable") — Orca shows these as their own segment ("36% used Fable") and so do we.
|
|
56
|
+
// Captured live 2026-08-30: session/weekly_all in limits[] duplicate five_hour/seven_day,
|
|
57
|
+
// so only the scoped entries add information.
|
|
58
|
+
const scoped = (Array.isArray(d.limits) ? d.limits : [])
|
|
59
|
+
.filter((l) => l && l.kind === "weekly_scoped" && l.percent != null && l.scope?.model?.display_name)
|
|
60
|
+
.map((l) => ({ name: String(l.scope.model.display_name), usedPct: Math.round(l.percent),
|
|
61
|
+
resetsAt: l.resets_at || null, locked: null, scoped: true }));
|
|
62
|
+
return { windows: [win(d.five_hour, "5h"), win(d.seven_day, "7d"), ...scoped].filter(Boolean) };
|
|
55
63
|
},
|
|
56
64
|
},
|
|
57
65
|
|
|
@@ -168,17 +176,51 @@ export async function fetchBalances(env = process.env, opts = {}) {
|
|
|
168
176
|
catch (e) { return { ...base, ok: false, error: String(e?.message || e) }; }
|
|
169
177
|
});
|
|
170
178
|
const rows = (await Promise.all(jobs)).filter(Boolean);
|
|
171
|
-
// Codex
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
179
|
+
// Codex: OpenAI publishes no public balance API, but the Codex CLI's OWN token can read the
|
|
180
|
+
// ChatGPT backend's usage windows — the same source Orca's footer reads (#5570; verified live
|
|
181
|
+
// 2026-08-30: plan_type + primary/secondary_window {used_percent, reset_at}). Evidence of
|
|
182
|
+
// configuration is the login artifact (~/.codex/auth.json), not an env key. The token never
|
|
183
|
+
// leaves the process; only percentages are reported. If the endpoint is unreachable, fall
|
|
184
|
+
// back to the honest subscription row this used to be — never an error row for a seat that
|
|
185
|
+
// bills flat.
|
|
186
|
+
// Same profile gate as every adapter (latent bug from the v6 static row, surfaced 2026-08-30:
|
|
187
|
+
// `!only ||` made a codex row appear with NO profile at all — "better empty than wrong").
|
|
188
|
+
if (only && (only.has("codex") || only.has("openai"))) {
|
|
175
189
|
try {
|
|
176
|
-
const { existsSync } = await import("node:fs");
|
|
190
|
+
const { existsSync, readFileSync } = await import("node:fs");
|
|
177
191
|
const { join } = await import("node:path");
|
|
178
192
|
const { homedir } = await import("node:os");
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
193
|
+
const authPath = join(homedir(), ".codex", "auth.json");
|
|
194
|
+
if (existsSync(authPath)) {
|
|
195
|
+
const base = { provider: "codex", label: "Codex", via: "codex login", ok: true };
|
|
196
|
+
try {
|
|
197
|
+
const tok = JSON.parse(readFileSync(authPath, "utf8"))?.tokens || {};
|
|
198
|
+
if (!tok.access_token) throw new Error("no codex token");
|
|
199
|
+
const r = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
200
|
+
headers: {
|
|
201
|
+
authorization: `Bearer ${tok.access_token}`,
|
|
202
|
+
"ChatGPT-Account-Id": tok.account_id || "",
|
|
203
|
+
"User-Agent": "codex-cli", "OpenAI-Beta": "codex-1", originator: "Codex Desktop",
|
|
204
|
+
},
|
|
205
|
+
signal: AbortSignal.timeout(8000),
|
|
206
|
+
});
|
|
207
|
+
if (!r.ok) throw new Error(`wham/usage ${r.status}`);
|
|
208
|
+
const d = await r.json();
|
|
209
|
+
const win = (w, fallbackName) => {
|
|
210
|
+
if (!w || w.used_percent == null) return null;
|
|
211
|
+
const secs = Number(w.limit_window_seconds) || 0;
|
|
212
|
+
const name = secs === 604800 ? "7d" : secs > 0 && secs <= 21600 ? "5h"
|
|
213
|
+
: secs > 0 ? `${Math.round(secs / 3600)}h` : fallbackName;
|
|
214
|
+
return { name, usedPct: Math.round(w.used_percent),
|
|
215
|
+
resetsAt: w.reset_at ? Number(w.reset_at) * 1000 : null, locked: null };
|
|
216
|
+
};
|
|
217
|
+
const windows = [win(d.rate_limit?.primary_window, "5h"), win(d.rate_limit?.secondary_window, "7d")].filter(Boolean);
|
|
218
|
+
if (!windows.length) throw new Error("no usage windows in response");
|
|
219
|
+
rows.push({ ...base, kind: "windows", plan: d.plan_type ? `ChatGPT ${d.plan_type}` : "OpenAI subscription", windows });
|
|
220
|
+
} catch {
|
|
221
|
+
rows.push({ ...base, kind: "subscription",
|
|
222
|
+
plan: "OpenAI subscription", note: "usage endpoint unreachable — flat subscription" });
|
|
223
|
+
}
|
|
182
224
|
}
|
|
183
225
|
} catch { /* no fs access → no row, never an error */ }
|
|
184
226
|
}
|
package/lib/project.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// RELAY_PROJECT always wins (deliberate override / crew inheritance). The hub
|
|
7
7
|
// applies an alias map on top of this to fold any historical divergence.
|
|
8
8
|
import { execSync } from "node:child_process";
|
|
9
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
|
9
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
|
10
10
|
import { basename, join, dirname } from "node:path";
|
|
11
11
|
import { homedir, hostname } from "node:os";
|
|
12
12
|
|
|
@@ -93,14 +93,25 @@ export function orchWriterSid(projectDir, project, { withinMs = 90_000, env = pr
|
|
|
93
93
|
} catch {}
|
|
94
94
|
return "";
|
|
95
95
|
}
|
|
96
|
-
export function writeOrchSession(project, sid) {
|
|
96
|
+
export function writeOrchSession(project, sid, by = "unknown") {
|
|
97
97
|
try {
|
|
98
98
|
if (!project || !sid) return false;
|
|
99
99
|
const p = orchSessionsPath();
|
|
100
100
|
mkdirSync(dirname(p), { recursive: true });
|
|
101
101
|
const rows = existsSync(p) ? readFileSync(p, "utf8").split("\n").filter(Boolean) : [];
|
|
102
|
+
const prev = rows.find(r => r.split("\t")[0] === project)?.split("\t")[1] || "";
|
|
102
103
|
const kept = rows.filter(r => r.split("\t")[0] !== project);
|
|
103
104
|
writeFileSync(p, [...kept, `${project}\t${sid}`].join("\n") + "\n");
|
|
105
|
+
// Every rewrite leaves a row in the sibling log (SYSTEM-CONTRACT §4: the map has exactly
|
|
106
|
+
// three writers, and a rewrite must be attributable after the fact). Local and append-only
|
|
107
|
+
// on purpose: this runs inside hooks with no time for network, and the interesting rewrite
|
|
108
|
+
// — a handoff claim — additionally becomes a bus event when the Phase 4 state machine lands.
|
|
109
|
+
if (prev !== sid) {
|
|
110
|
+
try {
|
|
111
|
+
appendFileSync(join(busDir(), "orch-sessions.log"),
|
|
112
|
+
`${new Date().toISOString()}\t${project}\t${prev || "-"}\t${sid}\t${by}\n`);
|
|
113
|
+
} catch {}
|
|
114
|
+
}
|
|
104
115
|
return true;
|
|
105
116
|
} catch { return false; }
|
|
106
117
|
}
|