sprag-cli 3.55.0 → 3.57.0

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.
Files changed (59) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +5 -1
  3. package/bin/cli.js +60 -19
  4. package/bin/postinstall.js +22 -0
  5. package/docs/CODEX.ko.md +44 -5
  6. package/docs/CODEX.md +74 -12
  7. package/package.json +2 -2
  8. package/src/agents.js +16 -2
  9. package/src/brief.js +17 -8
  10. package/src/claude-price.js +49 -0
  11. package/src/codex-brief.js +55 -2
  12. package/src/codex-cache-policy.js +26 -41
  13. package/src/codex-delegation.js +56 -8
  14. package/src/codex-doctor.js +1 -1
  15. package/src/codex-harness.js +63 -18
  16. package/src/codex-hooks.js +129 -20
  17. package/src/codex-ledger.js +150 -16
  18. package/src/codex-panel.js +51 -26
  19. package/src/codex-parser.js +41 -4
  20. package/src/codex-route-scan.js +14 -7
  21. package/src/codex-rule-health.js +41 -0
  22. package/src/commands/brief.js +14 -3
  23. package/src/commands/codex-delegate.js +17 -5
  24. package/src/commands/codex.js +20 -10
  25. package/src/commands/harness.js +6 -0
  26. package/src/commands/route-scan.js +4 -3
  27. package/src/compact-window.js +12 -4
  28. package/src/config.js +3 -3
  29. package/src/cost.js +62 -5
  30. package/src/ctx-window-cache.js +61 -0
  31. package/src/delegation-guard.js +15 -9
  32. package/src/doc2md.cjs +22 -8
  33. package/src/fig2md.cjs +7 -1
  34. package/src/formatters/statusline.js +22 -11
  35. package/src/formatters/table.js +19 -10
  36. package/src/gateway-auth.js +31 -3
  37. package/src/gateway-prices.js +39 -0
  38. package/src/harness.js +43 -12
  39. package/src/history.js +6 -2
  40. package/src/hook-manager.js +54 -32
  41. package/src/installer.js +286 -153
  42. package/src/korean-lint.cjs +40 -7
  43. package/src/litellm-budget.js +10 -1
  44. package/src/litellm-models.js +72 -10
  45. package/src/model-rules.js +90 -42
  46. package/src/money.js +9 -0
  47. package/src/month-spend.js +23 -12
  48. package/src/openai-prices.js +33 -0
  49. package/src/parser.js +232 -56
  50. package/src/postinstall.js +32 -0
  51. package/src/route-caps.js +74 -0
  52. package/src/route-inject.js +31 -7
  53. package/src/route-scan.js +93 -33
  54. package/src/savings-ledger.js +43 -11
  55. package/src/seed-rules.js +3 -4
  56. package/src/session-cache.js +35 -1
  57. package/src/state-file.js +43 -0
  58. package/src/stats.js +20 -5
  59. package/src/stdin-payload.js +18 -4
@@ -1,7 +1,8 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { spawn } from 'node:child_process';
3
- import { readFileSync, statSync } from 'node:fs';
4
- import { resolve } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { mkdirSync, openSync, readFileSync, readSync, closeSync, renameSync, statSync, writeFileSync } from 'node:fs';
5
+ import { join, resolve } from 'node:path';
5
6
  import { loadConfig, userLanguage } from './config.js';
6
7
  import { koreanStyleInjection, koreanStyleEnabled } from './korean-style.js';
7
8
  import { cohesionInjection } from './cohesion.js';
@@ -10,8 +11,11 @@ import { findProjectRoot } from './harness.js';
10
11
  import { runCodexBrief } from './codex-brief.js';
11
12
  import { codexDocumentTool, codexDocumentNote, convertCodexDocument } from './codex-doc2md.js';
12
13
  import { codexDelegationTool, codexRouteHint } from './codex-delegation.js';
14
+ import { bindCodexSubagent, closeStaleCodexRoutes, unsettledCodexRoutes } from './codex-ledger.js';
13
15
  import { seedOfferBlock } from './seed-rules.js';
14
16
  import { readCodexRouteScan, openCodexCandidates, shouldRescanCodex } from './codex-route-scan.js';
17
+ import { userDataDir } from './paths.js';
18
+ import { debug } from './debug.js';
15
19
 
16
20
  const require = createRequire(import.meta.url);
17
21
  const lint = require('./korean-lint.cjs');
@@ -21,6 +25,80 @@ function context(event, text) {
21
25
  return text ? { hookSpecificOutput: { hookEventName: event, additionalContext: text } } : null;
22
26
  }
23
27
 
28
+ /**
29
+ * Codex 0.159.2 keeps about 2,450 tokens of one hook's context and cuts the
30
+ * middle: a 4,500-token SessionStart kept its head and tail and dropped 2,042
31
+ * tokens, which was most of the Korean style guide, while the low-priority
32
+ * offers at the end survived. The budget leaves room for estimate error.
33
+ */
34
+ export const CODEX_CONTEXT_TOKEN_BUDGET = 2000;
35
+
36
+ /** Overestimates on purpose: ASCII at 3.5 chars a token, anything else at 1.3. */
37
+ export function estimateCodexTokens(text) {
38
+ let ascii = 0, other = 0;
39
+ for (const ch of String(text ?? '')) { if (ch.codePointAt(0) < 128) ascii++; else other++; }
40
+ return Math.ceil(ascii / 3.5 + other / 1.3);
41
+ }
42
+
43
+ /**
44
+ * Fit context parts into the budget whole, never cut. Parts are tried by
45
+ * priority (lower first); a part that does not fit moves to `overflowFile`
46
+ * when `spill` is set and is dropped otherwise. A pointer to the file goes
47
+ * first, since the head of a context is what Codex always keeps.
48
+ */
49
+ export function fitCodexContext(parts, { budget = CODEX_CONTEXT_TOKEN_BUDGET, overflowFile, write = writeOverflow } = {}) {
50
+ const items = parts.map((p, i) => ({ ...p, i, tokens: estimateCodexTokens(p.text) })).filter((p) => p.text);
51
+ const pointer = (file) => `[Sprag] Some Sprag instructions exceed Codex's hook context limit. Read ${JSON.stringify(file)} before starting work and follow it like the instructions below.`;
52
+ const reserve = overflowFile && items.some((p) => p.spill) ? estimateCodexTokens(pointer(overflowFile)) : 0;
53
+ let used = reserve;
54
+ const kept = new Set(), spilled = [];
55
+ for (const p of [...items].sort((a, b) => a.priority - b.priority || a.i - b.i)) {
56
+ if (used + p.tokens <= budget) { kept.add(p.i); used += p.tokens; }
57
+ else if (p.spill && overflowFile) spilled.push(p);
58
+ }
59
+ const out = items.filter((p) => kept.has(p.i)).map((p) => p.text);
60
+ if (spilled.length) {
61
+ try {
62
+ write(overflowFile, spilled.sort((a, b) => a.i - b.i).map((p) => p.text).join('\n\n') + '\n');
63
+ out.unshift(pointer(overflowFile));
64
+ } catch (e) { debug('codex:overflow', e); }
65
+ }
66
+ return out.join('\n\n');
67
+ }
68
+
69
+ function writeOverflow(file, text) {
70
+ mkdirSync(join(file, '..'), { recursive: true, mode: 0o700 });
71
+ const tmp = `${file}.${process.pid}.tmp`;
72
+ writeFileSync(tmp, text, { mode: 0o600 });
73
+ renameSync(tmp, file);
74
+ }
75
+
76
+ function overflowFileFor(root) {
77
+ const key = createHash('sha256').update(resolve(root || '.')).digest('hex').slice(0, 16);
78
+ return join(userDataDir(), 'codex-context', `${key}.md`);
79
+ }
80
+
81
+ /**
82
+ * `codex exec` runs have no one to answer a question, yet their SessionStart
83
+ * payload says `source: "startup"` like an interactive one. The rollout's
84
+ * first record says who started it (`originator: "codex_exec"`).
85
+ */
86
+ export function isCodexExecSession(transcriptPath) {
87
+ if (typeof transcriptPath !== 'string' || !transcriptPath) return false;
88
+ let fd;
89
+ try {
90
+ fd = openSync(transcriptPath, 'r');
91
+ const buffer = Buffer.alloc(64 * 1024);
92
+ const length = readSync(fd, buffer, 0, buffer.length, 0);
93
+ const first = JSON.parse(buffer.subarray(0, length).toString('utf8').split('\n')[0]);
94
+ const meta = first?.type === 'session_meta' ? first.payload : null;
95
+ return meta?.originator === 'codex_exec' || meta?.source === 'exec';
96
+ } catch (e) {
97
+ if (e?.code !== 'ENOENT') debug('codex:exec-detect', e);
98
+ return false;
99
+ } finally { if (fd !== undefined) closeSync(fd); }
100
+ }
101
+
24
102
  /**
25
103
  * Project route candidates from the last scan. Never scans inline: session
26
104
  * start must stay fast, so a stale scan is refreshed in a detached process
@@ -42,6 +120,20 @@ function defaultRescan() {
42
120
  { detached: true, stdio: 'ignore', windowsHide: true }).unref();
43
121
  }
44
122
 
123
+ /**
124
+ * Prices the delegated runs the ledger has not joined yet, in a detached
125
+ * process. The ledger was refreshed only by the route scan, which the rescan
126
+ * gate holds back for up to a day, so "Routing saved" showed a delegation that
127
+ * long after it happened. Claude's side has the same shortcut on its
128
+ * PostToolUse hook; Codex has no hook for a finished child, so the next prompt
129
+ * of the parent is the earliest point that knows one was spawned.
130
+ */
131
+ function defaultLedgerRefresh() {
132
+ if (process.env.CTS_NO_ROUTE_SCAN === '1') return;
133
+ spawn(process.execPath, [process.argv[1], 'route-scan', 'savings', '--refresh', '--quiet', '--agent', 'codex'],
134
+ { detached: true, stdio: 'ignore', windowsHide: true }).unref();
135
+ }
136
+
45
137
  export function lintCodexTool(payload, { scope = 'all' } = {}) {
46
138
  if (!payload || typeof payload !== 'object') return null;
47
139
  const input = payload.tool_input;
@@ -65,54 +157,71 @@ export function lintCodexTool(payload, { scope = 'all' } = {}) {
65
157
  if (lint.isHtmlFile(file)) text = lint.stripHtml(text);
66
158
  const findings = lint.lintKoreanText(text, { code: !lint.isProseFile(file) });
67
159
  if (findings.length) messages.push(lint.formatFindings(file, findings));
68
- } catch { /* Deleted, moved, or unreadable files need no feedback. */ }
160
+ } catch (e) { if (e?.code !== 'ENOENT') debug('codex:lint', e); }
69
161
  }
70
162
  return messages.length ? messages.join('\n\n') : null;
71
163
  }
72
164
 
73
- export async function codexHookOutput(event, payload, { cfg = loadConfig() } = {}) {
165
+ export async function codexHookOutput(event, payload, { cfg = loadConfig(), refreshLedger = defaultLedgerRefresh } = {}) {
74
166
  if (!payload || typeof payload !== 'object') return null;
75
167
  if (event === 'session-start' || event === 'subagent-start') {
76
168
  if (event === 'subagent-start' && cfg?.codex?.delegate !== true) return null;
169
+ if (event === 'subagent-start') {
170
+ try { bindCodexSubagent(payload); } catch (e) { debug('codex:bind', e); }
171
+ }
77
172
  const root = findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' });
78
173
  const parts = [];
79
174
  // Keep the preset file current with this version's wording before it is read.
80
175
  if (event === 'session-start') {
81
- try { (await import('./preset-ratchet.js')).preparePresetRatchet({ agent: 'codex' }); } catch { /* Presets are optional. */ }
176
+ try { (await import('./preset-ratchet.js')).preparePresetRatchet({ agent: 'codex' }); } catch (e) { debug('codex:preset', e); }
82
177
  }
83
178
  const { presetRatchetPath } = await import('./preset-ratchet.js');
179
+ // Lower priority survives the budget first. Rules and style move to a file
180
+ // when they do not fit; offers and notices are simply left out.
84
181
  for (const [scope, ratchet] of [['global', codexHarnessPaths(root, 'global').ratchet],
85
182
  ['preset', presetRatchetPath({ agent: 'codex' })], ['project', codexHarnessPaths(root, 'project').ratchet]]) {
86
183
  try {
87
184
  if (statSync(ratchet).size > 32 * 1024) {
88
- parts.push(`Read the ${scope} Sprag ratchet rules at ${JSON.stringify(ratchet)} before working; they exceed the automatic injection limit.`);
185
+ parts.push({ priority: 1, text: `Read the ${scope} Sprag ratchet rules at ${JSON.stringify(ratchet)} before working; they exceed the automatic injection limit.` });
89
186
  } else {
90
187
  const text = readFileSync(ratchet, 'utf8').trim();
91
188
  // An empty preset file is only its header; injecting it would spend
92
189
  // tokens every session on no rules.
93
- if (text && (scope !== 'preset' || /^- /m.test(text))) parts.push(`[Sprag Codex ratchet: ${scope}]\n${text}`);
190
+ if (text && (scope !== 'preset' || /^- /m.test(text))) parts.push({ priority: 1, spill: true, text: `[Sprag Codex ratchet: ${scope}]\n${text}` });
94
191
  }
95
- } catch { /* No rules registered in this scope. */ }
192
+ } catch (e) { if (e?.code !== 'ENOENT') debug('codex:ratchet', e); }
96
193
  }
97
- parts.push(koreanStyleInjection({ cfg }), await cohesionInjection({ cfg }));
98
- if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) parts.push(codexDocumentNote());
99
- if (event === 'session-start' && payload.source !== 'compact') {
100
- try { parts.push(seedOfferBlock({ root, agent: 'codex' })); } catch { /* Offers are optional. */ }
101
- try { parts.push(await routeNotice(root)); } catch { /* A missing scan only delays candidates. */ }
194
+ if (event === 'subagent-start') parts.push({ priority: 0, text:
195
+ '[Sprag Codex delegation]\nStay within the assigned task and any caller-provided budget. Return findings with file references and actual verification results; state unfinished work. Use only available Codex tools and configured roles. Do not infer a model tier or invent a tool-call budget.' });
196
+ parts.push({ priority: 2, spill: true, text: koreanStyleInjection({ cfg }) },
197
+ { priority: 3, spill: true, text: await cohesionInjection({ cfg }) });
198
+ if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) parts.push({ priority: 4, spill: true, text: codexDocumentNote() });
199
+ // Offers ask the user a question; a `codex exec` run has no one to answer it.
200
+ if (event === 'session-start' && payload.source !== 'compact' && !isCodexExecSession(payload.transcript_path)) {
201
+ try { parts.push({ priority: 5, text: await routeNotice(root) }); } catch (e) { debug('codex:route-notice', e); }
202
+ try { parts.push({ priority: 6, text: seedOfferBlock({ root, agent: 'codex' }) }); } catch (e) { debug('codex:seed-offer', e); }
102
203
  }
103
- if (event === 'subagent-start') parts.push(
104
- '[Sprag Codex delegation]\nStay within the assigned task and any caller-provided budget. Return findings with file references and actual verification results; state unfinished work. Use only available Codex tools and configured roles. Do not infer a model tier or invent a tool-call budget.');
105
- return context(event === 'subagent-start' ? 'SubagentStart' : 'SessionStart', parts.filter(Boolean).join('\n\n'));
204
+ return context(event === 'subagent-start' ? 'SubagentStart' : 'SessionStart',
205
+ fitCodexContext(parts, { overflowFile: overflowFileFor(root) }));
106
206
  }
107
207
  if (event === 'prompt') {
108
208
  const parts = [];
109
209
  if (cfg?.codex?.delegate === true) {
110
- try { parts.push(codexRouteHint(payload, { root: findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' }) })); }
111
- catch { /* Invalid routing state must not suppress briefing or conversion. */ }
210
+ // Before the hint for this prompt is recorded: a hint from an earlier turn
211
+ // that no child claimed is closed, and children that did run get priced.
212
+ try { closeStaleCodexRoutes(payload); } catch (e) { debug('codex:close-routes', e); }
213
+ try { if (unsettledCodexRoutes() > 0) refreshLedger(); } catch (e) { debug('codex:ledger-refresh', e); }
214
+ try {
215
+ parts.push(codexRouteHint(payload, { root: findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' }),
216
+ minContext: Number.isFinite(cfg?.codex?.delegateMinContext) ? cfg.codex.delegateMinContext : undefined }));
217
+ } catch (e) { debug('codex:route-hint', e); }
112
218
  }
113
219
  if (cfg?.codex?.brief !== false) {
114
- try { parts.push(runCodexBrief({ sessionId: payload.session_id, transcriptPath: payload.transcript_path, cwd: payload.cwd })); }
115
- catch { /* Local telemetry must not prevent document conversion. */ }
220
+ try {
221
+ parts.push(runCodexBrief({ sessionId: payload.session_id, transcriptPath: payload.transcript_path, cwd: payload.cwd,
222
+ root: findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' }) }));
223
+ }
224
+ catch (e) { debug('codex:brief', e); }
116
225
  }
117
226
  if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) {
118
227
  parts.push(doc2md.contextForPrompt(payload, { lang: userLanguage(), agent: 'codex', convert: convertCodexDocument }));
@@ -2,21 +2,30 @@ import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } fr
2
2
  import { join } from 'node:path';
3
3
  import { randomBytes, randomUUID } from 'node:crypto';
4
4
  import { userDataDir } from './paths.js';
5
+ import { roundUsd } from './savings-ledger.js';
5
6
  import { discoverCodexSessionFiles, parseCodexTurns } from './codex-parser.js';
6
- import { codexRunCost, readCodexPrices } from './codex-cache-policy.js';
7
+ import { codexRunCost, codexPriceBook, readCodexPrices } from './codex-cache-policy.js';
7
8
 
8
9
  /*
9
10
  * Codex routing savings, kept apart from Claude's delegation ledger.
10
11
  *
11
- * A child rollout only counts when Sprag itself routed it: the spawn rewrite
12
- * writes a random route id into the child's message and records the same id
13
- * here at that moment. The marker text alone proves nothing, since a model can
14
- * write it by hand; the pending record is what shows a rule existed at spawn time.
12
+ * A child rollout only counts when Sprag itself routed it: either path writes
13
+ * a random route id and records the same id here at that moment, so the
14
+ * pending record (not the marker text a model could write by hand) is what
15
+ * shows a rule existed before the child ran. Two ways an id reaches a child:
16
+ * - the PreToolUse spawn rewrite (codexDelegationTool) writes it into the
17
+ * spawned message, for Codex versions that route spawn_agent through hooks;
18
+ * - on versions that don't (0.159.2), the prompt hint (codexRouteHint) can
19
+ * only ask the model to include the line, so SubagentStart binds the id to
20
+ * whichever spawned child matches the pending record (bindCodexSubagent),
21
+ * and refreshCodexLedger falls back to that binding when no marker is found.
15
22
  */
16
23
 
17
24
  export const ROUTE_ID_RE = /<!-- sprag:codex:route id=([0-9a-f]{16}) -->/;
18
25
  const PENDING_TTL_MS = 7 * 86400000;
26
+ const BIND_TTL_MS = 30 * 60000;
19
27
  const pendingFile = (dir) => join(dir, 'codex-delegations.jsonl');
28
+ const bindsFile = (dir) => join(dir, 'codex-delegation-binds.jsonl');
20
29
  const ledgerFile = (dir) => join(dir, 'codex-delegation-ledger.json');
21
30
 
22
31
  export function newRouteId() {
@@ -31,6 +40,21 @@ export function recordCodexDelegation(entry, { dir = userDataDir() } = {}) {
31
40
  appendFileSync(pendingFile(dir), line + '\n', { mode: 0o600 });
32
41
  }
33
42
 
43
+ /**
44
+ * The pending file is append-only, so it only shrinks here: records past the
45
+ * pending TTL can never bind or join a child again. Rewritten in place through
46
+ * a rename, and only when something is actually stale.
47
+ */
48
+ export function pruneCodexDelegations({ dir = userDataDir(), now = Date.now() } = {}) {
49
+ const keep = readCodexDelegations({ dir }).filter((r) => now - r.at < PENDING_TTL_MS);
50
+ let all;
51
+ try { all = readFileSync(pendingFile(dir), 'utf8').split('\n').filter(Boolean).length; } catch { return; }
52
+ if (keep.length === all) return;
53
+ const file = pendingFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
54
+ writeFileSync(tmp, keep.map((r) => JSON.stringify(r) + '\n').join(''), { mode: 0o600 });
55
+ renameSync(tmp, file);
56
+ }
57
+
34
58
  export function readCodexDelegations({ dir = userDataDir() } = {}) {
35
59
  let text;
36
60
  try { text = readFileSync(pendingFile(dir), 'utf8'); } catch { return []; }
@@ -44,6 +68,99 @@ export function readCodexDelegations({ dir = userDataDir() } = {}) {
44
68
  return out;
45
69
  }
46
70
 
71
+ /** One append per bind, mirroring recordCodexDelegation's append-only shape. */
72
+ export function recordCodexBinding(entry, { dir = userDataDir() } = {}) {
73
+ mkdirSync(dir, { recursive: true });
74
+ appendFileSync(bindsFile(dir), JSON.stringify(entry) + '\n', { mode: 0o600 });
75
+ }
76
+
77
+ export function readCodexBindings({ dir = userDataDir() } = {}) {
78
+ let text;
79
+ try { text = readFileSync(bindsFile(dir), 'utf8'); } catch { return []; }
80
+ const out = [];
81
+ for (const line of text.split('\n')) {
82
+ try {
83
+ const b = JSON.parse(line);
84
+ if (b && /^[0-9a-f]{16}$/.test(b.id) && typeof b.childSessionId === 'string' && Number.isFinite(b.at)) out.push(b);
85
+ } catch { /* Partial trailing line. */ }
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /** Route ids a later prompt closed before any child claimed them. Kept in the binds file as `{ id, closed: true }` lines. */
91
+ function closedCodexRouteIds({ dir = userDataDir() } = {}) {
92
+ let text;
93
+ try { text = readFileSync(bindsFile(dir), 'utf8'); } catch { return new Set(); }
94
+ const out = new Set();
95
+ for (const line of text.split('\n')) {
96
+ try {
97
+ const b = JSON.parse(line);
98
+ if (b?.closed === true && /^[0-9a-f]{16}$/.test(b.id)) out.add(b.id);
99
+ } catch { /* Partial trailing line. */ }
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /**
105
+ * Close the routes this parent was hinted in an earlier turn and never spawned.
106
+ *
107
+ * A hint asks for a spawn in the turn that carries it. Left open, its pending
108
+ * record kept matching for the whole bind window, so a child the parent spawned
109
+ * for an unrelated reason twenty minutes later, on the same model, was bound to
110
+ * it and its cost was booked as a routing saving. A new prompt from the user is
111
+ * what ends the hinted turn, so that is where the record is closed. Returns how
112
+ * many were closed; writes nothing when there are none.
113
+ */
114
+ export function closeStaleCodexRoutes(payload, { dir = userDataDir(), now = Date.now() } = {}) {
115
+ if (typeof payload?.session_id !== 'string') return 0;
116
+ const sameTurn = (r) => typeof payload.turn_id === 'string' && r.turnId === payload.turn_id;
117
+ const open = readCodexDelegations({ dir }).filter((r) =>
118
+ r.via === 'prompt' && r.parentSessionId === payload.session_id && now - r.at < BIND_TTL_MS && !sameTurn(r));
119
+ if (!open.length) return 0;
120
+ const taken = new Set([...readCodexBindings({ dir }).map((b) => b.id), ...closedCodexRouteIds({ dir })]);
121
+ const stale = open.filter((r) => !taken.has(r.id));
122
+ for (const r of stale) appendFileSync(bindsFile(dir), JSON.stringify({ id: r.id, closed: true, at: now }) + '\n', { mode: 0o600 });
123
+ return stale.length;
124
+ }
125
+
126
+ /**
127
+ * Bind a just-spawned SubagentStart child to the pending route it most likely
128
+ * fulfills: same parent thread, same target model, recent enough, and not
129
+ * already claimed by another child or closed by a later prompt. When both the
130
+ * record and the payload name a turn, the turns must agree. Returns the bound
131
+ * route id, or null when nothing matches or this child is already bound.
132
+ */
133
+ export function bindCodexSubagent(payload, { dir = userDataDir(), now = Date.now() } = {}) {
134
+ if (typeof payload?.agent_id !== 'string' || typeof payload?.session_id !== 'string' || typeof payload?.model !== 'string') return null;
135
+ const binds = readCodexBindings({ dir });
136
+ if (binds.some((b) => b.childSessionId === payload.agent_id)) return null;
137
+ const boundIds = new Set([...binds.map((b) => b.id), ...closedCodexRouteIds({ dir })]);
138
+ const otherTurn = (r) => typeof payload.turn_id === 'string' && typeof r.turnId === 'string' && r.turnId !== payload.turn_id;
139
+ const pending = readCodexDelegations({ dir }).filter((r) =>
140
+ r.parentSessionId === payload.session_id && r.to === payload.model && now - r.at < BIND_TTL_MS && !boundIds.has(r.id) && !otherTurn(r));
141
+ if (!pending.length) return null;
142
+ // Oldest first: spawns are recorded in order, so the earliest unbound record belongs to the earliest child.
143
+ const chosen = pending.reduce((a, b) => (b.at < a.at ? b : a));
144
+ recordCodexBinding({ id: chosen.id, childSessionId: payload.agent_id, at: now }, { dir });
145
+ return chosen.id;
146
+ }
147
+
148
+ /**
149
+ * How many bound routes the ledger has not finished pricing. A child was
150
+ * spawned for each, so its saving exists but is not in the ledger until the
151
+ * next refresh, and the route scan that refreshes it runs at most daily when
152
+ * little new data arrives. Callers use a non-zero count to refresh now. Bound
153
+ * within the bind window only: a child still running after that settles at the
154
+ * regular scan.
155
+ */
156
+ export function unsettledCodexRoutes({ dir = userDataDir(), now = Date.now() } = {}) {
157
+ const recent = readCodexBindings({ dir }).filter((b) => now - b.at < BIND_TTL_MS);
158
+ if (!recent.length) return 0;
159
+ const pending = new Set(readCodexDelegations({ dir }).filter((r) => now - r.at < PENDING_TTL_MS).map((r) => r.id));
160
+ const events = loadCodexLedger({ dir }).events;
161
+ return recent.filter((b) => pending.has(b.id) && !(events[b.id]?.complete || events[b.id]?.aborted)).length;
162
+ }
163
+
47
164
  export function loadCodexLedger({ dir = userDataDir() } = {}) {
48
165
  try {
49
166
  const data = JSON.parse(readFileSync(ledgerFile(dir), 'utf8'));
@@ -59,38 +176,50 @@ function saveLedger(data, dir) {
59
176
  renameSync(tmp, file);
60
177
  }
61
178
 
62
- /** The route id a child rollout carries, and its own tokens, or null. */
63
- async function childRun(path, cutoffMs) {
179
+ /**
180
+ * The route id a child rollout carries, and its own tokens, or null. When no
181
+ * child turn carries the marker line (the prompt-hint path can only ask the
182
+ * model to write it, not guarantee it), `binds` — childSessionId -> id from
183
+ * SubagentStart — supplies the id instead.
184
+ */
185
+ async function childRun(path, cutoffMs, binds) {
64
186
  const s = await parseCodexTurns(path, { cutoffMs });
65
187
  if (!s.isSubagent) return null;
66
188
  let id = null;
67
189
  const tokens = { input: 0, cached: 0, cacheWrite: 0, output: 0 };
68
- let model = null, startedAt = null, endedAt = null, complete = false, usageKnown = false, mixedModels = false;
190
+ let calls = 0, toolErrors = 0;
191
+ let model = null, startedAt = null, endedAt = null, complete = false, aborted = false, usageKnown = false, mixedModels = false;
69
192
  for (const t of s.turns) {
70
193
  // Only the child's own first message may carry the id; copied history does not count.
71
194
  if (!id && t.text) { const m = ROUTE_ID_RE.exec(t.text); if (m) id = m[1]; }
72
195
  tokens.input += t.input; tokens.cached += t.cached; tokens.cacheWrite += t.cacheWrite; tokens.output += t.out;
196
+ calls += t.calls; toolErrors += t.errors;
73
197
  if (model && t.model && model !== t.model) mixedModels = true;
74
198
  model ??= t.model;
75
199
  usageKnown ||= t.usageKnown;
76
200
  if (Number.isFinite(t.startedAt)) startedAt = Math.min(startedAt ?? t.startedAt, t.startedAt);
77
- if (Number.isFinite(t.endedAt)) { endedAt = Math.max(endedAt ?? t.endedAt, t.endedAt); complete = !t.aborted; }
201
+ if (Number.isFinite(t.endedAt)) { endedAt = Math.max(endedAt ?? t.endedAt, t.endedAt); complete = !t.aborted; aborted = !!t.aborted; }
78
202
  }
203
+ if (!id && binds) id = binds.get(s.sessionId) ?? null;
79
204
  return id ? { id, sessionId: s.sessionId, parentThreadId: s.parentThreadId, provider: s.provider,
80
- model, tokens, startedAt, endedAt, complete, usageKnown: usageKnown && !mixedModels } : null;
205
+ model, tokens, calls, toolErrors, startedAt, endedAt, complete, aborted, usageKnown: usageKnown && !mixedModels } : null;
81
206
  }
82
207
 
83
208
  /**
84
209
  * Join child rollouts to pending routes and price them. Savings hold the
85
210
  * child's tokens constant and price them at the parent model, the same
86
211
  * approximation Claude's ledger states. A route to a pricier model is a loss,
87
- * shown as one rather than clamped away.
212
+ * shown as one rather than clamped away. A child that was interrupted before
213
+ * it finished replaced no parent work (the parent did the task itself), so its
214
+ * whole cost is booked as a loss.
88
215
  */
89
216
  export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date.now(), prices = readCodexPrices({ now, home, dir }) } = {}) {
217
+ try { pruneCodexDelegations({ dir, now }); } catch { /* Pruning is housekeeping; the TTL filter below still holds. */ }
90
218
  const pending = readCodexDelegations({ dir }).filter((r) => now - r.at < PENDING_TTL_MS);
91
219
  const ledger = loadCodexLedger({ dir });
92
220
  if (!pending.length) return ledger;
93
221
  const byId = new Map(pending.map((r) => [r.id, r]));
222
+ const bindMap = new Map(readCodexBindings({ dir }).filter((b) => byId.has(b.id)).map((b) => [b.childSessionId, b.id]));
94
223
  const oldest = Math.min(...pending.map((r) => r.at));
95
224
  const files = await discoverCodexSessionFiles({ home, days: Math.max(1, (now - oldest) / 86400000 + 1) });
96
225
  let changed = false;
@@ -98,7 +227,7 @@ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date
98
227
  for (const f of files.slice().reverse()) {
99
228
  if (f.mtime < oldest) continue;
100
229
  let run;
101
- try { run = await childRun(f.path, oldest - 60000); } catch { continue; }
230
+ try { run = await childRun(f.path, oldest - 60000, bindMap); } catch { continue; }
102
231
  const route = run && byId.get(run.id);
103
232
  // The id must come from a spawn this parent made, after the record was written.
104
233
  if (!route || (route.parentSessionId && run.parentThreadId && route.parentSessionId !== run.parentThreadId)) continue;
@@ -110,15 +239,20 @@ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date
110
239
  const previous = ledger.events[route.id];
111
240
  const provider = route.provider || run.provider;
112
241
  const sameRun = previous?.childSessionId === run.sessionId && previous.to === to && previous.from === route.from;
242
+ // Priced by the path the child took: OpenAI list for direct calls, the gateway's prices for LiteLLM.
243
+ const book = codexPriceBook(provider, prices, { home });
113
244
  const rates = sameRun && previous.rates ? previous.rates
114
- : prices?.provider === provider ? { from: prices.prices[route.from], to: prices.prices[to], checkedAt: prices.checkedAt } : null;
245
+ : book ? { from: book.prices[route.from], to: book.prices[to], checkedAt: book.checkedAt } : null;
246
+ const priceSource = !rates ? null : sameRun && previous.rates ? previous.priceSource ?? null : book?.source ?? null;
115
247
  const actual = rates && run.usageKnown ? codexRunCost(run.tokens, rates.to) : null;
116
248
  const counterfactual = rates && run.usageKnown ? codexRunCost(run.tokens, rates.from) : null;
117
- const usd = actual === null || counterfactual === null ? null : counterfactual - actual;
249
+ const usd = actual === null || counterfactual === null ? null : roundUsd(run.aborted ? -actual : counterfactual - actual);
118
250
  const event = { ts: run.endedAt ?? route.at, parentSessionId: route.parentSessionId || run.parentThreadId || null,
119
251
  childSessionId: run.sessionId, source: route.source, category: route.category ?? null, scope: route.scope ?? null,
120
- from: route.from, to, provider, tokens: run.tokens, usd, rates: usd === null ? null : rates,
121
- priceCheckedAt: rates?.checkedAt ?? null, complete: run.complete };
252
+ from: route.from, to, provider,
253
+ ...(route.targetRoot !== undefined ? { targetRoot: route.targetRoot } : {}),
254
+ ...(route.ruleCreatedAt !== undefined ? { ruleCreatedAt: route.ruleCreatedAt } : {}), tokens: run.tokens, calls: run.calls, toolErrors: run.toolErrors, usd, rates: usd === null ? null : rates,
255
+ priceCheckedAt: rates?.checkedAt ?? null, priceSource, complete: run.complete, ...(run.aborted ? { aborted: true } : {}) };
122
256
  // A transient price outage must not erase a completed, unchanged priced run.
123
257
  if (usd === null && sameRun && previous.complete && Number.isFinite(previous.usd) &&
124
258
  JSON.stringify(previous.tokens) === JSON.stringify(run.tokens)) continue;
@@ -1,4 +1,4 @@
1
- import { resolve, relative, isAbsolute } from 'node:path';
1
+ import { resolve, relative, isAbsolute, basename } from 'node:path';
2
2
  import { realpathSync, statSync } from 'node:fs';
3
3
  import { stat } from 'node:fs/promises';
4
4
  import { readPanelBinding } from './codex-panel-session.js';
@@ -9,7 +9,7 @@ import { loadConfig, statuslineDefaults } from './config.js';
9
9
  import { glyphsFor } from './glyphs.js';
10
10
  import { gaugeBar, formatMoney } from './formatters/statusline.js';
11
11
  import { codexCacheTimer } from './codex-cache.js';
12
- import { readCodexCachePolicy, readCodexPrices } from './codex-cache-policy.js';
12
+ import { readCodexCachePolicy, readCodexPrices, isDirectOpenAI } from './codex-cache-policy.js';
13
13
  import { codexIssues } from './codex-brief.js';
14
14
  import { labelForKey } from './window-labels.js';
15
15
  import { resolveLabelMode } from './statusline-mode.js';
@@ -17,7 +17,9 @@ import { formatResetIn } from './format-time.js';
17
17
  import { codexDocumentTotals } from './codex-doc2md-ledger.js';
18
18
  import { codexHookStatus } from './codex-installer.js';
19
19
  import { koreanStyleEnabled } from './korean-style.js';
20
- import { codexRoutingSavedTotals } from './codex-ledger.js';
20
+ import { codexRoutingSavedTotals, loadCodexLedger } from './codex-ledger.js';
21
+ import { loadCodexModelRules } from './codex-delegation.js';
22
+ import { codexRulesInReview } from './codex-rule-health.js';
21
23
  import { readCodexRouteScan, openCodexCandidates } from './codex-route-scan.js';
22
24
  import { codexBudgetProvider } from './codex-budget.js';
23
25
 
@@ -28,20 +30,22 @@ export const CODEX_SEGMENT_ORDER = [
28
30
 
29
31
  // Only cache full-session parses. A moving time cutoff would make an unchanged
30
32
  // file's cached totals stale. The panel reports session totals, not window totals.
33
+ // Matches the candidate TTL: older runs could not contribute a candidate anyway.
34
+ const RATCHET_SIBLING_MS = 30 * 60000;
35
+
31
36
  export function createPanelReader({ root = process.cwd(), home, sessionId: pinnedId, sessionFile, days = 7, readBudget = () => null,
32
37
  readCachePolicy = (session) => readCodexCachePolicy(session, { home }), hasFlag = () => false } = {}) {
33
38
  if (pinnedId && sessionFile) throw new Error('Choose --session or --session-file, not both.');
34
39
  const cache = new Map();
35
40
  const ratchetCache = new Map();
36
- const ratchetFor = (session) => {
41
+ const ratchetFor = (session, siblings = []) => {
37
42
  if (!session?.filePath) return [];
38
43
  try {
39
- const s = statSync(session.filePath);
40
- const key = `${s.mtimeMs}:${s.size}`;
44
+ const key = [session.filePath, ...siblings].map((file) => { const s = statSync(file); return `${file}:${s.mtimeMs}:${s.size}`; }).join('|');
41
45
  // Candidates expire by age, so a quiet file still needs a periodic re-read.
42
46
  const hit = ratchetCache.get(session.filePath);
43
47
  if (hit?.key === key && Date.now() - hit.at < 60000) return hit.value;
44
- const value = codexRatchetCandidates(session.filePath);
48
+ const value = codexRatchetCandidates(session.filePath, { siblings });
45
49
  ratchetCache.clear();
46
50
  ratchetCache.set(session.filePath, { key, at: Date.now(), value });
47
51
  return value;
@@ -62,26 +66,41 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
62
66
  files ??= sessionFile && !sessionId ? [] : await discoverCodexSessionFiles({ home, days: sessionId ? Infinity : days });
63
67
  const existing = new Set(files.map((f) => f.path));
64
68
  for (const path of cache.keys()) if (!existing.has(path)) cache.delete(path);
69
+ // A rollout is named after its session id. With an id to look for, the
70
+ // files carrying it are parsed first; without this the first frame of a
71
+ // pinned panel parsed every rollout Codex has ever written to find one
72
+ // session. The full search still runs when no file by that name holds the
73
+ // session (a renamed rollout).
74
+ const named = sessionId ? files.filter((f) => basename(f.path).includes(sessionId)) : [];
75
+ const passes = named.length && named.length < files.length ? [named, files] : [files];
65
76
  let selected = null;
66
77
  let matches = 0;
67
- const seen = new Set();
68
- for (const f of files.slice().reverse()) {
69
- let entry = cache.get(f.path);
70
- if (!entry || entry.mtime !== f.mtime || entry.size !== f.size) {
71
- try {
72
- entry = { ...f, session: await parseCodexSessionFile(f.path) };
73
- cache.set(f.path, entry);
74
- } catch { continue; }
78
+ // Recent rollouts of the same project, for repeats across `codex exec` runs.
79
+ let recent = [];
80
+ for (const pass of passes) {
81
+ if (selected) break;
82
+ matches = 0;
83
+ recent = [];
84
+ const seen = new Set();
85
+ for (const f of pass.slice().reverse()) {
86
+ let entry = cache.get(f.path);
87
+ if (!entry || entry.mtime !== f.mtime || entry.size !== f.size) {
88
+ try {
89
+ entry = { ...f, session: await parseCodexSessionFile(f.path) };
90
+ cache.set(f.path, entry);
91
+ } catch { continue; }
92
+ }
93
+ const s = entry.session;
94
+ if ((!sessionId || sessionFile) && s.isSubagent) continue;
95
+ if (sessionId ? s.sessionId !== sessionId : !insideProject(root, s.projectDir)) continue;
96
+ const key = s.sessionId || s.filePath;
97
+ if (seen.has(key)) continue;
98
+ seen.add(key);
99
+ matches++;
100
+ if (!sessionId && Date.now() - f.mtime < RATCHET_SIBLING_MS) recent.push(s.filePath);
101
+ // Activity timestamps survive archive moves that update filesystem mtime.
102
+ if (!selected || (s.lastActivity?.getTime() || 0) > (selected.lastActivity?.getTime() || 0)) selected = s;
75
103
  }
76
- const s = entry.session;
77
- if ((!sessionId || sessionFile) && s.isSubagent) continue;
78
- if (sessionId ? s.sessionId !== sessionId : !insideProject(root, s.projectDir)) continue;
79
- const key = s.sessionId || s.filePath;
80
- if (seen.has(key)) continue;
81
- seen.add(key);
82
- matches++;
83
- // Activity timestamps survive archive moves that update filesystem mtime.
84
- if (!selected || (s.lastActivity?.getTime() || 0) > (selected.lastActivity?.getTime() || 0)) selected = s;
85
104
  }
86
105
  const project = selected?.projectDir ? findProjectRoot(selected.projectDir, { agent: 'codex' }) : root;
87
106
  const cfg = loadConfig();
@@ -97,11 +116,16 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
97
116
  }
98
117
  let route = [];
99
118
  try { route = openCodexCandidates(readCodexRouteScan(), { root: project }); } catch { /* No scan yet. */ }
119
+ let ruleReview = [];
120
+ try {
121
+ const modelRules = loadCodexModelRules();
122
+ if (modelRules.length) ruleReview = codexRulesInReview({ root: project, rules: modelRules, events: Object.values(loadCodexLedger().events) }).map((r) => r.index);
123
+ } catch { /* Unreadable rules or ledger show no chip. */ }
100
124
  let routingSaved = null;
101
- try { routingSaved = { ...codexRoutingSavedTotals(), pricesAvailable: !!readCodexPrices({ provider: codexBudgetProvider(home ? { home } : {}), ...(home ? { home } : {}) }) }; }
125
+ try { routingSaved = { ...codexRoutingSavedTotals(), pricesAvailable: isDirectOpenAI(selected?.provider, home ? { home } : {}) || !!readCodexPrices({ provider: codexBudgetProvider(home ? { home } : {}), ...(home ? { home } : {}) }) }; }
102
126
  catch { /* Unreadable ledger reads as n/a. */ }
103
127
  return { session: selected, matches, root, sessionId, selection, bindingPending: !!sessionFile && !sessionId, days, harness, rules,
104
- ratchet: ratchetFor(selected), route, routingSaved,
128
+ ratchet: ratchetFor(selected, recent.filter((file) => file !== selected?.filePath)), route, ruleReview, routingSaved,
105
129
  labelMode: resolveLabelMode({ cfg: statuslineDefaults(), hasFlag }).mode,
106
130
  color: statuslineDefaults().color,
107
131
  timer: !hasFlag('--no-timer') && (hasFlag('--timer') || statuslineDefaults().timer),
@@ -261,6 +285,7 @@ function panelGroups(data, { now, version = '', full = false, mode = full ? 'tex
261
285
  chip(`Ratchet ${data.rules?.global ?? '?'}/${data.rules?.project ?? '?'}`),
262
286
  // A repeated failure outranks a delegation candidate, as in the Claude statusline.
263
287
  data.ratchet?.length ? chip(`ratchet? #${data.ratchet[0].id} x${data.ratchet[0].count}`, 33) : null,
288
+ data.ruleReview?.length ? chip(`rule-health #${data.ruleReview[0]}${data.ruleReview.length > 1 ? ` +${data.ruleReview.length - 1}` : ''}`, 33) : null,
264
289
  data.route?.length ? chip(`route? R${data.route[0].id} ${terminalText(data.route[0].category)}${data.route.length > 1 ? ` +${data.route.length - 1}` : ''}`, 33) : null,
265
290
  ]);
266
291
  register('korean', 'preferences', [