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,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
@@ -77,6 +110,26 @@ export function runCodexBrief({ sessionId, transcriptPath, cwd, now = Date.now()
77
110
  return next.length ? ['[Sprag Codex briefing]', ...next.map((issue) => `${issue.message} ${issue.advice}`)].join('\n') : null;
78
111
  }
79
112
 
113
+ /**
114
+ * Append one event outside the warning dedup, such as a handoff the user
115
+ * wrote. Claude's history records handoffs too, so both agents' timelines
116
+ * show when work was preserved.
117
+ */
118
+ export function recordCodexHistoryEvent({ sessionId, project = '', key, level = 'info', message, advice = '', now = Date.now(), dir = userDataDir() } = {}) {
119
+ if (typeof message !== 'string' || !message) return false;
120
+ const owner = typeof sessionId === 'string' && sessionId ? sessionId : 'no-session';
121
+ const directory = stateDir(dir);
122
+ const file = join(directory, `${hash(owner)}.json`);
123
+ const state = readState(file);
124
+ const events = [...state.events, { key, tier: 0, signature: key, level, message, advice,
125
+ at: new Date(now).toISOString(), sessionId: owner, project }].slice(-200);
126
+ mkdirSync(directory, { recursive: true });
127
+ const tmp = `${file}.${process.pid}.tmp`;
128
+ writeFileSync(tmp, JSON.stringify({ ...state, events }) + '\n', { mode: 0o600 });
129
+ renameSync(tmp, file);
130
+ return true;
131
+ }
132
+
80
133
  export function readCodexHistory({ days = 7, sessionId, project, dir = userDataDir(), now = Date.now() } = {}) {
81
134
  const directory = stateDir(dir);
82
135
  let files;
@@ -6,6 +6,10 @@ import { codexUserDir } from './agent.js';
6
6
  import { userDataDir } from './paths.js';
7
7
  import { codexBudgetProvider, codexProviderKey } from './codex-budget.js';
8
8
  import { codexCachePolicy } from './codex-cache.js';
9
+ import { PRICE_MAX_AGE_MS, gatewayPrices } from './gateway-prices.js';
10
+ import { OPENAI_LIST_PRICES, OPENAI_PRICES_CHECKED_AT } from './openai-prices.js';
11
+
12
+ export { PRICE_MAX_AGE_MS, rate, deploymentPrice, gatewayPrices, codexRunCost } from './gateway-prices.js';
9
13
 
10
14
  export const CACHE_POLICY_MAX_AGE_MS = 300000;
11
15
  const OPENAI_POLICY = 'https://developers.openai.com/api/docs/guides/prompt-caching#cache-lifetime';
@@ -51,42 +55,6 @@ export function gatewayCachePolicies(payload) {
51
55
  policies[0] && policies.every((p) => JSON.stringify(p) === JSON.stringify(policies[0])) ? policies[0] : null]));
52
56
  }
53
57
 
54
- export const PRICE_MAX_AGE_MS = 7 * 86400000;
55
- const rate = (value) => Number.isFinite(value) && value >= 0 && value < 1 ? value : null;
56
-
57
- function deploymentPrice(row) {
58
- const info = row?.model_info || {};
59
- const mode = typeof info.mode === 'string' ? info.mode : null;
60
- if (mode && !['chat', 'responses', 'completion'].includes(mode)) return null;
61
- const input = rate(info.input_cost_per_token), output = rate(info.output_cost_per_token);
62
- if (input === null || output === null) return null;
63
- return { input, output, cacheRead: rate(info.cache_read_input_token_cost), cacheWrite: rate(info.cache_creation_input_token_cost) };
64
- }
65
-
66
- /** Per-token prices by alias. Load-balanced deployments must agree, like cache policy. */
67
- export function gatewayPrices(payload) {
68
- if (!Array.isArray(payload?.data)) throw new Error('LiteLLM model/info returned no model list');
69
- const groups = new Map();
70
- for (const row of payload.data) {
71
- if (typeof row?.model_name !== 'string' || row.model_name.length > 256) continue;
72
- groups.set(row.model_name, [...(groups.get(row.model_name) || []), deploymentPrice(row)]);
73
- }
74
- return Object.fromEntries([...groups].map(([name, prices]) => [name,
75
- prices[0] && prices.every((p) => JSON.stringify(p) === JSON.stringify(prices[0])) ? prices[0] : null]));
76
- }
77
-
78
- /**
79
- * USD for a token mix at one model's prices, or null when a needed rate is
80
- * missing. Cached input without a cache-read rate is unknown, not free.
81
- */
82
- export function codexRunCost({ input = 0, cached = 0, cacheWrite = 0, output = 0 }, price) {
83
- if (!price || price.input === null || price.output === null) return null;
84
- if (cached > 0 && price.cacheRead === null) return null;
85
- // cache_write_input_tokens is billed at the creation rate when one is published, else as input.
86
- const uncached = Math.max(0, input - cached - cacheWrite);
87
- return uncached * price.input + cached * (price.cacheRead ?? 0) + cacheWrite * (price.cacheWrite ?? price.input) + output * price.output;
88
- }
89
-
90
58
  function cacheFile(provider, { home = codexUserDir(), dir = userDataDir() } = {}) {
91
59
  const key = createHash('sha256').update(JSON.stringify([resolve(home), provider.name, provider.base])).digest('hex').slice(0, 24);
92
60
  return join(dir, 'codex-cache-policy', `${key}.json`);
@@ -97,14 +65,31 @@ function gatewayProvider(session, opts) {
97
65
  return codexBudgetProvider({ ...opts, providerName: session.provider });
98
66
  }
99
67
 
100
- function directPolicy(session, { home = codexUserDir(), now = Date.now() } = {}) {
101
- if (session?.provider !== 'openai' || !codexCachePolicy(session.model)) return null;
68
+ /** True when Codex's OpenAI provider talks to api.openai.com itself, not to a proxy. No config = direct. */
69
+ export function isDirectOpenAI(provider, { home = codexUserDir() } = {}) {
70
+ if (provider !== 'openai') return false;
102
71
  try {
103
72
  let cfg = {};
104
73
  try { cfg = parse(readFileSync(join(home, 'config.toml'), 'utf8')); }
105
- catch (e) { if (e.code !== 'ENOENT') return null; }
106
- if (cfg.openai_base_url && new URL(cfg.openai_base_url).hostname !== 'api.openai.com') return null;
107
- } catch { return null; }
74
+ catch (e) { if (e.code !== 'ENOENT') return false; }
75
+ if (cfg.openai_base_url && new URL(cfg.openai_base_url).hostname !== 'api.openai.com') return false;
76
+ } catch { return false; }
77
+ return true;
78
+ }
79
+
80
+ /**
81
+ * The price table a Codex provider's calls are billed by: OpenAI list prices for
82
+ * direct calls, the gateway's own /model/info prices for a LiteLLM provider.
83
+ * Never a fallback chain, so a model the gateway does not price stays unpriced.
84
+ */
85
+ export function codexPriceBook(provider, litellm, opts = {}) {
86
+ if (isDirectOpenAI(provider, opts)) return { source: 'list', provider: 'openai', checkedAt: OPENAI_PRICES_CHECKED_AT, prices: OPENAI_LIST_PRICES };
87
+ if (litellm?.provider === provider) return { ...litellm, source: 'litellm' };
88
+ return null;
89
+ }
90
+
91
+ function directPolicy(session, { home = codexUserDir(), now = Date.now() } = {}) {
92
+ if (!codexCachePolicy(session?.model) || !isDirectOpenAI(session?.provider, { home })) return null;
108
93
  return { backend: 'OpenAI', model: session.model, seconds: codexCachePolicy(session.model),
109
94
  requestedModel: session.model, provider: session.provider, checkedAt: now, reference: OPENAI_POLICY };
110
95
  }
@@ -7,6 +7,7 @@ import { codexUserDir, codexProviderName } from './agent.js';
7
7
  import { categorize, ESCALATE_RE, EDIT_RE } from './route-scan.js';
8
8
  import { looksPasted } from './route-inject.js';
9
9
  import { newRouteId, recordCodexDelegation } from './codex-ledger.js';
10
+ import { readCodexTailLines } from './codex-parser.js';
10
11
 
11
12
  export const CODEX_ROUTE_CATEGORIES = ['paste', 'translate', 'explore', 'read', 'check', 'run'];
12
13
  const MARKER = '<!-- sprag:codex:delegation -->';
@@ -53,7 +54,10 @@ export function addCodexModelRule({ category, model, effort, from, scope, root,
53
54
  const rules = loadCodexModelRules({ dir });
54
55
  const targetRoot = scope === 'project' ? resolve(root) : null;
55
56
  const rule = { category, model, effort, from, scope, targetRoot, ...(provider ? { provider } : {}), status: 'active', createdAt: new Date().toISOString() };
56
- 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));
57
61
  if (old < 0) rules.push(rule); else rules[old] = rule;
58
62
  saveRules(rules, dir);
59
63
  return rule;
@@ -88,13 +92,51 @@ export function matchCodexModelRule(text, { model, root, provider = codexProvide
88
92
  return usable.find((r) => r.scope === 'project') || usable[0] || null;
89
93
  }
90
94
 
95
+ /**
96
+ * Input tokens from the rollout's last token_count record, or 0 when the
97
+ * transcript is missing or unreadable. Only the tail is read: the newest
98
+ * measurement is always near the end of the file.
99
+ */
100
+ export function codexContextTokens(transcriptPath) {
101
+ const lines = readCodexTailLines(transcriptPath, 512 * 1024);
102
+ if (!lines) return 0;
103
+ for (let i = lines.length - 1; i >= 0; i--) {
104
+ const line = lines[i];
105
+ if (!line || !line.includes('token_count')) continue;
106
+ let e;
107
+ try { e = JSON.parse(line); } catch { continue; }
108
+ const n = e?.payload?.type === 'token_count' ? e.payload?.info?.last_token_usage?.input_tokens : undefined;
109
+ if (Number.isFinite(n) && n >= 0) return n;
110
+ }
111
+ return 0;
112
+ }
113
+
114
+ /**
115
+ * A prompt-time routing suggestion for Codex 0.159.2, where `spawn_agent`
116
+ * never reaches PreToolUse (see codexDelegationTool below). Gated on parent
117
+ * context size: a small parent's own coordination overhead outweighs the
118
+ * savings from a delegated child (measured 2026-09-30, see docs/CODEX.md).
119
+ */
91
120
  export function codexRouteHint(payload, opts = {}) {
92
- const rule = matchCodexModelRule(payload?.prompt, { provider: payload?.model_provider || codexProviderName(), ...opts, model: payload?.model, root: opts.root || payload?.cwd });
121
+ const provider = payload?.model_provider || codexProviderName();
122
+ const rule = matchCodexModelRule(payload?.prompt, { provider, ...opts, model: payload?.model, root: opts.root || payload?.cwd });
93
123
  if (!rule) return null;
94
- return `[Sprag model routing] This request matches the approved ${rule.category} rule: delegate the bounded task to model ${rule.model}`
95
- + (rule.effort ? ` with reasoning_effort ${rule.effort}` : '')
96
- + '. Use the available native Codex subagent tool and a built-in role. Wait for its result, verify it, and report unfinished work. '
97
- + 'Do not spawn another CLI process. If subagent tools or the target model are unavailable, keep the task on the main agent and state that delegation was unavailable.';
124
+ const minContext = opts.minContext ?? 60000;
125
+ const ctx = opts.contextTokens ?? codexContextTokens(payload?.transcript_path);
126
+ if (ctx < minContext) return null;
127
+ const id = newRouteId();
128
+ const record = opts.record ?? recordCodexDelegation;
129
+ try {
130
+ record({ id, parentSessionId: payload?.session_id ?? null, turnId: payload?.turn_id ?? null, via: 'prompt',
131
+ source: 'rule', category: rule.category, scope: rule.scope, from: payload?.model, to: rule.model,
132
+ provider, effort: rule.effort ?? null, contextTokens: ctx,
133
+ targetRoot: rule.targetRoot ?? null, ruleCreatedAt: rule.createdAt ?? null }, { dir: opts.dir });
134
+ } catch { /* Accounting is optional; the hint still applies. */ }
135
+ return `[Sprag model routing] This request matches the approved ${rule.category} rule (route ${id}). `
136
+ + `Spawn one sub-agent with model ${rule.model}${rule.effort ? `, reasoning_effort ${rule.effort}` : ''} and fork_turns "none". `
137
+ + `Give it a self-contained task message that ends with the line <!-- sprag:codex:route id=${id} -->. `
138
+ + 'Wait for it to finish (call wait_agent with timeout_ms of at least 120000, and again if it times out) and do not do the delegated task yourself while it runs; then verify its result and report unfinished work. Do not spawn another CLI process. '
139
+ + 'If sub-agent tools or the target model are unavailable, keep the task on the main agent and state that delegation was unavailable.';
98
140
  }
99
141
 
100
142
  function customRole(role, root, home) {
@@ -120,7 +162,9 @@ function customRole(role, root, home) {
120
162
  return false;
121
163
  }
122
164
 
123
- export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd || process.cwd(), home = codexUserDir(), rules, dir = userDataDir(), record = recordCodexDelegation } = {}) {
165
+ // Kept for Codex versions that do send spawn_agent through PreToolUse; on
166
+ // 0.159.2 the hook never fires for it, so codexRouteHint above is the live path.
167
+ export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd || process.cwd(), home = codexUserDir(), rules: given, dir = userDataDir(), record = recordCodexDelegation } = {}) {
124
168
  if (cfg.codex?.delegate !== true) return null;
125
169
  const tool = payload?.tool_name?.replace(/^(?:functions|tools)\./, '');
126
170
  if (!['spawn_agent', 'Agent'].includes(tool)) return null;
@@ -129,6 +173,9 @@ export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd ||
129
173
  const role = input.agent_type || 'default';
130
174
  if (!['default', 'worker', 'explorer'].includes(role) || customRole(role, payload.cwd || root, home)) return null;
131
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 = []; } }
132
179
  const provider = payload.model_provider || codexProviderName({ home });
133
180
  const rule = explicit ? null : matchCodexModelRule(input.message, { model: payload.model, provider, root, rules });
134
181
  const target = rule || (!explicit && cfg.codex?.delegateTarget);
@@ -144,7 +191,8 @@ export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd ||
144
191
  try {
145
192
  record({ id, parentSessionId: typeof payload.session_id === 'string' ? payload.session_id : null,
146
193
  source: rule ? 'rule' : 'default', category: rule?.category ?? null, scope: rule?.scope ?? null,
147
- 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 });
148
196
  routeLine = `\n<!-- sprag:codex:route id=${id} -->`;
149
197
  } catch { /* Accounting is optional; the route itself still applies. */ }
150
198
  }
@@ -12,7 +12,7 @@ export const CODEX_CAPABILITIES = [
12
12
  { feature: 'reports', support: 'supported', command: 'sprag --agent codex --format json', note: 'Token usage, cached input, reasoning, session and project filters.' },
13
13
  { feature: 'harness', support: 'supported', command: 'sprag harness check --agent codex', note: 'AGENTS.md, overrides, explicit-scope ratchet rule management.' },
14
14
  { feature: 'brief', support: 'supported', command: 'sprag brief --agent codex', note: 'Fresh context and limit warnings; change-triggered prompt hook.' },
15
- { feature: 'history', support: 'supported', command: 'sprag history --agent codex', note: 'Codex-only session warning records; last shows the latest warning.' },
15
+ { feature: 'history', support: 'supported', command: 'sprag history --agent codex', note: 'Codex-only records of context (80%/95%) and 5h/weekly rate-limit (90%) warnings, plus handoffs. Cache and TTL chips are not recorded; last shows the latest event.' },
16
16
  { feature: 'handoff', support: 'supported', command: 'sprag handoff --agent codex', note: 'Git state and recorded Codex session usage; never reads Claude caps.' },
17
17
  { feature: 'korean', support: 'supported', command: 'sprag korean status --agent codex', note: 'Session guidance and recognized write-target lint; shared preference.' },
18
18
  { feature: 'cohesion', support: 'supported', command: 'sprag cohesion status --agent codex', note: 'Shared English guidance preference.' },
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { codexUserDir } from './agent.js';
5
- import { codexToolEvents, codexLegacyToolEvent, readCodexTailLines } from './codex-parser.js';
5
+ import { codexToolEvents, codexLegacyToolEvent, readCodexTailLines, forkedCopyFilter } from './codex-parser.js';
6
6
 
7
7
  export const CODEX_BEGIN = '<!-- sprag:codex:harness:begin -->';
8
8
  export const CODEX_END = '<!-- sprag:codex:harness:end -->';
@@ -14,16 +14,24 @@ 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
- 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.\n${CODEX_END}\n`;
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
  }
28
36
 
29
37
  function range(content) {
@@ -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' }) {
@@ -133,11 +167,8 @@ function countable(ev) {
133
167
  return true;
134
168
  }
135
169
 
136
- /**
137
- * Repeated failures in a rollout's recent turns, newest-first by count. Reads
138
- * only the file tail, which is where the current session's recent work is.
139
- */
140
- export function codexRatchetCandidates(filePath, { now = Date.now(), turns: window = 30, ttlMs = 30 * 60000, tailBytes = 2 * 1024 * 1024, home } = {}) {
170
+ /** Countable failures in one rollout's last `window` turns. Reads only the file tail. */
171
+ function rolloutFailures(filePath, { window, tailBytes }) {
141
172
  const lines = readCodexTailLines(filePath, tailBytes);
142
173
  if (!lines) return [];
143
174
  const order = [];
@@ -147,11 +178,14 @@ export function codexRatchetCandidates(filePath, { now = Date.now(), turns: wind
147
178
  const outputs = [];
148
179
  let cwd = null;
149
180
  let current = null;
181
+ const copied = forkedCopyFilter();
150
182
  for (const line of lines) {
151
183
  // Cheap prefilter: these candidate lines are a small share of a rollout.
152
- if (!/"(?:item_completed|exec_command_end|patch_apply_end|task_started|turn_context|session_meta|function_call|function_call_output|custom_tool_call|custom_tool_call_output)"/.test(line)) continue;
184
+ if (!/"(?:item_completed|exec_command_end|patch_apply_end|task_started|turn_context|session_meta|thread_settings_applied|function_call|function_call_output|custom_tool_call|custom_tool_call_output)"/.test(line)) continue;
153
185
  let e;
154
186
  try { e = JSON.parse(line); } catch { continue; }
187
+ // A forked child's copy of the parent history holds the parent's failures, not its own.
188
+ if (copied(e)) continue;
155
189
  const p = e?.payload;
156
190
  if (e?.type === 'session_meta' && typeof p?.cwd === 'string') cwd ??= p.cwd;
157
191
  if (e?.type === 'turn_context' && typeof p?.cwd === 'string') cwd = p.cwd;
@@ -178,9 +212,20 @@ export function codexRatchetCandidates(filePath, { now = Date.now(), turns: wind
178
212
  if (countable(ev)) failures.push(ev);
179
213
  }
180
214
  const recent = new Set(order.slice(-window));
215
+ return failures.filter((ev) => recent.has(ev.turn));
216
+ }
217
+
218
+ /**
219
+ * Repeated failures in recent turns, newest-first by count. `siblings` are
220
+ * other rollouts of the same project: every `codex exec` run writes its own
221
+ * file, so a mistake repeated across runs only shows up when they are read
222
+ * together. The TTL applies to all of them.
223
+ */
224
+ export function codexRatchetCandidates(filePath, { now = Date.now(), turns: window = 30, ttlMs = 30 * 60000, tailBytes = 2 * 1024 * 1024, home, siblings = [] } = {}) {
225
+ const failures = [filePath, ...siblings].filter(Boolean).flatMap((file) => rolloutFailures(file, { window, tailBytes }));
181
226
  const groups = new Map();
182
227
  for (const ev of failures) {
183
- if (!recent.has(ev.turn) || !Number.isFinite(ev.at) || now - ev.at > ttlMs) continue;
228
+ if (!Number.isFinite(ev.at) || now - ev.at > ttlMs) continue;
184
229
  const pattern = codexErrorSignature(ev, { cwd: ev.cwd, home });
185
230
  const g = groups.get(pattern) || { pattern, count: 0, lastAt: 0 };
186
231
  g.count++;