sprag-cli 3.54.0 → 3.56.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 (42) hide show
  1. package/README.ko.md +17 -5
  2. package/README.md +23 -6
  3. package/bin/cli.js +6 -6
  4. package/docs/CODEX.ko.md +57 -10
  5. package/docs/CODEX.md +89 -20
  6. package/package.json +6 -3
  7. package/src/claude-price.js +28 -0
  8. package/src/codex-brief.js +20 -0
  9. package/src/codex-cache-policy.js +26 -41
  10. package/src/codex-delegation.js +45 -5
  11. package/src/codex-doctor.js +1 -1
  12. package/src/codex-harness.js +20 -9
  13. package/src/codex-hooks.js +114 -19
  14. package/src/codex-ledger.js +94 -16
  15. package/src/codex-panel-setup.js +98 -0
  16. package/src/codex-panel.js +12 -7
  17. package/src/codex-parser.js +41 -4
  18. package/src/codex-route-scan.js +14 -7
  19. package/src/codex-rule-health.js +18 -0
  20. package/src/commands/codex-delegate.js +12 -3
  21. package/src/commands/codex.js +41 -11
  22. package/src/commands/harness.js +13 -2
  23. package/src/commands/install.js +106 -10
  24. package/src/commands/route-scan.js +8 -3
  25. package/src/commands/seed.js +7 -4
  26. package/src/commands/uninstall.js +23 -0
  27. package/src/commands/upgrade.js +13 -0
  28. package/src/cost.js +57 -3
  29. package/src/formatters/statusline.js +18 -7
  30. package/src/formatters/table.js +1 -0
  31. package/src/gateway-prices.js +39 -0
  32. package/src/harness-templates.js +33 -1
  33. package/src/harness.js +26 -1
  34. package/src/litellm-models.js +72 -10
  35. package/src/model-rules.js +2 -1
  36. package/src/money.js +9 -0
  37. package/src/month-spend.js +10 -5
  38. package/src/openai-prices.js +33 -0
  39. package/src/preset-ratchet.js +133 -0
  40. package/src/route-scan.js +16 -8
  41. package/src/savings-ledger.js +10 -6
  42. package/src/seed-rules.js +32 -7
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Sets up the Codex companion panel to run inline, inside the same terminal,
3
+ * instead of opening a separate macOS Terminal window. Codex's native footer
4
+ * cannot run a command, so the panel is the closest thing Codex has to a
5
+ * statusline; showing it below the Codex pane in tmux (via a zsh `codex()`
6
+ * wrapper) means a user does not get a new window per session. Install falls
7
+ * back to the separate window when tmux cannot be set up, the login shell is
8
+ * not zsh, or the user already has their own `codex` alias/function.
9
+ */
10
+
11
+ import { execFileSync } from 'node:child_process';
12
+ import { existsSync } from 'node:fs';
13
+ import { basename, dirname, join } from 'node:path';
14
+ import { installPanelShell } from './codex-panel-shell.js';
15
+
16
+ /** Whether a tmux binary answers `-V`. */
17
+ export function tmuxAvailable({ exec = execFileSync } = {}) {
18
+ try { exec('tmux', ['-V'], { stdio: 'pipe' }); return true; }
19
+ catch { return false; }
20
+ }
21
+
22
+ export function ensureTmux({ platform = process.platform, env = process.env, exec = execFileSync, exists = existsSync } = {}) {
23
+ // Checked first, before probing anything, so tests and CI never spawn brew.
24
+ if (env.CTS_NO_TMUX === '1' || (env.CI && env.CI !== 'false')) return { status: 'skipped' };
25
+ if (tmuxAvailable({ exec })) return { status: 'present' };
26
+ if (platform !== 'darwin') {
27
+ return { status: 'unavailable', detail: 'install tmux with your package manager (e.g. sudo apt install tmux)' };
28
+ }
29
+ let brew;
30
+ try { exec('brew', ['--prefix'], { stdio: 'pipe' }); brew = 'brew'; }
31
+ catch {
32
+ for (const p of ['/opt/homebrew/bin/brew', '/usr/local/bin/brew']) {
33
+ if (exists(p)) { brew = p; break; }
34
+ }
35
+ }
36
+ if (!brew) {
37
+ return { status: 'unavailable', detail: 'Homebrew not found; install tmux from https://github.com/tmux/tmux/wiki/Installing or MacPorts (sudo port install tmux)' };
38
+ }
39
+ try {
40
+ // brew update alone can take minutes; postinstall cannot afford that here.
41
+ exec(brew, ['install', 'tmux'], { stdio: 'inherit', timeout: 600_000,
42
+ env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1', HOMEBREW_NO_INSTALL_CLEANUP: '1' } });
43
+ } catch (e) {
44
+ return { status: 'failed', via: 'brew', detail: String(e.message).split('\n')[0] };
45
+ }
46
+ // No npm-package fallback: no official npm tmux exists, and a third-party
47
+ // prebuilt binary would be an unaudited supply-chain risk.
48
+ const fallback = join(dirname(brew === 'brew' ? '/opt/homebrew/bin/brew' : brew), 'tmux');
49
+ if (tmuxAvailable({ exec }) || exists(fallback)) return { status: 'installed', via: 'brew' };
50
+ return { status: 'failed', via: 'brew', detail: 'tmux not on PATH after brew install' };
51
+ }
52
+
53
+ export function setupCodexPanel({ platform = process.platform, env = process.env, config = {}, noPanel = false,
54
+ exec, exists, installShell = installPanelShell, tmux = ensureTmux } = {}) {
55
+ if (noPanel) return { panelAuto: false, mode: 'off' };
56
+ // Panel auto and the zsh integration are macOS-only today.
57
+ if (platform !== 'darwin') return { panelAuto: false, mode: 'off' };
58
+ const windowAuto = config?.codex?.panelAuto !== false;
59
+ if (config?.codex?.panelShell === false) {
60
+ return { panelAuto: windowAuto, mode: windowAuto ? 'window' : 'off', reason: 'shell integration turned off' };
61
+ }
62
+ if (basename(env.SHELL || '') !== 'zsh') {
63
+ return { panelAuto: windowAuto, mode: windowAuto ? 'window' : 'off', reason: 'login shell is not zsh' };
64
+ }
65
+ const t = tmux({ platform, env, ...(exec && { exec }), ...(exists && { exists }) });
66
+ if (!['present', 'installed'].includes(t.status)) {
67
+ return { panelAuto: windowAuto, mode: windowAuto ? 'window' : 'off', reason: t.detail || t.status, tmux: t };
68
+ }
69
+ let shellFile;
70
+ try { shellFile = installShell(); }
71
+ catch (e) {
72
+ return { panelAuto: windowAuto, mode: windowAuto ? 'window' : 'off', reason: e.message, tmux: t };
73
+ }
74
+ // The inline panel replaces the separate window by default; an explicit
75
+ // `panel auto on` keeps both.
76
+ return { panelAuto: config?.codex?.panelAuto === true, mode: 'inline', tmux: t, shellFile };
77
+ }
78
+
79
+ /** Formats a `setupCodexPanel` result as lines a caller prefixes and prints. */
80
+ export function describePanelSetup(result, lang = 'en') {
81
+ if (result.mode === 'off') return [];
82
+ if (result.mode === 'inline') {
83
+ const lines = [lang === 'ko'
84
+ ? `패널: tmux 인라인 (${result.shellFile}). 새 터미널에서 codex 를 실행하면 같은 창 아래쪽에 패널이 붙습니다.`
85
+ : `panel: inline tmux (${result.shellFile}). Open a new terminal and run codex; the panel attaches below it.`];
86
+ if (result.tmux?.status === 'installed') {
87
+ lines.push(lang === 'ko' ? 'tmux 를 Homebrew 로 설치했습니다.' : 'installed tmux with Homebrew.');
88
+ }
89
+ return lines;
90
+ }
91
+ const lines = [lang === 'ko'
92
+ ? `패널: 별도 Terminal 창${result.reason ? ` (${result.reason})` : ''}`
93
+ : `panel: separate Terminal window${result.reason ? ` (${result.reason})` : ''}`];
94
+ if (result.tmux?.detail && ['unavailable', 'failed'].includes(result.tmux.status)) {
95
+ lines.push(`tmux: ${result.tmux.detail}`);
96
+ }
97
+ return lines;
98
+ }
@@ -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';
@@ -28,20 +28,22 @@ export const CODEX_SEGMENT_ORDER = [
28
28
 
29
29
  // Only cache full-session parses. A moving time cutoff would make an unchanged
30
30
  // file's cached totals stale. The panel reports session totals, not window totals.
31
+ // Matches the candidate TTL: older runs could not contribute a candidate anyway.
32
+ const RATCHET_SIBLING_MS = 30 * 60000;
33
+
31
34
  export function createPanelReader({ root = process.cwd(), home, sessionId: pinnedId, sessionFile, days = 7, readBudget = () => null,
32
35
  readCachePolicy = (session) => readCodexCachePolicy(session, { home }), hasFlag = () => false } = {}) {
33
36
  if (pinnedId && sessionFile) throw new Error('Choose --session or --session-file, not both.');
34
37
  const cache = new Map();
35
38
  const ratchetCache = new Map();
36
- const ratchetFor = (session) => {
39
+ const ratchetFor = (session, siblings = []) => {
37
40
  if (!session?.filePath) return [];
38
41
  try {
39
- const s = statSync(session.filePath);
40
- const key = `${s.mtimeMs}:${s.size}`;
42
+ const key = [session.filePath, ...siblings].map((file) => { const s = statSync(file); return `${file}:${s.mtimeMs}:${s.size}`; }).join('|');
41
43
  // Candidates expire by age, so a quiet file still needs a periodic re-read.
42
44
  const hit = ratchetCache.get(session.filePath);
43
45
  if (hit?.key === key && Date.now() - hit.at < 60000) return hit.value;
44
- const value = codexRatchetCandidates(session.filePath);
46
+ const value = codexRatchetCandidates(session.filePath, { siblings });
45
47
  ratchetCache.clear();
46
48
  ratchetCache.set(session.filePath, { key, at: Date.now(), value });
47
49
  return value;
@@ -65,6 +67,8 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
65
67
  let selected = null;
66
68
  let matches = 0;
67
69
  const seen = new Set();
70
+ // Recent rollouts of the same project, for repeats across `codex exec` runs.
71
+ const recent = [];
68
72
  for (const f of files.slice().reverse()) {
69
73
  let entry = cache.get(f.path);
70
74
  if (!entry || entry.mtime !== f.mtime || entry.size !== f.size) {
@@ -80,6 +84,7 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
80
84
  if (seen.has(key)) continue;
81
85
  seen.add(key);
82
86
  matches++;
87
+ if (!sessionId && Date.now() - f.mtime < RATCHET_SIBLING_MS) recent.push(s.filePath);
83
88
  // Activity timestamps survive archive moves that update filesystem mtime.
84
89
  if (!selected || (s.lastActivity?.getTime() || 0) > (selected.lastActivity?.getTime() || 0)) selected = s;
85
90
  }
@@ -98,10 +103,10 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
98
103
  let route = [];
99
104
  try { route = openCodexCandidates(readCodexRouteScan(), { root: project }); } catch { /* No scan yet. */ }
100
105
  let routingSaved = null;
101
- try { routingSaved = { ...codexRoutingSavedTotals(), pricesAvailable: !!readCodexPrices({ provider: codexBudgetProvider(home ? { home } : {}), ...(home ? { home } : {}) }) }; }
106
+ try { routingSaved = { ...codexRoutingSavedTotals(), pricesAvailable: isDirectOpenAI(selected?.provider, home ? { home } : {}) || !!readCodexPrices({ provider: codexBudgetProvider(home ? { home } : {}), ...(home ? { home } : {}) }) }; }
102
107
  catch { /* Unreadable ledger reads as n/a. */ }
103
108
  return { session: selected, matches, root, sessionId, selection, bindingPending: !!sessionFile && !sessionId, days, harness, rules,
104
- ratchet: ratchetFor(selected), route, routingSaved,
109
+ ratchet: ratchetFor(selected, recent.filter((file) => file !== selected?.filePath)), route, routingSaved,
105
110
  labelMode: resolveLabelMode({ cfg: statuslineDefaults(), hasFlag }).mode,
106
111
  color: statuslineDefaults().color,
107
112
  timer: !hasFlag('--no-timer') && (hasFlag('--timer') || statuslineDefaults().timer),
@@ -7,16 +7,45 @@ import { codexUserDir } from './agent.js';
7
7
  const count = (n) => Number.isSafeInteger(n) && n >= 0 ? n : 0;
8
8
  const emptyTotals = () => ({ input: 0, cacheRead: 0, cacheCreation: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0 });
9
9
 
10
+ /**
11
+ * A child spawned with fork_turns "all" writes its own session_meta first,
12
+ * then a copy of the parent's session_meta and history, then its own work.
13
+ * The copy belongs to the parent: identity comes from the first session_meta,
14
+ * and the copied lines are skipped so the parent's request is not counted a
15
+ * second time. The copy ends at the child's own thread_settings_applied, or
16
+ * at the first line written after the batch copy, whichever comes first.
17
+ */
18
+ export function forkedCopyFilter() {
19
+ let ownId = null, copyStamp = null;
20
+ return (e) => {
21
+ const p = e?.payload;
22
+ if (e?.type === 'session_meta') {
23
+ const id = p?.id || p?.session_id || null;
24
+ if (ownId === null) { ownId = id; return false; }
25
+ // A resumed rollout can repeat its own meta; only another thread's is a copy.
26
+ if (id && id !== ownId) { copyStamp = e.timestamp ?? null; return true; }
27
+ return true;
28
+ }
29
+ if (copyStamp === null) return false;
30
+ const own = e?.type === 'event_msg' && p?.type === 'thread_settings_applied' && p.thread_id === ownId;
31
+ if (own || e?.timestamp !== copyStamp) { copyStamp = null; return false; }
32
+ return true;
33
+ };
34
+ }
35
+
10
36
  function updateSnapshot(session, e) {
11
37
  const p = e?.payload;
12
38
  if (!p || typeof p !== 'object') return;
13
39
  const activity = Date.parse(e.timestamp);
14
40
  if (Number.isFinite(activity)) session.lastActivity = new Date(activity);
15
41
  if (e.type === 'session_meta') {
42
+ // Callers drop forked copies, so a later meta can only be this thread's own repeat.
43
+ if (session.sessionId) return;
16
44
  session.sessionId = p.id || p.session_id || null;
17
45
  session.projectDir = typeof p.cwd === 'string' ? p.cwd : '';
18
46
  session.source = p.source ?? null;
19
47
  session.isSubagent = typeof p.source === 'object' && p.source !== null && 'subagent' in p.source;
48
+ session.forkedFrom = typeof p.forked_from_id === 'string' ? p.forked_from_id : null;
20
49
  session.branch = typeof p.git?.branch === 'string' ? p.git.branch : null;
21
50
  session.provider = typeof p.model_provider === 'string' ? p.model_provider : null;
22
51
  }
@@ -63,8 +92,12 @@ export function readCodexSnapshot(filePath, { tailBytes = 256 * 1024 } = {}) {
63
92
  const lines = readCodexTailLines(filePath, tailBytes);
64
93
  if (!lines) return null;
65
94
  const snapshot = {};
95
+ const copied = forkedCopyFilter();
66
96
  for (const line of lines) {
67
- try { updateSnapshot(snapshot, JSON.parse(line)); } catch { /* Partial or malformed record. */ }
97
+ try {
98
+ const e = JSON.parse(line);
99
+ if (!copied(e)) updateSnapshot(snapshot, e);
100
+ } catch { /* Partial or malformed record. */ }
68
101
  }
69
102
  return snapshot;
70
103
  }
@@ -164,7 +197,8 @@ const emptyTurn = (turnId) => ({ turnId, startedAt: null, endedAt: null, aborted
164
197
  * structured event described, so a call is never counted twice.
165
198
  */
166
199
  export async function parseCodexTurns(filePath, { cutoffMs = -Infinity } = {}) {
167
- const session = { filePath, sessionId: null, projectDir: '', isSubagent: false, parentThreadId: null, provider: null, turns: [] };
200
+ const session = { filePath, sessionId: null, projectDir: '', isSubagent: false, parentThreadId: null, forkedFrom: null, provider: null, turns: [] };
201
+ const copied = forkedCopyFilter();
168
202
  const turns = new Map();
169
203
  const turnOf = (id) => {
170
204
  if (!id) return null;
@@ -184,12 +218,14 @@ export async function parseCodexTurns(filePath, { cutoffMs = -Infinity } = {}) {
184
218
  let e;
185
219
  try { e = JSON.parse(line); } catch { continue; }
186
220
  const p = e?.payload;
187
- if (!p || typeof p !== 'object') continue;
221
+ if (!p || typeof p !== 'object' || copied(e)) continue;
188
222
  if (e.type === 'session_meta') {
223
+ if (session.sessionId) continue;
189
224
  session.sessionId = p.id || p.session_id || null;
190
225
  session.projectDir = typeof p.cwd === 'string' ? p.cwd : '';
191
226
  session.isSubagent = typeof p.source === 'object' && p.source !== null && 'subagent' in p.source;
192
227
  session.parentThreadId = p.source?.subagent?.thread_spawn?.parent_thread_id || null;
228
+ session.forkedFrom = typeof p.forked_from_id === 'string' ? p.forked_from_id : null;
193
229
  session.provider = typeof p.model_provider === 'string' ? p.model_provider : null;
194
230
  continue;
195
231
  }
@@ -303,6 +339,7 @@ export async function parseCodexSessionFile(filePath, { cutoffMs = -Infinity } =
303
339
  lastContextTokens: null, rateLimits: null, lastActivity: null, effort: null,
304
340
  cacheActivity: null,
305
341
  };
342
+ const copied = forkedCopyFilter();
306
343
  let previous = null;
307
344
  const stream = createReadStream(filePath, { encoding: 'utf8' });
308
345
  const lines = createInterface({ input: stream, crlfDelay: Infinity });
@@ -310,7 +347,7 @@ export async function parseCodexSessionFile(filePath, { cutoffMs = -Infinity } =
310
347
  let e;
311
348
  try { e = JSON.parse(line); } catch { continue; }
312
349
  const p = e?.payload;
313
- if (!p) continue;
350
+ if (!p || copied(e)) continue;
314
351
  updateSnapshot(session, e);
315
352
  if (e.type !== 'event_msg' || p.type !== 'token_count') continue;
316
353
  if (!p.info) continue;
@@ -8,7 +8,8 @@ import { discoverCodexSessionFiles, parseCodexTurns } from './codex-parser.js';
8
8
  import { categorize, isSkippable, tierOf, calibrateThresholds, MIN_RECURRENCE,
9
9
  RESCAN_MIN_INTERVAL_MS, RESCAN_BIG_DELTA_BYTES, RESCAN_MAX_AGE_MS } from './route-scan.js';
10
10
  import { codexPromptCategory, loadCodexModelRules } from './codex-delegation.js';
11
- import { codexRunCost, readCodexPrices } from './codex-cache-policy.js';
11
+ import { codexRunCost, codexPriceBook, isDirectOpenAI, readCodexPrices } from './codex-cache-policy.js';
12
+ import { OPENAI_PRICES_CHECKED_AT } from './openai-prices.js';
12
13
  import { refreshCodexLedger } from './codex-ledger.js';
13
14
 
14
15
  /*
@@ -81,11 +82,12 @@ export async function shouldRescanCodex(cache, { home, days = 14, now = Date.now
81
82
  * a rule target, so a gateway that also serves unrelated models (embeddings,
82
83
  * other vendors) never has one suggested blind.
83
84
  */
84
- function pickTarget(group, { prices, known }) {
85
- const cost = (model) => prices?.provider === group.provider ? codexRunCost(group.tokens, prices.prices[model]) : null;
85
+ function pickTarget(group, { prices, known, home }) {
86
+ const book = codexPriceBook(group.provider, prices, { home });
87
+ const cost = (model) => book ? codexRunCost(group.tokens, book.prices[model]) : null;
86
88
  const parent = cost(group.from);
87
89
  if (parent === null) return { suggestedModel: null, alternatives: [], estSavedUsd: null };
88
- const cheaper = Object.keys(prices.prices).filter((m) => m !== group.from && prices.prices[m])
90
+ const cheaper = Object.keys(book.prices).filter((m) => m !== group.from && book.prices[m])
89
91
  .map((m) => ({ model: m, usd: cost(m) })).filter((x) => x.usd !== null && x.usd < parent)
90
92
  .sort((a, b) => a.usd - b.usd);
91
93
  const eligible = cheaper.filter((x) => known.has(`${group.provider}|${x.model}`));
@@ -151,7 +153,11 @@ export async function runCodexRouteScan({ days = 14, now = Date.now(), home, dir
151
153
  (!r.provider || r.provider === g.provider) &&
152
154
  (r.scope === 'global' || r.targetRoot === g.projectRoot));
153
155
  const known = new Set([...observed, ...rules.filter((r) => r.provider).map((r) => `${r.provider}|${r.model}`)]);
154
- if (prices?.provider && cfg?.codex?.delegateTarget?.model) known.add(`${prices.provider}|${cfg.codex.delegateTarget.model}`);
156
+ const target = cfg?.codex?.delegateTarget?.model;
157
+ if (target) {
158
+ if (prices?.provider) known.add(`${prices.provider}|${target}`);
159
+ if (isDirectOpenAI('openai', { home })) known.add(`openai|${target}`);
160
+ }
155
161
  const prev = readCodexRouteScan({ dir });
156
162
  const resolved = new Set(prev?.resolved || []);
157
163
  const candidates = [...groups.values()]
@@ -160,9 +166,10 @@ export async function runCodexRouteScan({ days = 14, now = Date.now(), home, dir
160
166
  .slice(0, MAX_CANDIDATES)
161
167
  .map((g, i) => ({ id: i + 1, signature: g.signature, category: g.category, label: g.label, labelEn: g.labelEn,
162
168
  from: g.from, provider: g.provider, projectRoot: g.projectRoot, count: g.count, tiers: g.tiers, example: g.example.slice(0, 160),
163
- lastSeen: g.lastSeen || null, suggestedScope: 'project', ...pickTarget(g, { prices, known }) }));
169
+ lastSeen: g.lastSeen || null, suggestedScope: 'project', ...pickTarget(g, { prices, known, home }) }));
170
+ const priceCheckedAt = prices?.checkedAt ?? ([...groups.values()].some((g) => isDirectOpenAI(g.provider, { home })) ? OPENAI_PRICES_CHECKED_AT : null);
164
171
  const data = { version: 1, scannedAt: new Date(now).toISOString(), days, dataBytes, totalEpisodes: episodes.length,
165
- thresholds, observedModels: [...observed], priceCheckedAt: prices?.checkedAt ?? null, candidates, resolved: [...resolved] };
172
+ thresholds, observedModels: [...observed], priceCheckedAt, candidates, resolved: [...resolved] };
166
173
  writeCache(data, dir);
167
174
  if (refreshLedger) {
168
175
  try { await refreshCodexLedger({ dir, home, now, prices }); } catch { /* The ledger catches up on the next scan. */ }
@@ -0,0 +1,18 @@
1
+ import { loadCodexLedger } from './codex-ledger.js';
2
+ import { isFailedRun } from './route-scan.js';
3
+ import { wilsonLowerBound, HEALTH_ERR_RATE, HEALTH_MIN_SAMPLE_DELEGATED } from './model-rules.js';
4
+
5
+ /**
6
+ * Measured outcome of one Codex rule, judged as Claude's rule health judges
7
+ * its delegations: a run fails when interrupted or when its tool-error density
8
+ * crosses isFailedRun's line, and the rule goes to review only when the Wilson
9
+ * lower bound of the failure rate clears HEALTH_ERR_RATE over enough runs.
10
+ */
11
+ export function codexRuleHealth(rule, { events = Object.values(loadCodexLedger().events) } = {}) {
12
+ 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));
14
+ const errs = runs.filter((e) => e.aborted || isFailedRun({ toolErrors: e.toolErrors, calls: e.calls })).length;
15
+ const saved = runs.filter((e) => Number.isFinite(e.usd)).reduce((sum, e) => sum + e.usd, 0);
16
+ const review = runs.length >= HEALTH_MIN_SAMPLE_DELEGATED && wilsonLowerBound(errs, runs.length) > HEALTH_ERR_RATE;
17
+ return { runs: runs.length, errs, rate: runs.length ? errs / runs.length : 0, saved, status: review ? 'review' : 'active' };
18
+ }
@@ -2,7 +2,9 @@ import { loadConfig, saveConfig } from '../config.js';
2
2
  import { configureCodexHooks } from '../codex-installer.js';
3
3
  import { validateCodexTarget, loadCodexModelRules, addCodexModelRule, removeCodexModelRule } from '../codex-delegation.js';
4
4
  import { findCodexCandidate, resolveCodexCandidate } from '../codex-route-scan.js';
5
- import { codexRoutingSavedTotals } from '../codex-ledger.js';
5
+ import { codexRoutingSavedTotals, loadCodexLedger } from '../codex-ledger.js';
6
+ import { codexRuleHealth } from '../codex-rule-health.js';
7
+ import { signedUsd } from '../money.js';
6
8
  import { codexProviderName } from '../agent.js';
7
9
 
8
10
  export function run({ args, getArg, hasFlag, root }) {
@@ -31,7 +33,14 @@ export function run({ args, getArg, hasFlag, root }) {
31
33
  else if (args[2]) throw new Error('Usage: delegate rules [add <category|R<N>> --from <model> --model <model> --global|--project | rm <N>]');
32
34
  const rules = loadCodexModelRules();
33
35
  if (!rules.length) console.log('No Codex model rules. Claude rules are not imported.');
34
- rules.forEach((r, i) => console.log(`#${i + 1} ${r.category} | ${r.from} -> ${r.model} | ${r.scope}${r.targetRoot ? ` ${r.targetRoot}` : ''}`));
36
+ let events = [];
37
+ try { events = Object.values(loadCodexLedger().events); } catch { /* No ledger yet: rules list without outcomes. */ }
38
+ rules.forEach((r, i) => {
39
+ const h = codexRuleHealth(r, { events });
40
+ const outcome = h.runs ? ` | measured x${h.runs}, err ${Math.round(h.rate * 100)}%, saved ${signedUsd(h.saved, 4)}` : '';
41
+ const warn = h.status === 'review' ? ` | rule-health: ${h.errs}/${h.runs} runs failed, narrow the condition or remove (delegate rules rm ${i + 1} --agent codex)` : '';
42
+ console.log(`#${i + 1} ${r.category} | ${r.from} -> ${r.model} | ${r.scope}${r.targetRoot ? ` ${r.targetRoot}` : ''}${outcome}${warn}`);
43
+ });
35
44
  return;
36
45
  }
37
46
  if (!['on', 'off', 'status', 'model'].includes(sub)) throw new Error('Usage: delegate on|off|status|model <model>|rules --agent codex');
@@ -54,6 +63,6 @@ export function run({ args, getArg, hasFlag, root }) {
54
63
  console.log('Explicit spawn models and custom roles are preserved. Targets must be available from your Codex provider.');
55
64
  const saved = codexRoutingSavedTotals();
56
65
  console.log(saved.priced
57
- ? `Routing saved: $${saved.total.toFixed(4)} over ${saved.priced} priced run(s). Details: sprag route-scan savings --agent codex`
66
+ ? `Routing saved: ${saved.total < 0 ? '-' : ''}$${Math.abs(saved.total).toFixed(4)} over ${saved.priced} priced run(s). Details: sprag route-scan savings --agent codex`
58
67
  : `Routing saved: n/a (${saved.runs ? `${saved.runs} attributed run(s), none priced` : 'no attributed runs yet'}). Review/trust the Sprag hooks in Codex /hooks.`);
59
68
  }
@@ -4,6 +4,8 @@ import { initCodexHarness, uninitCodexHarness, codexHarnessStatus } from '../cod
4
4
  import { findProjectRoot, harnessPromote, harnessListRules, harnessRmRule, harnessPrune } from '../harness.js';
5
5
  import { loadConfig, saveConfig } from '../config.js';
6
6
  import { resolve } from 'node:path';
7
+ import { debug } from '../debug.js';
8
+ import { signedUsd } from '../money.js';
7
9
 
8
10
  const HELP = `Sprag / Codex
9
11
 
@@ -19,7 +21,7 @@ Usage: sprag <command> --agent codex [options]
19
21
  doctor [--format json] Config, hooks, harness, logs, and capabilities
20
22
  capabilities [--format json] Support matrix, including native/unsupported areas
21
23
  brief [on|off|status] Current warnings or prompt-briefing preference
22
- last / history Codex warning history; --days, --session, --project
24
+ last / history Context/rate-limit warnings and handoffs; --days, --session, --project
23
25
  handoff [--cwd path] Write Git state and Codex usage to HANDOFF-*.md
24
26
  delegate on|off|status Native model routing and SubagentStart guidance
25
27
  delegate model <model> Default target for spawns without an explicit model
@@ -155,7 +157,7 @@ export async function run({ args, version }) {
155
157
  const money = (usd) => Number.isFinite(usd) ? `${usd < 0 ? '-' : ''}$${Math.abs(usd).toFixed(4)}` : 'n/a (unpriced)';
156
158
  console.log(`Codex routing saved: ${totals.priced ? `${money(totals.week)} (7d) / ${money(totals.total)} total` : 'n/a'} | ${totals.runs} attributed run(s), ${totals.runs - totals.priced} unpriced`);
157
159
  for (const e of events.slice(0, 20)) {
158
- console.log(`${new Date(e.ts).toISOString().slice(0, 16)} ${e.source}${e.category ? `/${e.category}` : ''} ${e.from} -> ${e.to} ${money(e.usd)}${e.complete ? '' : ' (incomplete)'}`);
160
+ console.log(`${new Date(e.ts).toISOString().slice(0, 16)} ${e.source}${e.category ? `/${e.category}` : ''} ${e.from} -> ${e.to} ${money(e.usd)}${e.aborted ? ' (interrupted: whole cost counted as a loss)' : e.complete ? '' : ' (incomplete)'}`);
159
161
  }
160
162
  console.log('Counterfactual: the child run\'s own tokens priced at the parent model. Only Sprag-routed spawns with a recorded route id count.');
161
163
  return;
@@ -172,7 +174,7 @@ export async function run({ args, version }) {
172
174
  if (hasFlag('--refresh') || !cache) {
173
175
  // Prices live in the gateway snapshot; refresh it when stale so candidates can name a target.
174
176
  const { readCodexPrices, refreshCodexPrices } = await import('../codex-cache-policy.js');
175
- if (!readCodexPrices()) { try { await refreshCodexPrices(); } catch { /* Candidates still list; targets need --model. */ } }
177
+ if (!readCodexPrices()) { try { await refreshCodexPrices(); } catch (e) { debug('codex:prices', e); } }
176
178
  cache = await rs.runCodexRouteScan({ days });
177
179
  }
178
180
  if (hasFlag('--quiet')) return;
@@ -181,7 +183,7 @@ export async function run({ args, version }) {
181
183
  console.log(`Codex route-scan: ${cache.totalEpisodes} turns in ${cache.days} days, scanned ${cache.scannedAt}${cache.priceCheckedAt ? '' : ' | prices unavailable'}`);
182
184
  if (!open.length) console.log('No recurring delegable pattern. Needs 3+ similar simple turns per project and parent model.');
183
185
  for (const c of open) {
184
- const target = c.suggestedModel ? `-> ${c.suggestedModel}${Number.isFinite(c.estSavedUsd) ? ` (~$${c.estSavedUsd.toFixed(4)} over ${c.count} turns)` : ''}`
186
+ const target = c.suggestedModel ? `-> ${c.suggestedModel}${Number.isFinite(c.estSavedUsd) ? ` (~${signedUsd(c.estSavedUsd, 4)} over ${c.count} turns)` : ''}`
185
187
  : `-> choose --model${c.alternatives.length ? ` (priced cheaper: ${c.alternatives.join(', ')})` : ''}`;
186
188
  console.log(`R${c.id} ${c.category} x${c.count} [T2 ${c.tiers.T2} / T1 ${c.tiers.T1}] ${c.provider || 'unknown provider'} / ${c.from} ${target}`);
187
189
  console.log(` ${c.projectRoot}`);
@@ -225,7 +227,7 @@ export async function run({ args, version }) {
225
227
  sessionId: getArg('--session'), project: getArg('--project') || getArg('-p') });
226
228
  if (cmd === 'last') events = events.slice(-1);
227
229
  if (format === 'json') { console.log(JSON.stringify({ agent: 'codex', events }, null, 2)); return; }
228
- if (!events.length) console.log('No Codex warning history in this window. Records are written by the trusted prompt hook.');
230
+ if (!events.length) console.log('No Codex warning history in this window. The trusted prompt hook records context and rate-limit warnings; handoffs are recorded when written.');
229
231
  else if (hasFlag('--list')) console.log([...new Set(events.map((event) => event.at.slice(0, 10)))].reverse().join('\n'));
230
232
  else for (const event of events) {
231
233
  console.log(terminalText(`${event.at} [${event.sessionId}] ${event.message}`));
@@ -239,6 +241,11 @@ export async function run({ args, version }) {
239
241
  const cwd = getArg('--cwd') || process.cwd();
240
242
  const data = await createPanelReader({ root: findProjectRoot(resolve(cwd), { agent: 'codex' }), sessionId: getArg('--session'), days: numArg('--days', { dflt: 7, min: 0 }) })();
241
243
  const result = writeHandoff({ cwd, agent: 'codex', session: data.session });
244
+ try {
245
+ const { recordCodexHistoryEvent } = await import('../codex-brief.js');
246
+ recordCodexHistoryEvent({ sessionId: data.session?.sessionId, project: cwd, key: 'handoff',
247
+ message: `Handoff written: ${result.path}` });
248
+ } catch (e) { debug('codex:handoff-record', e); }
242
249
  console.log(`Handoff written: ${result.path}`);
243
250
  console.log('Fill in the remaining-work sections, then ask the next Codex session to read this file.');
244
251
  return;
@@ -272,6 +279,11 @@ export async function run({ args, version }) {
272
279
  if (!['install', 'remove'].includes(args[2])) throw new Error('Usage: sprag panel shell install|remove --agent codex');
273
280
  const { installPanelShell } = await import('../codex-panel-shell.js');
274
281
  const file = installPanelShell({ remove: args[2] === 'remove' });
282
+ // install adds the integration by default, so a removal must be
283
+ // remembered or the next upgrade puts it back.
284
+ const cfg = loadConfig();
285
+ cfg.codex = { ...cfg.codex, panelShell: args[2] === 'install' };
286
+ saveConfig(cfg);
275
287
  console.log(`Shell integration ${args[2]}: ${file}. Open a new terminal to apply it.`);
276
288
  return;
277
289
  }
@@ -319,7 +331,7 @@ export async function run({ args, version }) {
319
331
  })() : { read: createCodexBudgetReader() };
320
332
  if (hasFlag('--once')) {
321
333
  const provider = codexBudgetProvider();
322
- if (provider) { try { readBudget.set(await fetchCodexBudget(provider)); } catch { /* No verified budget. */ } }
334
+ if (provider) { try { readBudget.set(await fetchCodexBudget(provider)); } catch (e) { debug('codex:budget', e); } }
323
335
  }
324
336
  const { createInlinePanelResizer } = await import('../codex-panel-runner.js');
325
337
  const rawInterval = getArg('--interval');
@@ -335,7 +347,7 @@ export async function run({ args, version }) {
335
347
  readCachePolicy: hasFlag('--once') ? async (session) => {
336
348
  const { readCodexCachePolicy } = await import('../codex-cache-policy.js');
337
349
  try { return readCodexCachePolicy(session) || await refreshCodexCachePolicy(session); }
338
- catch { return null; }
350
+ catch (e) { debug('codex:cache-policy', e); return null; }
339
351
  } : createCodexCachePolicyReader(), hasFlag }),
340
352
  interval: interval * 1000, once: hasFlag('--once'),
341
353
  color: !hasFlag('--no-color') && !process.env.NO_COLOR && !!process.stdout.isTTY, version, compact: hasFlag('--compact'),
@@ -372,22 +384,30 @@ export async function run({ args, version }) {
372
384
  }
373
385
  const out = await codexHookOutput(getArg('--event'), payload);
374
386
  if (out) console.log(JSON.stringify(out));
375
- } catch { /* Hook failures must not break the agent loop. */ }
387
+ } catch (e) { debug('codex:hook', e); /* Hook failures must not break the agent loop. */ }
376
388
  return;
377
389
  }
378
390
  if (cmd === 'install') {
379
391
  const cfg = loadConfig();
380
- const panelAuto = process.platform === 'darwin' && !hasFlag('--no-panel') && cfg?.codex?.panelAuto !== false;
392
+ const { setupCodexPanel, describePanelSetup } = await import('../codex-panel-setup.js');
393
+ const panel = setupCodexPanel({ config: cfg || {}, noPanel: hasFlag('--no-panel') });
394
+ const panelAuto = panel.panelAuto;
381
395
  const hook = configureCodexHooks({ panelAuto });
382
396
  console.log(`Codex hooks: ${hook.file} (${hook.action})`);
397
+ for (const line of describePanelSetup(panel)) console.log(`Codex ${line}`);
383
398
  if (process.env.CTS_NO_HARNESS !== '1') {
384
399
  const h = initCodexHarness({ root, scope: 'global' });
385
400
  console.log(`Codex harness: ${h.file}`);
386
401
  }
402
+ try {
403
+ const { preparePresetRatchet } = await import('../preset-ratchet.js');
404
+ const p = preparePresetRatchet({ agent: 'codex' });
405
+ console.log(`Codex preset rules: ${p.file} (${p.rules} registered${p.moved ? `, ${p.moved} moved from ratchet.md` : ''})`);
406
+ } catch (e) { debug('codex:preset', e); /* Presets are optional; `seed` still registers them. */ }
387
407
  console.log('Open /hooks in Codex and review/trust the Sprag hooks before they can run.');
388
408
  console.log(`Panel auto-start: ${panelAuto ? 'registered with the other hooks' : 'off'}. Hook registration is complete; runtime activation is not verified.`);
389
409
  console.log('After approval, start or resume Codex and run: sprag panel doctor --agent codex');
390
- if (panelAuto) console.log('Some Codex versions defer SessionStart until the first prompt. For immediate opening on typing codex: sprag panel shell install --agent codex (optional zsh integration).');
410
+ if (panel.mode === 'window') console.log('Some Codex versions defer SessionStart until the first prompt. For immediate opening on typing codex: sprag panel shell install --agent codex (optional zsh integration).');
391
411
  console.log('Existing config.toml, Claude Code settings, and ratchet rules are preserved.');
392
412
  console.log('Korean guidance is opt-in: sprag korean on --agent codex');
393
413
  return;
@@ -444,6 +464,15 @@ export async function run({ args, version }) {
444
464
  const r = harnessListRules(opts);
445
465
  console.log(`Codex ratchet [${scope}]: ${r.path}`);
446
466
  for (const rule of r.rules) console.log(`#${rule.index} ${rule.text}`);
467
+ if (scope === 'global') {
468
+ const { acceptedGlobalPresets, presetRatchetPath } = await import('../preset-ratchet.js');
469
+ const presets = acceptedGlobalPresets({ agent: 'codex' });
470
+ if (presets.length) {
471
+ console.log(`Codex preset rules [global]: ${presetRatchetPath({ agent: 'codex' })}`);
472
+ for (const p of presets) console.log(`${p.id} ${p.text}`);
473
+ console.log('Remove a preset with: sprag seed skip <id> --agent codex');
474
+ }
475
+ }
447
476
  } else if (sub === 'rm') {
448
477
  const raw = args[2];
449
478
  // Locate the index even when scope flags precede it.
@@ -486,7 +515,8 @@ export async function run({ args, version }) {
486
515
  console.log(`Cache: ${doc2md.cacheDir()}`);
487
516
  try {
488
517
  for (const hook of codexHookStatus().filter((h) => ['PreToolUse', 'UserPromptSubmit'].includes(h.event))) console.log(`${hook.event}: ${hook.registered ? 'registered' : 'missing/outdated'}`);
489
- } catch {
518
+ } catch (e) {
519
+ debug('codex:hook-status', e);
490
520
  console.log('Hooks: cannot read Codex hooks.json; file left unchanged. Run sprag doctor --agent codex for diagnostics.');
491
521
  }
492
522
  console.log('Prompt conversion, supported reads, and document/cache write guards require trusted hooks. Review in Codex /hooks.');
@@ -337,7 +337,7 @@ export async function run({ args, hasFlag }) {
337
337
  // rules, and a project ratchet inherits global anyway). Strictly opt-in:
338
338
  // install/init never auto-injects rules.
339
339
  const scope = parseHarnessScope(args.slice(2), 'global');
340
- const r = harnessPull({ scope });
340
+ const r = await harnessPull({ scope });
341
341
  console.log(`Curated preset rules → ${r.path} [${r.scope}]`);
342
342
  if (r.added.length) {
343
343
  console.log(`✅ ${r.added.length}/${r.presets} rule(s) registered:`);
@@ -366,7 +366,18 @@ export async function run({ args, hasFlag }) {
366
366
  console.log('');
367
367
  };
368
368
  if (wantProject) print('project');
369
- if (wantGlobal) print('global');
369
+ if (wantGlobal) {
370
+ print('global');
371
+ // Presets live in their own tool-owned file and are removed by id,
372
+ // so they are listed apart from the numbered rules `rm <N>` acts on.
373
+ const { acceptedGlobalPresets, presetRatchetPath } = await import('../preset-ratchet.js');
374
+ const presets = acceptedGlobalPresets();
375
+ if (presets.length) {
376
+ console.log(`📦 Preset rules [global] — ${presetRatchetPath()}\n`);
377
+ for (const p of presets) console.log(` ${p.id} ${p.text}`);
378
+ console.log('\nRemove a preset with: sprag seed skip <id>\n');
379
+ }
380
+ }
370
381
  console.log('Remove with: sprag harness rm [--global|--project] <N>');
371
382
  console.log('Archive in bulk: sprag harness prune [--global] [--tag <t>] [--older-than <months>] [--dry-run]');
372
383
  return;