sprag-cli 3.56.0 → 3.58.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 (53) hide show
  1. package/README.ko.md +60 -40
  2. package/README.md +63 -41
  3. package/bin/cli.js +65 -19
  4. package/bin/postinstall.js +22 -0
  5. package/docs/CODEX.ko.md +5 -1
  6. package/docs/CODEX.md +5 -0
  7. package/package.json +14 -26
  8. package/src/agents.js +16 -2
  9. package/src/brief.js +17 -8
  10. package/src/claude-price.js +23 -2
  11. package/src/codex-brief.js +35 -2
  12. package/src/codex-delegation.js +12 -4
  13. package/src/codex-harness.js +43 -9
  14. package/src/codex-hooks.js +24 -3
  15. package/src/codex-ledger.js +61 -5
  16. package/src/codex-panel.js +42 -22
  17. package/src/codex-rule-health.js +24 -1
  18. package/src/commands/brief.js +14 -3
  19. package/src/commands/codex-delegate.js +7 -4
  20. package/src/commands/codex.js +2 -0
  21. package/src/commands/harness.js +6 -0
  22. package/src/commands/install.js +24 -13
  23. package/src/compact-window.js +12 -4
  24. package/src/config.js +3 -3
  25. package/src/cost.js +5 -2
  26. package/src/ctx-window-cache.js +61 -0
  27. package/src/delegation-guard.js +15 -9
  28. package/src/doc2md.cjs +22 -8
  29. package/src/fig2md.cjs +7 -1
  30. package/src/formatters/statusline.js +4 -4
  31. package/src/formatters/table.js +18 -10
  32. package/src/gateway-auth.js +31 -3
  33. package/src/harness.js +43 -12
  34. package/src/history.js +6 -2
  35. package/src/hook-manager.js +54 -32
  36. package/src/installer.js +315 -158
  37. package/src/korean-lint.cjs +40 -7
  38. package/src/litellm-budget.js +10 -1
  39. package/src/model-rules.js +88 -41
  40. package/src/month-spend.js +17 -11
  41. package/src/parser.js +232 -56
  42. package/src/postinstall.js +32 -0
  43. package/src/prompt.js +28 -0
  44. package/src/route-caps.js +74 -0
  45. package/src/route-inject.js +31 -7
  46. package/src/route-scan.js +78 -26
  47. package/src/savings-ledger.js +33 -5
  48. package/src/seed-rules.js +3 -4
  49. package/src/session-cache.js +35 -1
  50. package/src/state-file.js +43 -0
  51. package/src/stats.js +20 -5
  52. package/src/statusline-base.js +37 -0
  53. package/src/stdin-payload.js +36 -5
package/src/brief.js CHANGED
@@ -29,6 +29,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, openSync,
29
29
  import { join } from 'node:path';
30
30
  import { userDataDir } from './paths.js';
31
31
  import { resolveModelId, isOneMillionModel, effectiveWindow } from './compact-window.js';
32
+ import { liveWindowFor } from './ctx-window-cache.js';
32
33
 
33
34
  // Context tiers as a fraction of the session's context window. Tier 1 warns
34
35
  // (compaction/cost territory ahead), tier 2 urges wrapping up. A session only
@@ -83,14 +84,21 @@ function saveState(state, now) {
83
84
  *
84
85
  * `observedMax` stays as a floor: it proves a 1M window even when the model id
85
86
  * is unreadable (env override, settings we do not resolve).
87
+ *
88
+ * `liveWindow` outranks both. It is the size Claude Code reported to the
89
+ * statusline for this session, so the briefing and the statusline count against
90
+ * the same window. The model id cannot stand in for it: a model that is 1M
91
+ * without a `[1m]` suffix reads as 200k there.
86
92
  */
87
- export function ctxWindowFor(observedMax = 0, root = process.cwd()) {
93
+ export function ctxWindowFor(observedMax = 0, root = process.cwd(), { liveWindow = null } = {}) {
88
94
  let modelWindow = observedMax > WINDOW_1M_MIN_INPUT ? 1_000_000 : 200_000;
95
+ const reported = Number.isFinite(liveWindow) && liveWindow > 0;
96
+ if (reported) modelWindow = liveWindow;
89
97
  let window = modelWindow;
90
98
  let compactCapped = false;
91
99
  try {
92
100
  const { model } = resolveModelId(root);
93
- if (isOneMillionModel(model)) modelWindow = window = 1_000_000;
101
+ if (!reported && isOneMillionModel(model)) modelWindow = window = 1_000_000;
94
102
  const cap = effectiveWindow(root).value;
95
103
  if (cap !== null && cap < window) {
96
104
  window = cap;
@@ -104,7 +112,7 @@ export function ctxWindowFor(observedMax = 0, root = process.cwd()) {
104
112
  * Last request's input size for THIS session, from the transcript tail.
105
113
  * Reads at most TAIL_BYTES — prompt-submit hooks must stay fast.
106
114
  */
107
- export function sessionCtx(transcriptPath, { root = process.cwd() } = {}) {
115
+ export function sessionCtx(transcriptPath, { root = process.cwd(), liveWindow = null } = {}) {
108
116
  let size;
109
117
  try { size = statSync(transcriptPath).size; } catch { return null; }
110
118
  const start = Math.max(0, size - TAIL_BYTES);
@@ -130,7 +138,7 @@ export function sessionCtx(transcriptPath, { root = process.cwd() } = {}) {
130
138
  if (total > 0) { input = total; maxInput = Math.max(maxInput, total); }
131
139
  }
132
140
  if (input == null) return null;
133
- const { window, modelWindow, compactCapped } = ctxWindowFor(maxInput, root);
141
+ const { window, modelWindow, compactCapped } = ctxWindowFor(maxInput, root, { liveWindow });
134
142
  return { input, window, modelWindow, compactCapped, pct: input / window };
135
143
  }
136
144
 
@@ -166,14 +174,15 @@ export function seedSessionBriefed(sessionId, signatures, now = Date.now()) {
166
174
  * (recorded via seedSessionBriefed) are skipped; every other fresh signature
167
175
  * is emitted, including on the session's first event.
168
176
  */
169
- export async function runBrief({ sessionId, transcriptPath, now = Date.now() }) {
177
+ export async function runBrief({ sessionId, transcriptPath, root = process.cwd(), now = Date.now() }) {
170
178
  if (!sessionId) return null;
179
+ const liveWindow = liveWindowFor(sessionId);
171
180
  const state = loadState();
172
181
  const s = state.sessions[sessionId] || { ctxTier: 0, briefed: [] };
173
182
  const items = [];
174
183
 
175
184
  // ── context tier crossing (per-session) ──
176
- const ctx = transcriptPath ? sessionCtx(transcriptPath) : null;
185
+ const ctx = transcriptPath ? sessionCtx(transcriptPath, { root, liveWindow }) : null;
177
186
  if (ctx) {
178
187
  const tier = ctxTierOf(ctx.pct);
179
188
  // The tier is not monotonic: auto-compaction drops the live context back to
@@ -238,7 +247,7 @@ export async function runBrief({ sessionId, transcriptPath, now = Date.now() })
238
247
  // never needed. 200k sessions are exempt (their window is already <= 200k).
239
248
  try {
240
249
  const cw = await import('./compact-window.js');
241
- const st = cw.compactWindowStatus({ root: process.cwd() });
250
+ const st = cw.compactWindowStatus({ root, liveWindow });
242
251
  if (!st.ok) {
243
252
  const sig = `compact-window|${st.reason}`;
244
253
  const briefed = new Set(s.briefed || []);
@@ -246,7 +255,7 @@ export async function runBrief({ sessionId, transcriptPath, now = Date.now() })
246
255
  briefed.add(sig);
247
256
  s.briefed = [...briefed];
248
257
  const now = st.window ? `현재 ${fmtK(st.window)}` : '현재 미설정';
249
- items.push(`1M 컨텍스트 모델(${st.model})인데 autoCompactWindow가 ${now}입니다 — 자동 압축이 80만 토큰 근처에서야 걸려 그전까지 모든 요청이 전체 컨텍스트를 재과금합니다. 1M은 너무 크니 ${fmtK(st.recommendedMin)}~${fmtK(st.recommendedMax)} 범위를 권장합니다(그 범위 안이면 경고하지 않습니다). 1M 창 자체는 그대로 두고 압축 시점만 앞당깁니다. 등록: sprag compact-window set --global|--project [--value ${fmtK(st.recommendedMax)}] (기본 ${fmtK(st.recommended)}, 적용 범위는 사용자에게 확인) / 끄기: compact-window off`);
258
+ items.push(`1M 컨텍스트 모델(${st.model || 'Claude Code 기본 모델'})인데 autoCompactWindow가 ${now}입니다 — 자동 압축이 80만 토큰 근처에서야 걸려 그전까지 모든 요청이 전체 컨텍스트를 재과금합니다. 1M은 너무 크니 ${fmtK(st.recommendedMin)}~${fmtK(st.recommendedMax)} 범위를 권장합니다(그 범위 안이면 경고하지 않습니다). 1M 창 자체는 그대로 두고 압축 시점만 앞당깁니다. 등록: sprag compact-window set --global|--project [--value ${fmtK(st.recommendedMax)}] (기본 ${fmtK(st.recommended)}, 적용 범위는 사용자에게 확인) / 끄기: compact-window off`);
250
259
  }
251
260
  }
252
261
  } catch { /* settings unreadable — other briefings above still apply */ }
@@ -16,10 +16,31 @@ export function sessionCost(totals, model, { env = process.env, now } = {}) {
16
16
  return price ? estimateCost(totals, model, { price }) : null;
17
17
  }
18
18
 
19
- /** Like estimateCostAcross, plus how many sessions had no price and were left out. */
19
+ /** One priced part per model that spent tokens, or the whole at its one model. */
20
+ function partsByModel(x) {
21
+ if (!x.modelTotals) return [{ totals: x.totals, model: x.model }];
22
+ return Object.entries(x.modelTotals).map(([model, totals]) => ({ totals, model }));
23
+ }
24
+
25
+ /**
26
+ * What a session's cost is made of: the session itself and each subagent run
27
+ * it spawned (parser.js attaches them as `subagentRuns`). A run has its own
28
+ * model, so it is priced on its own instead of at the session's rate, and a
29
+ * session or run that switched model is split again so each model's tokens
30
+ * meet that model's rate. `own` marks the parts that are the session itself.
31
+ */
32
+ export function pricedParts(session) {
33
+ if (!session) return [];
34
+ return [
35
+ ...partsByModel(session).map((part) => ({ ...part, own: true })),
36
+ ...(session.subagentRuns || []).flatMap(partsByModel),
37
+ ];
38
+ }
39
+
40
+ /** Like estimateCostAcross, plus how many priced parts (a session, a subagent run, or one model's share of either) had no price and were left out. */
20
41
  export function sessionCostAcross(sessions, opts = {}) {
21
42
  let unpriced = 0;
22
- const total = estimateCostAcross(sessions, (totals, model) => {
43
+ const total = estimateCostAcross((sessions || []).flatMap(pricedParts), (totals, model) => {
23
44
  const c = sessionCost(totals, model, opts);
24
45
  if (!c) unpriced++;
25
46
  return c;
@@ -1,10 +1,14 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync } from 'node:fs';
3
- import { join } from 'node:path';
3
+ import { join, resolve } from 'node:path';
4
4
  import { codexUserDir } from './agent.js';
5
5
  import { userDataDir } from './paths.js';
6
6
  import { readCodexSnapshot } from './codex-parser.js';
7
+ import { loadCodexModelRules } from './codex-delegation.js';
8
+ import { loadCodexLedger } from './codex-ledger.js';
9
+ import { codexRulesInReview } from './codex-rule-health.js';
7
10
 
11
+ const RESET_TOLERANCE_S = 300;
8
12
  const hash = (value) => createHash('sha256').update(value).digest('hex');
9
13
  const stateDir = (dir) => join(dir, 'codex', hash(codexUserDir()), 'sessions');
10
14
  const fresh = (value, now) => {
@@ -45,7 +49,7 @@ function readState(file) {
45
49
  }
46
50
 
47
51
  /** One file per home/session keeps unrelated sessions out of the same dedup state. */
48
- export function runCodexBrief({ sessionId, transcriptPath, cwd, now = Date.now(), dir = userDataDir() } = {}) {
52
+ export function runCodexBrief({ sessionId, transcriptPath, cwd, root: projectRoot, now = Date.now(), dir = userDataDir() } = {}) {
49
53
  if (typeof sessionId !== 'string' || !sessionId) return null;
50
54
  const snapshot = readCodexSnapshot(transcriptPath);
51
55
  if (!snapshot || (snapshot.sessionId && snapshot.sessionId !== sessionId)) return null;
@@ -54,6 +58,35 @@ export function runCodexBrief({ sessionId, transcriptPath, cwd, now = Date.now()
54
58
  const file = join(directory, `${hash(sessionId)}.json`);
55
59
  const state = readState(file);
56
60
  const active = Array.isArray(state.active) ? state.active.filter((sig) => typeof sig === 'string') : [];
61
+ // Codex rewrites resets_at by about a second between records, so a limit
62
+ // already announced for the same window keeps its first signature.
63
+ for (const issue of issues) {
64
+ const m = /^limit:(primary|secondary):(\d+(?:\.\d+)?)$/.exec(issue.signature);
65
+ if (!m) continue;
66
+ const resetsAt = Number(m[2]);
67
+ const known = active.find((sig) => {
68
+ const k = /^limit:(primary|secondary):(\d+(?:\.\d+)?)$/.exec(sig);
69
+ return k && k[1] === m[1] && Math.abs(Number(k[2]) - resetsAt) <= RESET_TOLERANCE_S;
70
+ });
71
+ if (known) issue.signature = known;
72
+ }
73
+ // A failing model rule is announced once per session; the lines never block the other warnings.
74
+ try {
75
+ // The hook passes the project root its rule matching uses; a prompt sent from
76
+ // a subdirectory must still see the project's rules.
77
+ const root = resolve(projectRoot || cwd || snapshot.projectDir || '.');
78
+ const rules = loadCodexModelRules({ dir });
79
+ if (rules.length) {
80
+ const events = Object.values(loadCodexLedger({ dir }).events);
81
+ for (const { index, rule, health } of codexRulesInReview({ root, rules, events })) {
82
+ issues.push({ key: 'rule-health', tier: 1,
83
+ signature: `rule-health:${rule.scope}:${rule.targetRoot ?? ''}:${rule.category}:${rule.from}:${rule.model}:${rule.createdAt ?? ''}`,
84
+ level: 'warning',
85
+ message: `Codex model rule #${index} (${rule.category}, ${rule.from} -> ${rule.model}) failed ${health.errs} of ${health.runs} measured runs.`,
86
+ advice: `Narrow or remove it: sprag delegate rules rm ${index} --agent codex` });
87
+ }
88
+ }
89
+ } catch { /* An unreadable rule file or ledger only skips this line. */ }
57
90
  const next = issues.filter((issue) => !active.includes(issue.signature) &&
58
91
  !(issue.key === 'context' && issue.tier === 1 && active.includes('context:2')));
59
92
  // Missing or stale measurements do not resolve an earlier warning. A fresh
@@ -54,7 +54,10 @@ export function addCodexModelRule({ category, model, effort, from, scope, root,
54
54
  const rules = loadCodexModelRules({ dir });
55
55
  const targetRoot = scope === 'project' ? resolve(root) : null;
56
56
  const rule = { category, model, effort, from, scope, targetRoot, ...(provider ? { provider } : {}), status: 'active', createdAt: new Date().toISOString() };
57
- const old = rules.findIndex((r) => r.category === category && r.from === from && r.scope === scope && r.targetRoot === targetRoot && r.provider === provider);
57
+ // A rule saved without a provider has no `provider` key, and a caller with
58
+ // none passes null. Compared as they are, undefined !== null, so re-adding
59
+ // the same rule stacked a duplicate instead of replacing it.
60
+ const old = rules.findIndex((r) => r.category === category && r.from === from && r.scope === scope && r.targetRoot === targetRoot && (r.provider ?? null) === (provider ?? null));
58
61
  if (old < 0) rules.push(rule); else rules[old] = rule;
59
62
  saveRules(rules, dir);
60
63
  return rule;
@@ -126,7 +129,8 @@ export function codexRouteHint(payload, opts = {}) {
126
129
  try {
127
130
  record({ id, parentSessionId: payload?.session_id ?? null, turnId: payload?.turn_id ?? null, via: 'prompt',
128
131
  source: 'rule', category: rule.category, scope: rule.scope, from: payload?.model, to: rule.model,
129
- provider, effort: rule.effort ?? null, contextTokens: ctx }, { dir: opts.dir });
132
+ provider, effort: rule.effort ?? null, contextTokens: ctx,
133
+ targetRoot: rule.targetRoot ?? null, ruleCreatedAt: rule.createdAt ?? null }, { dir: opts.dir });
130
134
  } catch { /* Accounting is optional; the hint still applies. */ }
131
135
  return `[Sprag model routing] This request matches the approved ${rule.category} rule (route ${id}). `
132
136
  + `Spawn one sub-agent with model ${rule.model}${rule.effort ? `, reasoning_effort ${rule.effort}` : ''} and fork_turns "none". `
@@ -160,7 +164,7 @@ function customRole(role, root, home) {
160
164
 
161
165
  // Kept for Codex versions that do send spawn_agent through PreToolUse; on
162
166
  // 0.159.2 the hook never fires for it, so codexRouteHint above is the live path.
163
- export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd || process.cwd(), home = codexUserDir(), rules, dir = userDataDir(), record = recordCodexDelegation } = {}) {
167
+ export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd || process.cwd(), home = codexUserDir(), rules: given, dir = userDataDir(), record = recordCodexDelegation } = {}) {
164
168
  if (cfg.codex?.delegate !== true) return null;
165
169
  const tool = payload?.tool_name?.replace(/^(?:functions|tools)\./, '');
166
170
  if (!['spawn_agent', 'Agent'].includes(tool)) return null;
@@ -169,6 +173,9 @@ export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd ||
169
173
  const role = input.agent_type || 'default';
170
174
  if (!['default', 'worker', 'explorer'].includes(role) || customRole(role, payload.cwd || root, home)) return null;
171
175
  const explicit = Object.hasOwn(input, 'model');
176
+ // An unreadable rule file must not stop the default target or the spawn itself.
177
+ let rules = given;
178
+ if (!rules) { try { rules = loadCodexModelRules({ dir }); } catch { rules = []; } }
172
179
  const provider = payload.model_provider || codexProviderName({ home });
173
180
  const rule = explicit ? null : matchCodexModelRule(input.message, { model: payload.model, provider, root, rules });
174
181
  const target = rule || (!explicit && cfg.codex?.delegateTarget);
@@ -184,7 +191,8 @@ export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd ||
184
191
  try {
185
192
  record({ id, parentSessionId: typeof payload.session_id === 'string' ? payload.session_id : null,
186
193
  source: rule ? 'rule' : 'default', category: rule?.category ?? null, scope: rule?.scope ?? null,
187
- from: payload.model, to: target.model, provider, effort: updatedInput.reasoning_effort ?? null }, { dir });
194
+ from: payload.model, to: target.model, provider, effort: updatedInput.reasoning_effort ?? null,
195
+ ...(rule ? { targetRoot: rule.targetRoot ?? null, ruleCreatedAt: rule.createdAt ?? null } : {}) }, { dir });
188
196
  routeLine = `\n<!-- sprag:codex:route id=${id} -->`;
189
197
  } catch { /* Accounting is optional; the route itself still applies. */ }
190
198
  }
@@ -14,14 +14,22 @@ const sections = [
14
14
  ['Default Safe Path', 'Ask before destructive or external actions unless already authorized. Proceed with reads, local edits, and tests within the granted permissions.'],
15
15
  ];
16
16
 
17
+ const scopeDir = (root, scope) => (scope === 'global' ? codexUserDir() : root);
18
+
17
19
  export function codexHarnessPaths(root, scope = 'project') {
18
- const dir = scope === 'global' ? codexUserDir() : root;
20
+ const dir = scopeDir(root, scope);
19
21
  // Codex reads the override instead of AGENTS.md when it is non-empty.
20
22
  const override = join(dir, 'AGENTS.override.md');
21
23
  const file = existsSync(override) && readFileSync(override, 'utf8').trim() ? override : join(dir, 'AGENTS.md');
22
24
  return { file, ratchet: scope === 'global' ? join(dir, 'ratchet.md') : join(dir, '.codex', 'ratchet.md') };
23
25
  }
24
26
 
27
+ /** Every instruction file of a scope that init may have written to, active or not. */
28
+ function harnessFiles(root, scope) {
29
+ const dir = scopeDir(root, scope);
30
+ return [join(dir, 'AGENTS.override.md'), join(dir, 'AGENTS.md')];
31
+ }
32
+
25
33
  export function codexHarnessBlock() {
26
34
  return `${CODEX_BEGIN}\n## Sprag Harness\n\n${sections.map(([name, text], i) => `### ${i + 1}. ${name}\n${text}`).join('\n\n')}\n\nRead the global and project Codex ratchet files before working when present. Run \`sprag harness list --agent codex --global\` and \`sprag harness list --agent codex --project\` to retrieve them. These are instructions, not Claude-style @ imports.\nUse \`sprag doc2md <file> --agent codex\` before reading PDF, Office, or Figma documents. Delegate only through available Codex tools and configured roles; do not assume Claude model names or Task/Agent arguments. A [Sprag model routing] note on a request means the user has asked for that request to be delegated: spawn the sub-agent it describes (its model, reasoning_effort, fork_turns "none", and a self-contained message ending with the route line it gives), wait for it to finish instead of doing the same task yourself, then verify the result before replying.\n${CODEX_END}\n`;
27
35
  }
@@ -40,8 +48,11 @@ export function initCodexHarness({ root, scope = 'project' }) {
40
48
  const existing = existsSync(paths.file) ? readFileSync(paths.file, 'utf8') : '';
41
49
  const span = range(existing);
42
50
  const block = codexHarnessBlock();
51
+ // Trailing newlines are dropped before the separator is added, so the gap
52
+ // above the block is always one blank line however the file ended.
53
+ const body = existing.replace(/\n+$/, '');
43
54
  const next = span ? existing.slice(0, span[0]) + block.trimEnd() + existing.slice(span[1])
44
- : existing + (existing ? '\n\n' : '') + block;
55
+ : body + (body ? '\n\n' : '') + block;
45
56
  mkdirSync(dirname(paths.file), { recursive: true });
46
57
  if (next !== existing) {
47
58
  if (existing) writeFileSync(`${paths.file}.bak-${Date.now()}`, existing);
@@ -54,15 +65,38 @@ export function initCodexHarness({ root, scope = 'project' }) {
54
65
  return paths;
55
66
  }
56
67
 
68
+ /**
69
+ * The file without the block, and without the blank lines init put around it.
70
+ * Cutting the block alone left those behind, so each install and uninstall
71
+ * added three more.
72
+ */
73
+ function withoutBlock(text, [begin, end]) {
74
+ const before = text.slice(0, begin).replace(/\n+$/, '');
75
+ const after = text.slice(end).replace(/^\n+/, '');
76
+ if (!before) return after;
77
+ return after ? `${before}\n\n${after}` : `${before}\n`;
78
+ }
79
+
80
+ /**
81
+ * Removes the block from every file of the scope that holds one. The active
82
+ * file alone is not enough: init writes AGENTS.md, and an AGENTS.override.md
83
+ * created afterwards becomes the active file while the block stays in the
84
+ * other one, where a later uninit never looked.
85
+ */
57
86
  export function uninitCodexHarness({ root, scope = 'project' }) {
58
87
  const { file } = codexHarnessPaths(root, scope);
59
- if (!existsSync(file)) return { file, removed: false };
60
- const existing = readFileSync(file, 'utf8');
61
- const span = range(existing);
62
- if (!span) return { file, removed: false };
63
- writeFileSync(`${file}.bak-${Date.now()}`, existing);
64
- writeFileSync(file, existing.slice(0, span[0]) + existing.slice(span[1]));
65
- return { file, removed: true };
88
+ const cleaned = [];
89
+ for (const candidate of harnessFiles(root, scope)) {
90
+ if (!existsSync(candidate)) continue;
91
+ const existing = readFileSync(candidate, 'utf8');
92
+ const span = range(existing);
93
+ if (!span) continue;
94
+ writeFileSync(`${candidate}.bak-${Date.now()}`, existing);
95
+ writeFileSync(candidate, withoutBlock(existing, span));
96
+ cleaned.push(candidate);
97
+ }
98
+ if (!cleaned.length) return { file, removed: false };
99
+ return { file: cleaned.includes(file) ? file : cleaned[0], removed: true, ...(cleaned.length > 1 ? { files: cleaned } : {}) };
66
100
  }
67
101
 
68
102
  export function codexHarnessStatus({ root, scope = 'project' }) {
@@ -11,7 +11,7 @@ import { findProjectRoot } from './harness.js';
11
11
  import { runCodexBrief } from './codex-brief.js';
12
12
  import { codexDocumentTool, codexDocumentNote, convertCodexDocument } from './codex-doc2md.js';
13
13
  import { codexDelegationTool, codexRouteHint } from './codex-delegation.js';
14
- import { bindCodexSubagent } from './codex-ledger.js';
14
+ import { bindCodexSubagent, closeStaleCodexRoutes, unsettledCodexRoutes } from './codex-ledger.js';
15
15
  import { seedOfferBlock } from './seed-rules.js';
16
16
  import { readCodexRouteScan, openCodexCandidates, shouldRescanCodex } from './codex-route-scan.js';
17
17
  import { userDataDir } from './paths.js';
@@ -120,6 +120,20 @@ function defaultRescan() {
120
120
  { detached: true, stdio: 'ignore', windowsHide: true }).unref();
121
121
  }
122
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
+
123
137
  export function lintCodexTool(payload, { scope = 'all' } = {}) {
124
138
  if (!payload || typeof payload !== 'object') return null;
125
139
  const input = payload.tool_input;
@@ -148,7 +162,7 @@ export function lintCodexTool(payload, { scope = 'all' } = {}) {
148
162
  return messages.length ? messages.join('\n\n') : null;
149
163
  }
150
164
 
151
- export async function codexHookOutput(event, payload, { cfg = loadConfig() } = {}) {
165
+ export async function codexHookOutput(event, payload, { cfg = loadConfig(), refreshLedger = defaultLedgerRefresh } = {}) {
152
166
  if (!payload || typeof payload !== 'object') return null;
153
167
  if (event === 'session-start' || event === 'subagent-start') {
154
168
  if (event === 'subagent-start' && cfg?.codex?.delegate !== true) return null;
@@ -193,13 +207,20 @@ export async function codexHookOutput(event, payload, { cfg = loadConfig() } = {
193
207
  if (event === 'prompt') {
194
208
  const parts = [];
195
209
  if (cfg?.codex?.delegate === true) {
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); }
196
214
  try {
197
215
  parts.push(codexRouteHint(payload, { root: findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' }),
198
216
  minContext: Number.isFinite(cfg?.codex?.delegateMinContext) ? cfg.codex.delegateMinContext : undefined }));
199
217
  } catch (e) { debug('codex:route-hint', e); }
200
218
  }
201
219
  if (cfg?.codex?.brief !== false) {
202
- try { parts.push(runCodexBrief({ sessionId: payload.session_id, transcriptPath: payload.transcript_path, cwd: payload.cwd })); }
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
+ }
203
224
  catch (e) { debug('codex:brief', e); }
204
225
  }
205
226
  if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) {
@@ -87,19 +87,57 @@ export function readCodexBindings({ dir = userDataDir() } = {}) {
87
87
  return out;
88
88
  }
89
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
+
90
126
  /**
91
127
  * Bind a just-spawned SubagentStart child to the pending route it most likely
92
128
  * fulfills: same parent thread, same target model, recent enough, and not
93
- * already claimed by another child. Returns the bound route id, or null when
94
- * nothing matches or this child is already bound.
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.
95
132
  */
96
133
  export function bindCodexSubagent(payload, { dir = userDataDir(), now = Date.now() } = {}) {
97
134
  if (typeof payload?.agent_id !== 'string' || typeof payload?.session_id !== 'string' || typeof payload?.model !== 'string') return null;
98
135
  const binds = readCodexBindings({ dir });
99
136
  if (binds.some((b) => b.childSessionId === payload.agent_id)) return null;
100
- const boundIds = new Set(binds.map((b) => b.id));
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;
101
139
  const pending = readCodexDelegations({ dir }).filter((r) =>
102
- r.parentSessionId === payload.session_id && r.to === payload.model && now - r.at < BIND_TTL_MS && !boundIds.has(r.id));
140
+ r.parentSessionId === payload.session_id && r.to === payload.model && now - r.at < BIND_TTL_MS && !boundIds.has(r.id) && !otherTurn(r));
103
141
  if (!pending.length) return null;
104
142
  // Oldest first: spawns are recorded in order, so the earliest unbound record belongs to the earliest child.
105
143
  const chosen = pending.reduce((a, b) => (b.at < a.at ? b : a));
@@ -107,6 +145,22 @@ export function bindCodexSubagent(payload, { dir = userDataDir(), now = Date.now
107
145
  return chosen.id;
108
146
  }
109
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
+
110
164
  export function loadCodexLedger({ dir = userDataDir() } = {}) {
111
165
  try {
112
166
  const data = JSON.parse(readFileSync(ledgerFile(dir), 'utf8'));
@@ -195,7 +249,9 @@ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date
195
249
  const usd = actual === null || counterfactual === null ? null : roundUsd(run.aborted ? -actual : counterfactual - actual);
196
250
  const event = { ts: run.endedAt ?? route.at, parentSessionId: route.parentSessionId || run.parentThreadId || null,
197
251
  childSessionId: run.sessionId, source: route.source, category: route.category ?? null, scope: route.scope ?? null,
198
- from: route.from, to, provider, tokens: run.tokens, calls: run.calls, toolErrors: run.toolErrors, usd, rates: usd === null ? null : rates,
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,
199
255
  priceCheckedAt: rates?.checkedAt ?? null, priceSource, complete: run.complete, ...(run.aborted ? { aborted: true } : {}) };
200
256
  // A transient price outage must not erase a completed, unchanged priced run.
201
257
  if (usd === null && sameRun && previous.complete && Number.isFinite(previous.usd) &&
@@ -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';
@@ -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
 
@@ -64,29 +66,41 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
64
66
  files ??= sessionFile && !sessionId ? [] : await discoverCodexSessionFiles({ home, days: sessionId ? Infinity : days });
65
67
  const existing = new Set(files.map((f) => f.path));
66
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];
67
76
  let selected = null;
68
77
  let matches = 0;
69
- const seen = new Set();
70
78
  // Recent rollouts of the same project, for repeats across `codex exec` runs.
71
- const recent = [];
72
- for (const f of files.slice().reverse()) {
73
- let entry = cache.get(f.path);
74
- if (!entry || entry.mtime !== f.mtime || entry.size !== f.size) {
75
- try {
76
- entry = { ...f, session: await parseCodexSessionFile(f.path) };
77
- cache.set(f.path, entry);
78
- } catch { continue; }
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;
79
103
  }
80
- const s = entry.session;
81
- if ((!sessionId || sessionFile) && s.isSubagent) continue;
82
- if (sessionId ? s.sessionId !== sessionId : !insideProject(root, s.projectDir)) continue;
83
- const key = s.sessionId || s.filePath;
84
- if (seen.has(key)) continue;
85
- seen.add(key);
86
- matches++;
87
- if (!sessionId && Date.now() - f.mtime < RATCHET_SIBLING_MS) recent.push(s.filePath);
88
- // Activity timestamps survive archive moves that update filesystem mtime.
89
- if (!selected || (s.lastActivity?.getTime() || 0) > (selected.lastActivity?.getTime() || 0)) selected = s;
90
104
  }
91
105
  const project = selected?.projectDir ? findProjectRoot(selected.projectDir, { agent: 'codex' }) : root;
92
106
  const cfg = loadConfig();
@@ -102,11 +116,16 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
102
116
  }
103
117
  let route = [];
104
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. */ }
105
124
  let routingSaved = null;
106
125
  try { routingSaved = { ...codexRoutingSavedTotals(), pricesAvailable: isDirectOpenAI(selected?.provider, home ? { home } : {}) || !!readCodexPrices({ provider: codexBudgetProvider(home ? { home } : {}), ...(home ? { home } : {}) }) }; }
107
126
  catch { /* Unreadable ledger reads as n/a. */ }
108
127
  return { session: selected, matches, root, sessionId, selection, bindingPending: !!sessionFile && !sessionId, days, harness, rules,
109
- ratchet: ratchetFor(selected, recent.filter((file) => file !== selected?.filePath)), route, routingSaved,
128
+ ratchet: ratchetFor(selected, recent.filter((file) => file !== selected?.filePath)), route, ruleReview, routingSaved,
110
129
  labelMode: resolveLabelMode({ cfg: statuslineDefaults(), hasFlag }).mode,
111
130
  color: statuslineDefaults().color,
112
131
  timer: !hasFlag('--no-timer') && (hasFlag('--timer') || statuslineDefaults().timer),
@@ -266,6 +285,7 @@ function panelGroups(data, { now, version = '', full = false, mode = full ? 'tex
266
285
  chip(`Ratchet ${data.rules?.global ?? '?'}/${data.rules?.project ?? '?'}`),
267
286
  // A repeated failure outranks a delegation candidate, as in the Claude statusline.
268
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,
269
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,
270
290
  ]);
271
291
  register('korean', 'preferences', [
@@ -1,3 +1,4 @@
1
+ import { resolve } from 'node:path';
1
2
  import { loadCodexLedger } from './codex-ledger.js';
2
3
  import { isFailedRun } from './route-scan.js';
3
4
  import { wilsonLowerBound, HEALTH_ERR_RATE, HEALTH_MIN_SAMPLE_DELEGATED } from './model-rules.js';
@@ -9,10 +10,32 @@ import { wilsonLowerBound, HEALTH_ERR_RATE, HEALTH_MIN_SAMPLE_DELEGATED } from '
9
10
  * lower bound of the failure rate clears HEALTH_ERR_RATE over enough runs.
10
11
  */
11
12
  export function codexRuleHealth(rule, { events = Object.values(loadCodexLedger().events) } = {}) {
13
+ const since = Date.parse(rule.createdAt);
12
14
  const runs = events.filter((e) => e && e.category === rule.category && e.from === rule.from && e.to === rule.model &&
13
- (!rule.provider || !e.provider || e.provider === rule.provider) && (e.complete || e.aborted));
15
+ (!rule.provider || !e.provider || e.provider === rule.provider) && (e.complete || e.aborted) &&
16
+ (e.scope == null || e.scope === rule.scope) &&
17
+ // A project run counts only for the project it was recorded in; events from before the field existed cannot be placed.
18
+ (rule.scope !== 'project' || (typeof e.targetRoot === 'string' && e.targetRoot === rule.targetRoot)) &&
19
+ // A rule deleted and added again starts a fresh record.
20
+ (!Number.isFinite(since) || (Number.isFinite(e.ts) && e.ts >= since)));
14
21
  const errs = runs.filter((e) => e.aborted || isFailedRun({ toolErrors: e.toolErrors, calls: e.calls })).length;
15
22
  const saved = runs.filter((e) => Number.isFinite(e.usd)).reduce((sum, e) => sum + e.usd, 0);
16
23
  const review = runs.length >= HEALTH_MIN_SAMPLE_DELEGATED && wilsonLowerBound(errs, runs.length) > HEALTH_ERR_RATE;
17
24
  return { runs: runs.length, errs, rate: runs.length ? errs / runs.length : 0, saved, status: review ? 'review' : 'active' };
18
25
  }
26
+
27
+ /**
28
+ * Rules in review that apply to the project at `root`: global rules, and
29
+ * project rules whose targetRoot is `root`; a null `root` lists every rule. `index` is the 1-based number the
30
+ * rule has in `delegate rules --agent codex`, so every surface names it alike.
31
+ */
32
+ export function codexRulesInReview({ root, rules, events }) {
33
+ const here = root ? resolve(root) : null;
34
+ const out = [];
35
+ (rules || []).forEach((rule, i) => {
36
+ if (here && rule.scope === 'project' && rule.targetRoot !== here) return;
37
+ const health = codexRuleHealth(rule, { events });
38
+ if (health.status === 'review') out.push({ index: i + 1, rule, health });
39
+ });
40
+ return out;
41
+ }