sprag-cli 3.59.0 → 3.61.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 (45) hide show
  1. package/README.ko.md +17 -0
  2. package/README.md +23 -0
  3. package/bin/cli.js +51 -3
  4. package/docs/CODEX.ko.md +69 -6
  5. package/docs/CODEX.md +84 -12
  6. package/package.json +1 -1
  7. package/src/agent.js +9 -0
  8. package/src/caps-cache.js +3 -3
  9. package/src/codex-brief.js +4 -5
  10. package/src/codex-cache-policy.js +3 -3
  11. package/src/codex-delegation.js +114 -15
  12. package/src/codex-doc2md-ledger.js +3 -3
  13. package/src/codex-doctor.js +2 -2
  14. package/src/codex-hooks.js +4 -5
  15. package/src/codex-installer.js +3 -3
  16. package/src/codex-ledger.js +25 -10
  17. package/src/codex-panel-session.js +3 -3
  18. package/src/codex-panel-state.js +51 -5
  19. package/src/codex-route-scan.js +12 -7
  20. package/src/commands/brief.js +1 -1
  21. package/src/commands/codex-delegate.js +81 -6
  22. package/src/commands/codex.js +8 -2
  23. package/src/commands/compact-window.js +6 -2
  24. package/src/commands/install.js +4 -1
  25. package/src/commands/korean.js +69 -3
  26. package/src/commands/route-scan.js +6 -4
  27. package/src/commands/saved.js +40 -1
  28. package/src/ctx-window-cache.js +14 -0
  29. package/src/formatters/statusline.js +167 -18
  30. package/src/harness-analyzer.cjs +8 -2
  31. package/src/harness.js +32 -23
  32. package/src/installer.js +52 -0
  33. package/src/model-alias.js +3 -3
  34. package/src/model-rules.js +14 -9
  35. package/src/preset-ratchet.js +3 -3
  36. package/src/reply-language.cjs +167 -0
  37. package/src/route-inject.js +57 -8
  38. package/src/route-scan.js +51 -11
  39. package/src/savings-ledger.js +3 -2
  40. package/src/seed-rules.js +32 -23
  41. package/src/session-cache.js +12 -4
  42. package/src/shared-model-rules.js +73 -0
  43. package/src/state-file.js +99 -2
  44. package/src/statusline-data.js +97 -0
  45. package/src/update-check.js +46 -6
@@ -1,13 +1,16 @@
1
- import { readFileSync, readdirSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
1
+ import { readFileSync, readdirSync, mkdirSync } from 'node:fs';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { parse } from 'smol-toml';
5
5
  import { userDataDir } from './paths.js';
6
+ import { writeViaTmp } from './state-file.js';
6
7
  import { codexUserDir, codexProviderName } from './agent.js';
7
8
  import { categorize, ESCALATE_RE, EDIT_RE } from './route-scan.js';
8
9
  import { looksPasted } from './route-inject.js';
9
10
  import { newRouteId, recordCodexDelegation } from './codex-ledger.js';
10
11
  import { readCodexTailLines } from './codex-parser.js';
12
+ import { loadConfig } from './config.js';
13
+ import { loadSharedModelRules, sharedModelRulesForProject, sharedRuleBounds, SHARED_RULE_TIERS } from './shared-model-rules.js';
11
14
 
12
15
  export const CODEX_ROUTE_CATEGORIES = ['paste', 'translate', 'explore', 'read', 'check', 'run'];
13
16
  const MARKER = '<!-- sprag:codex:delegation -->';
@@ -22,6 +25,32 @@ export function validateCodexTarget(model, effort) {
22
25
  return { model, ...(effort === undefined ? {} : { effort }) };
23
26
  }
24
27
 
28
+ export function validateCodexSharedTarget({ tier, from, model, effort, provider }) {
29
+ validateCodexTarget(model, effort);
30
+ if (!SHARED_RULE_TIERS.includes(tier)) throw new Error('Shared rule tier must be T1 or T2.');
31
+ if (!safeId(from) || from === model) throw new Error('--from must name a different parent model.');
32
+ if (!safeId(provider)) throw new Error('A shared model mapping requires a Codex provider ID.');
33
+ return { tier, from, model, ...(effort === undefined ? {} : { effort }), provider };
34
+ }
35
+
36
+ export function codexSharedTargets(cfg) {
37
+ const targets = cfg?.codex?.sharedRules?.targets;
38
+ if (!Array.isArray(targets)) return [];
39
+ return targets.flatMap((target) => {
40
+ try { return [validateCodexSharedTarget(target)]; } catch { return []; }
41
+ });
42
+ }
43
+
44
+ export function sharedCodexModelRules({ cfg = loadConfig(), dir = userDataDir(), root, start = root,
45
+ model, provider = codexProviderName() } = {}) {
46
+ if (cfg.codex?.sharedRules?.enabled === false) return [];
47
+ const targets = codexSharedTargets(cfg).filter((t) => t.from === model && t.provider === provider);
48
+ return sharedModelRulesForProject(loadSharedModelRules({ dir }), start).flatMap((rule) => {
49
+ const target = targets.find((t) => t.tier === rule.tier);
50
+ return target ? [{ ...rule, ...target, source: 'shared', status: 'active' }] : [];
51
+ });
52
+ }
53
+
25
54
  const rulesFile = (dir) => join(dir, 'codex-model-rules.json');
26
55
  export function loadCodexModelRules({ dir = userDataDir() } = {}) {
27
56
  try {
@@ -41,8 +70,7 @@ export function loadCodexModelRules({ dir = userDataDir() } = {}) {
41
70
  function saveRules(rules, dir) {
42
71
  mkdirSync(dir, { recursive: true });
43
72
  const file = rulesFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
44
- writeFileSync(tmp, JSON.stringify({ version: 1, rules }, null, 2) + '\n', { mode: 0o600 });
45
- renameSync(tmp, file);
73
+ writeViaTmp(tmp, file, JSON.stringify({ version: 1, rules }, null, 2) + '\n', { mode: 0o600 });
46
74
  }
47
75
 
48
76
  export function addCodexModelRule({ category, model, effort, from, scope, root, provider }, { dir = userDataDir() } = {}) {
@@ -92,6 +120,16 @@ export function matchCodexModelRule(text, { model, root, provider = codexProvide
92
120
  return usable.find((r) => r.scope === 'project') || usable[0] || null;
93
121
  }
94
122
 
123
+ /** A Codex-only rule wins; otherwise the shared policies mapped for this parent, T2 first. */
124
+ function matchingRoutes(text, opts) {
125
+ const own = matchCodexModelRule(text, { ...opts, rules: opts.rules ?? loadCodexModelRules({ dir: opts.dir }) });
126
+ if (own) return [own];
127
+ const category = codexPromptCategory(text);
128
+ if (!category) return [];
129
+ return sharedCodexModelRules(opts).filter((r) => r.category === category.id)
130
+ .sort((a, b) => SHARED_RULE_TIERS.indexOf(a.tier) - SHARED_RULE_TIERS.indexOf(b.tier));
131
+ }
132
+
95
133
  /**
96
134
  * Input tokens from the rollout's last token_count record, or 0 when the
97
135
  * transcript is missing or unreadable. Only the tail is read: the newest
@@ -111,32 +149,85 @@ export function codexContextTokens(transcriptPath) {
111
149
  return 0;
112
150
  }
113
151
 
152
+ export const DEFAULT_DELEGATE_MIN_CONTEXT = 60000;
153
+
154
+ /** Parent input tokens below which the prompt hint stays silent. */
155
+ export function codexDelegateMinContext(cfg) {
156
+ const value = cfg?.codex?.delegateMinContext;
157
+ return Number.isSafeInteger(value) && value >= 0 ? value : DEFAULT_DELEGATE_MIN_CONTEXT;
158
+ }
159
+
160
+ // Codex tells the model to set spawn_agent's model only on the user's request,
161
+ // so the shared hint says where this request came from.
162
+ const SHARED_APPROVAL = 'The user approved this policy and its model mapping, so setting model here follows the user\'s request.';
163
+ const SHARED_TIER_GUIDANCE = 'Choose T2 only for a simple bounded lookup or command. Use T1 for multi-step tool chains or work spanning multiple files. If the required tier is not mapped, keep the task on the main agent. Do not delegate design judgement, diagnosis, or irreversible actions.';
164
+ const WAIT_TEXT = '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. '
165
+ + 'If sub-agent tools or the target model are unavailable, keep the task on the main agent and state that delegation was unavailable.';
166
+
167
+ function sharedTargetText(rule) {
168
+ return `${rule.tier}: model ${rule.model}${rule.effort ? `, reasoning_effort ${rule.effort}` : ''}. ${sharedRuleBounds(rule)}`.trim();
169
+ }
170
+
171
+ function sharedDenyReason(routes) {
172
+ return `[Sprag model routing] This spawn matches the approved shared ${routes[0].category} policy but names no model. ${SHARED_APPROVAL} `
173
+ // A full-history fork inherits the parent model and refuses overrides.
174
+ + 'Spawn again with fork_turns "none" and the model and reasoning_effort of the tier you choose, keeping the route line the routing hint gave for that tier:\n'
175
+ + routes.map(sharedTargetText).join('\n')
176
+ + `\n${SHARED_TIER_GUIDANCE} To keep the task on your own model, pass that model explicitly. If a model cannot be set, do the task on the main agent instead of spawning again.`;
177
+ }
178
+
114
179
  /**
115
180
  * A prompt-time routing suggestion for Codex 0.159.2, where `spawn_agent`
116
181
  * never reaches PreToolUse (see codexDelegationTool below). Gated on parent
117
182
  * context size: a small parent's own coordination overhead outweighs the
118
183
  * savings from a delegated child (measured 2026-09-30, see docs/CODEX.md).
184
+ *
185
+ * A shared policy offers both mapped tiers and records one route per tier;
186
+ * SubagentStart binds the child to the route whose model it runs, and the
187
+ * next prompt closes the tier that was not chosen.
119
188
  */
120
189
  export function codexRouteHint(payload, opts = {}) {
190
+ // The hook passes its loaded config. Without one there are no shared
191
+ // mappings and the default gate applies, so a caller never routes from the
192
+ // user's real config by accident.
193
+ const cfg = opts.cfg ?? {};
121
194
  const provider = payload?.model_provider || codexProviderName();
122
- const rule = matchCodexModelRule(payload?.prompt, { provider, ...opts, model: payload?.model, root: opts.root || payload?.cwd });
195
+ const routes = matchingRoutes(payload?.prompt, { cfg, provider, ...opts, model: payload?.model,
196
+ root: opts.root || payload?.cwd, start: payload?.cwd });
197
+ const rule = routes[0];
123
198
  if (!rule) return null;
124
- const minContext = opts.minContext ?? 60000;
199
+ const minContext = opts.minContext ?? codexDelegateMinContext(cfg);
125
200
  const ctx = opts.contextTokens ?? codexContextTokens(payload?.transcript_path);
126
201
  if (ctx < minContext) return null;
127
- const id = newRouteId();
128
202
  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. */ }
203
+ // Tiers offered by one hint are alternatives. SubagentStart binds by model
204
+ // alone, so it must know which records compete for the same child.
205
+ const offerId = routes.length > 1 ? newRouteId() : null;
206
+ const recordRoute = (route) => {
207
+ const id = newRouteId();
208
+ const shared = route.source === 'shared';
209
+ try {
210
+ record({ id, parentSessionId: payload?.session_id ?? null, turnId: payload?.turn_id ?? null, via: 'prompt',
211
+ source: shared ? 'shared' : 'rule', category: route.category, scope: route.scope, from: payload?.model, to: route.model,
212
+ provider: route.provider || provider, effort: route.effort ?? null, contextTokens: ctx, targetRoot: route.targetRoot ?? null,
213
+ ...(shared ? { tier: route.tier, sharedSignature: route.sharedSignature, ...(offerId ? { offerId } : {}) }
214
+ : { ruleCreatedAt: route.createdAt ?? null }) }, { dir: opts.dir });
215
+ } catch { /* Accounting is optional; the hint still applies. */ }
216
+ return id;
217
+ };
218
+ if (rule.source === 'shared') {
219
+ const targets = routes.map((route) => `${sharedTargetText(route)} Route line: <!-- sprag:codex:route id=${recordRoute(route)} -->`);
220
+ return `[Sprag model routing] This request matches the approved shared ${rule.category} policy. ${SHARED_APPROVAL} ${SHARED_TIER_GUIDANCE}\n`
221
+ + `${targets.join('\n')}\n`
222
+ + 'Spawn at most one sub-agent with the selected model, reasoning_effort when specified, and fork_turns "none"; a full-history fork ignores the model. '
223
+ + 'Give it a self-contained task message with the selected cap that ends with that tier\'s route line only. '
224
+ + WAIT_TEXT;
225
+ }
226
+ const id = recordRoute(rule);
135
227
  return `[Sprag model routing] This request matches the approved ${rule.category} rule (route ${id}). `
136
228
  + `Spawn one sub-agent with model ${rule.model}${rule.effort ? `, reasoning_effort ${rule.effort}` : ''} and fork_turns "none". `
137
229
  + `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.';
230
+ + WAIT_TEXT;
140
231
  }
141
232
 
142
233
  function customRole(role, root, home) {
@@ -177,7 +268,15 @@ export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd ||
177
268
  let rules = given;
178
269
  if (!rules) { try { rules = loadCodexModelRules({ dir }); } catch { rules = []; } }
179
270
  const provider = payload.model_provider || codexProviderName({ home });
180
- const rule = explicit ? null : matchCodexModelRule(input.message, { model: payload.model, provider, root, rules });
271
+ const routes = explicit ? [] : matchingRoutes(input.message, { model: payload.model, provider, root, start: payload.cwd, rules, cfg, dir });
272
+ // Only the caller can choose a shared policy's tier. A shared match without a
273
+ // model goes back to the caller with the mapped choices instead of a guessed
274
+ // downgrade, or a default target that ignores the tier.
275
+ if (routes[0]?.source === 'shared') {
276
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
277
+ permissionDecisionReason: sharedDenyReason(routes) } };
278
+ }
279
+ const rule = routes[0] ?? null;
181
280
  const target = rule || (!explicit && cfg.codex?.delegateTarget);
182
281
  const updatedInput = { ...input };
183
282
  let routeLine = '';
@@ -1,8 +1,9 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, realpathSync, statSync } from 'node:fs';
2
+ import { mkdirSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
3
3
  import { extname, join, resolve } from 'node:path';
4
4
  import { codexUserDir } from './agent.js';
5
5
  import { userDataDir } from './paths.js';
6
+ import { writeViaTmp } from './state-file.js';
6
7
  import { readResetMarks, isBeforeReset } from './saved-reset.cjs';
7
8
 
8
9
  const hash = (value) => createHash('sha256').update(value).digest('hex');
@@ -22,8 +23,7 @@ export function recordCodexDocument(source, result, opts) {
22
23
  clipped: !!(meta.clipped || meta.truncated) };
23
24
  mkdirSync(dir, { recursive: true, mode: 0o700 });
24
25
  const tmp = `${file}.${randomUUID()}.tmp`;
25
- writeFileSync(tmp, JSON.stringify(event) + '\n', { mode: 0o600 });
26
- renameSync(tmp, file);
26
+ writeViaTmp(tmp, file, JSON.stringify(event) + '\n', { mode: 0o600 });
27
27
  } catch { /* Accounting must not prevent document conversion. */ }
28
28
  }
29
29
 
@@ -17,13 +17,13 @@ export const CODEX_CAPABILITIES = [
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.' },
19
19
  { feature: 'doc2md', support: 'supported', command: 'sprag doc2md status --agent codex', note: 'Shared converter, prompt paths, supported file reads, and recognized document/cache write guards; not arbitrary shell extraction.' },
20
- { feature: 'delegate', support: 'supported', command: 'sprag delegate on --agent codex', note: 'Opt-in native spawn model routing, scoped category rules, and SubagentStart guidance. Explicit models/custom roles preserved; no enforced budget.' },
20
+ { feature: 'delegate', support: 'supported', command: 'sprag delegate on --agent codex', note: 'Native routing and shared delegation policies with explicit Codex tier mappings. Explicit models/custom roles preserved; bounds are guidance, not enforced limits.' },
21
21
  { feature: 'routing-saved', support: 'partial', command: 'sprag route-scan savings --agent codex', note: 'Sprag-routed child usage with matching LiteLLM prices; counterfactual estimate, not billed savings. Unpriced runs stay unknown.' },
22
22
  { feature: 'cache-timer', support: 'partial', command: 'sprag cache status --refresh --agent codex', note: 'Documented minimum retention after recorded cache usage; LiteLLM deployments resolved through model/info. Unknown routes show age; exact expiry is not reported.' },
23
23
  { feature: 'mode', support: 'partial', command: 'sprag mode --agent codex', note: 'Labels, color, and timer apply to panel/statusline; Claude TTL buckets do not.' },
24
24
  { feature: 'upgrade', support: 'manual', command: 'sprag upgrade --print --agent codex', note: 'Skip the Claude npm postinstall; reinstall Codex hooks after upgrade.' },
25
25
  { feature: 'route-scan', support: 'supported', command: 'sprag route-scan --agent codex', note: 'Recurring simple turns grouped by project, provider, and parent model; explicit approval before routing.' },
26
- { feature: 'seed', support: 'supported', command: 'sprag seed --agent codex', note: 'Agent-compatible ratchet presets; separate decisions and explicit scope. No Claude tier presets.' },
26
+ { feature: 'seed', support: 'supported', command: 'sprag seed --agent codex', note: 'Shared delegation policies plus agent-compatible ratchet presets. Explicit scope; Codex model mappings required. No Claude installation needed.' },
27
27
  { feature: 'transcript ratchet', support: 'supported', command: 'sprag --statusline --agent codex', note: 'Recent repeated tool failures become ratchet? candidates; no automatic rule registration.' },
28
28
  { feature: 'compact-window', support: 'native', note: 'Codex uses model_auto_compact_token_limit; no Claude autoCompactWindow heuristic.' },
29
29
  { feature: 'cost / exact cache expiry', support: 'unavailable', note: 'Not measured in rollout logs; never estimated with Anthropic prices.' },
@@ -1,12 +1,13 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { createHash } from 'node:crypto';
4
- import { mkdirSync, openSync, readFileSync, readSync, closeSync, renameSync, statSync, writeFileSync } from 'node:fs';
4
+ import { mkdirSync, openSync, readFileSync, readSync, closeSync, statSync } from 'node:fs';
5
5
  import { join, resolve } from 'node:path';
6
6
  import { loadConfig, userLanguage } from './config.js';
7
7
  import { koreanStyleInjection, koreanStyleEnabled } from './korean-style.js';
8
8
  import { cohesionInjection } from './cohesion.js';
9
9
  import { codexHarnessPaths } from './codex-harness.js';
10
+ import { writeViaTmp } from './state-file.js';
10
11
  import { findProjectRoot } from './harness.js';
11
12
  import { runCodexBrief } from './codex-brief.js';
12
13
  import { codexDocumentTool, codexDocumentNote, convertCodexDocument } from './codex-doc2md.js';
@@ -69,8 +70,7 @@ export function fitCodexContext(parts, { budget = CODEX_CONTEXT_TOKEN_BUDGET, ov
69
70
  function writeOverflow(file, text) {
70
71
  mkdirSync(join(file, '..'), { recursive: true, mode: 0o700 });
71
72
  const tmp = `${file}.${process.pid}.tmp`;
72
- writeFileSync(tmp, text, { mode: 0o600 });
73
- renameSync(tmp, file);
73
+ writeViaTmp(tmp, file, text, { mode: 0o600 });
74
74
  }
75
75
 
76
76
  function overflowFileFor(root) {
@@ -212,8 +212,7 @@ export async function codexHookOutput(event, payload, { cfg = loadConfig(), refr
212
212
  try { closeStaleCodexRoutes(payload); } catch (e) { debug('codex:close-routes', e); }
213
213
  try { if (unsettledCodexRoutes() > 0) refreshLedger(); } catch (e) { debug('codex:ledger-refresh', e); }
214
214
  try {
215
- parts.push(codexRouteHint(payload, { root: findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' }),
216
- minContext: Number.isFinite(cfg?.codex?.delegateMinContext) ? cfg.codex.delegateMinContext : undefined }));
215
+ parts.push(codexRouteHint(payload, { cfg, root: findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' }) }));
217
216
  } catch (e) { debug('codex:route-hint', e); }
218
217
  }
219
218
  if (cfg?.codex?.brief !== false) {
@@ -1,6 +1,7 @@
1
- import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
3
  import { codexUserDir } from './agent.js';
4
+ import { writeViaTmp } from './state-file.js';
4
5
 
5
6
  export const CODEX_HANDLERS = {
6
7
  SessionStart: { matcher: 'startup|resume|clear|compact', command: 'sprag codex-hook --agent codex --event session-start', timeout: 15 },
@@ -72,8 +73,7 @@ export function configureCodexHooks({ remove = false, panelAuto } = {}) {
72
73
  mkdirSync(dirname(file), { recursive: true });
73
74
  if (existsSync(file)) writeFileSync(`${file}.bak-${Date.now()}`, readFileSync(file));
74
75
  const tmp = `${file}.${process.pid}.tmp`;
75
- writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
76
- renameSync(tmp, file);
76
+ writeViaTmp(tmp, file, JSON.stringify(data, null, 2) + '\n');
77
77
  }
78
78
  return { file, action: remove ? 'removed' : 'configured' };
79
79
  }
@@ -1,7 +1,8 @@
1
- import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
1
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { randomBytes, randomUUID } from 'node:crypto';
4
4
  import { userDataDir } from './paths.js';
5
+ import { writeViaTmp } from './state-file.js';
5
6
  import { roundUsd } from './savings-ledger.js';
6
7
  import { readResetMarks, isBeforeReset } from './saved-reset.cjs';
7
8
  import { discoverCodexSessionFiles, parseCodexTurns } from './codex-parser.js';
@@ -52,8 +53,7 @@ export function pruneCodexDelegations({ dir = userDataDir(), now = Date.now() }
52
53
  try { all = readFileSync(pendingFile(dir), 'utf8').split('\n').filter(Boolean).length; } catch { return; }
53
54
  if (keep.length === all) return;
54
55
  const file = pendingFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
55
- writeFileSync(tmp, keep.map((r) => JSON.stringify(r) + '\n').join(''), { mode: 0o600 });
56
- renameSync(tmp, file);
56
+ writeViaTmp(tmp, file, keep.map((r) => JSON.stringify(r) + '\n').join(''), { mode: 0o600 });
57
57
  }
58
58
 
59
59
  export function readCodexDelegations({ dir = userDataDir() } = {}) {
@@ -88,20 +88,24 @@ export function readCodexBindings({ dir = userDataDir() } = {}) {
88
88
  return out;
89
89
  }
90
90
 
91
- /** Route ids a later prompt closed before any child claimed them. Kept in the binds file as `{ id, closed: true }` lines. */
92
- function closedCodexRouteIds({ dir = userDataDir() } = {}) {
91
+ /** When a later prompt closed each route no child had claimed. Kept in the binds file as `{ id, closed: true, at }` lines. */
92
+ function closedCodexRouteTimes({ dir = userDataDir() } = {}) {
93
93
  let text;
94
- try { text = readFileSync(bindsFile(dir), 'utf8'); } catch { return new Set(); }
95
- const out = new Set();
94
+ try { text = readFileSync(bindsFile(dir), 'utf8'); } catch { return new Map(); }
95
+ const out = new Map();
96
96
  for (const line of text.split('\n')) {
97
97
  try {
98
98
  const b = JSON.parse(line);
99
- if (b?.closed === true && /^[0-9a-f]{16}$/.test(b.id)) out.add(b.id);
99
+ if (b?.closed === true && /^[0-9a-f]{16}$/.test(b.id) && !out.has(b.id)) out.set(b.id, Number.isFinite(b.at) ? b.at : -Infinity);
100
100
  } catch { /* Partial trailing line. */ }
101
101
  }
102
102
  return out;
103
103
  }
104
104
 
105
+ function closedCodexRouteIds(opts) {
106
+ return new Set(closedCodexRouteTimes(opts).keys());
107
+ }
108
+
105
109
  /**
106
110
  * Close the routes this parent was hinted in an earlier turn and never spawned.
107
111
  *
@@ -142,6 +146,9 @@ export function bindCodexSubagent(payload, { dir = userDataDir(), now = Date.now
142
146
  if (!pending.length) return null;
143
147
  // Oldest first: spawns are recorded in order, so the earliest unbound record belongs to the earliest child.
144
148
  const chosen = pending.reduce((a, b) => (b.at < a.at ? b : a));
149
+ // Tiers one hint offered together can map to the same model; the model then
150
+ // cannot say which tier this child is. Leave it to the child's route marker.
151
+ if (chosen.offerId && pending.some((r) => r !== chosen && r.offerId === chosen.offerId)) return null;
145
152
  recordCodexBinding({ id: chosen.id, childSessionId: payload.agent_id, at: now }, { dir });
146
153
  return chosen.id;
147
154
  }
@@ -173,8 +180,7 @@ export function loadCodexLedger({ dir = userDataDir() } = {}) {
173
180
  function saveLedger(data, dir) {
174
181
  mkdirSync(dir, { recursive: true });
175
182
  const file = ledgerFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
176
- writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
177
- renameSync(tmp, file);
183
+ writeViaTmp(tmp, file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
178
184
  }
179
185
 
180
186
  /**
@@ -221,6 +227,7 @@ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date
221
227
  if (!pending.length) return ledger;
222
228
  const byId = new Map(pending.map((r) => [r.id, r]));
223
229
  const bindMap = new Map(readCodexBindings({ dir }).filter((b) => byId.has(b.id)).map((b) => [b.childSessionId, b.id]));
230
+ const closedAt = closedCodexRouteTimes({ dir });
224
231
  const oldest = Math.min(...pending.map((r) => r.at));
225
232
  const files = await discoverCodexSessionFiles({ home, days: Math.max(1, (now - oldest) / 86400000 + 1) });
226
233
  let changed = false;
@@ -233,7 +240,14 @@ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date
233
240
  // The id must come from a spawn this parent made, after the record was written.
234
241
  if (!route || (route.parentSessionId && run.parentThreadId && route.parentSessionId !== run.parentThreadId)) continue;
235
242
  if (route.provider && route.provider !== run.provider) continue;
243
+ // A shared policy names its model only through the caller's choice, so
244
+ // credit it only for a child of that parent that ran the mapped model.
245
+ if (route.source === 'shared' && (run.model !== route.to || !route.parentSessionId ||
246
+ run.parentThreadId !== route.parentSessionId)) continue;
236
247
  if (Number.isFinite(run.startedAt) && run.startedAt < route.at - 5000) continue;
248
+ // A closed route ended with its hinted turn. Only a child that started
249
+ // before the close can be its run; a later marker is a copy, not a spawn.
250
+ if (closedAt.has(route.id) && !(Number.isFinite(run.startedAt) && run.startedAt < closedAt.get(route.id))) continue;
237
251
  if (seen.has(route.id)) continue;
238
252
  seen.add(route.id);
239
253
  const to = run.model || route.to;
@@ -250,6 +264,7 @@ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date
250
264
  const usd = actual === null || counterfactual === null ? null : roundUsd(run.aborted ? -actual : counterfactual - actual);
251
265
  const event = { ts: run.endedAt ?? route.at, parentSessionId: route.parentSessionId || run.parentThreadId || null,
252
266
  childSessionId: run.sessionId, source: route.source, category: route.category ?? null, scope: route.scope ?? null,
267
+ ...(route.source === 'shared' ? { tier: route.tier, sharedSignature: route.sharedSignature } : {}),
253
268
  from: route.from, to, provider,
254
269
  ...(route.targetRoot !== undefined ? { targetRoot: route.targetRoot } : {}),
255
270
  ...(route.ruleCreatedAt !== undefined ? { ruleCreatedAt: route.ruleCreatedAt } : {}), tokens: run.tokens, calls: run.calls, toolErrors: run.toolErrors, usd, rates: usd === null ? null : rates,
@@ -1,5 +1,6 @@
1
- import { readFileSync, writeFileSync, renameSync } from 'node:fs';
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { isAbsolute } from 'node:path';
3
+ import { writeViaTmp } from './state-file.js';
3
4
 
4
5
  export function createPanelBinding(file) {
5
6
  writeFileSync(file, JSON.stringify({ version: 1, sessionId: null, transcriptPath: null }), { mode: 0o600 });
@@ -27,9 +28,8 @@ export function bindPanelSession(event, payload, { file = process.env.SPRAG_CODE
27
28
  if (current.version !== 1) return false;
28
29
  if (payload.source === 'compact' && current.sessionId && current.sessionId !== payload.session_id) return false;
29
30
  const tmp = `${file}.${process.pid}.tmp`;
30
- writeFileSync(tmp, JSON.stringify({ version: 1, sessionId: payload.session_id,
31
+ writeViaTmp(tmp, file, JSON.stringify({ version: 1, sessionId: payload.session_id,
31
32
  transcriptPath: typeof payload.transcript_path === 'string' && isAbsolute(payload.transcript_path) ? payload.transcript_path : null }), { mode: 0o600 });
32
- renameSync(tmp, file);
33
33
  return true;
34
34
  } catch { return false; }
35
35
  }
@@ -1,8 +1,15 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
2
+ import { readFileSync, mkdirSync, readdirSync, lstatSync, unlinkSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { userDataDir } from './paths.js';
5
5
  import { codexUserDir } from './agent.js';
6
+ import { writeViaTmp, runHousekeeping, sweepStaleTmp } from './state-file.js';
7
+
8
+ // Every panel session leaves a `-frame` and a `-hook` file behind, keyed by a
9
+ // hash that is not reused once the session is gone. They are diagnostics for a
10
+ // live session, so a file untouched for a week describes one that is over.
11
+ const PANEL_STATE_FILE = /^codex-panel-[0-9a-f]{64}-(?:frame|hook)\.json$/;
12
+ export const PANEL_STATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
6
13
 
7
14
  export function panelStatePath(root, kind, dir = userDataDir(), sessionId) {
8
15
  const key = createHash('sha256').update(codexUserDir() + '\0' + resolve(root) + (sessionId ? `\0${sessionId}` : '')).digest('hex');
@@ -15,17 +22,56 @@ export function recordPanelState(root, kind, value, dir = userDataDir()) {
15
22
  const file = panelStatePath(root, kind, dir);
16
23
  const tmp = `${file}.${process.pid}.tmp`;
17
24
  const text = JSON.stringify({ ...value, at: new Date().toISOString() });
18
- writeFileSync(tmp, text);
19
- renameSync(tmp, file);
25
+ writeViaTmp(tmp, file, text);
20
26
  if (value.sessionId) {
21
27
  const sessionFile = panelStatePath(root, kind, dir, value.sessionId);
22
28
  const sessionTmp = `${sessionFile}.${process.pid}.tmp`;
23
- writeFileSync(sessionTmp, text);
24
- renameSync(sessionTmp, sessionFile);
29
+ writeViaTmp(sessionTmp, sessionFile, text);
25
30
  }
31
+ // A live panel records a frame every couple of seconds, so the clean-up is
32
+ // rate-limited to once a day (see runHousekeeping) rather than listing the
33
+ // directory on every write. This session's own files, of both kinds, are
34
+ // never candidates: a session that has run for over a week keeps writing
35
+ // frames while its hook record is just as old.
36
+ runHousekeeping(dir, {
37
+ 'stale-tmp': () => sweepStaleTmp(dir),
38
+ 'codex-panel': () => sweepPanelState({
39
+ dir,
40
+ keep: ['frame', 'hook'].flatMap((k) => [
41
+ panelStatePath(root, k, dir),
42
+ ...(value.sessionId ? [panelStatePath(root, k, dir, value.sessionId)] : []),
43
+ ]),
44
+ }),
45
+ });
26
46
  } catch { /* Diagnostics must not stop a session or panel. */ }
27
47
  }
28
48
 
49
+ /**
50
+ * Remove panel state files untouched for `maxAgeMs`. Only the exact
51
+ * `codex-panel-<64 hex>-(frame|hook).json` shape is considered, and anything
52
+ * in `keep` (full paths) is left alone whatever its mtime.
53
+ *
54
+ * @returns {number} how many files were removed
55
+ */
56
+ export function sweepPanelState({ dir = userDataDir(), now = Date.now(), maxAgeMs = PANEL_STATE_MAX_AGE_MS, keep = [] } = {}) {
57
+ let names;
58
+ try { names = readdirSync(dir); } catch { return 0; }
59
+ const protectedPaths = new Set(keep);
60
+ let removed = 0;
61
+ for (const name of names) {
62
+ if (!PANEL_STATE_FILE.test(name)) continue;
63
+ const path = join(dir, name);
64
+ if (protectedPaths.has(path)) continue;
65
+ try {
66
+ const st = lstatSync(path);
67
+ if (!st.isFile() || now - st.mtimeMs <= maxAgeMs) continue;
68
+ unlinkSync(path);
69
+ removed += 1;
70
+ } catch { /* already gone, or not ours to remove */ }
71
+ }
72
+ return removed;
73
+ }
74
+
29
75
  export function readPanelState(root, kind, dir = userDataDir(), sessionId) {
30
76
  try { return JSON.parse(readFileSync(panelStatePath(root, kind, dir, sessionId), 'utf8')); }
31
77
  catch { return null; }
@@ -1,13 +1,14 @@
1
- import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
1
+ import { readFileSync, mkdirSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { userDataDir } from './paths.js';
5
+ import { writeViaTmp } from './state-file.js';
5
6
  import { loadConfig } from './config.js';
6
7
  import { findProjectRoot } from './harness.js';
7
8
  import { discoverCodexSessionFiles, parseCodexTurns } from './codex-parser.js';
8
9
  import { categorize, isSkippable, tierOf, calibrateThresholds, MIN_RECURRENCE,
9
10
  RESCAN_MIN_INTERVAL_MS, RESCAN_BIG_DELTA_BYTES, RESCAN_MAX_AGE_MS } from './route-scan.js';
10
- import { codexPromptCategory, loadCodexModelRules } from './codex-delegation.js';
11
+ import { codexPromptCategory, loadCodexModelRules, sharedCodexModelRules, codexSharedTargets } from './codex-delegation.js';
11
12
  import { codexRunCost, codexPriceBook, isDirectOpenAI, readCodexPrices } from './codex-cache-policy.js';
12
13
  import { OPENAI_PRICES_CHECKED_AT } from './openai-prices.js';
13
14
  import { refreshCodexLedger } from './codex-ledger.js';
@@ -32,8 +33,7 @@ export function readCodexRouteScan({ dir = userDataDir() } = {}) {
32
33
  function writeCache(data, dir) {
33
34
  mkdirSync(dir, { recursive: true });
34
35
  const file = cacheFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
35
- writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
36
- renameSync(tmp, file);
36
+ writeViaTmp(tmp, file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
37
37
  }
38
38
 
39
39
  /** Candidates not yet approved or dismissed; with `root`, only that project's. */
@@ -149,15 +149,20 @@ export async function runCodexRouteScan({ days = 14, now = Date.now(), home, dir
149
149
  }
150
150
  let rules = [];
151
151
  try { rules = loadCodexModelRules({ dir }); } catch { /* An unreadable rule file only means candidates may repeat. */ }
152
- const covered = (g) => rules.some((r) => r.category === g.category && r.from === g.from &&
153
- (!r.provider || r.provider === g.provider) &&
154
- (r.scope === 'global' || r.targetRoot === g.projectRoot));
152
+ const covered = (g) => {
153
+ if (rules.some((r) => r.category === g.category && r.from === g.from &&
154
+ (!r.provider || r.provider === g.provider) &&
155
+ (r.scope === 'global' || r.targetRoot === g.projectRoot))) return true;
156
+ const shared = sharedCodexModelRules({ cfg, dir, root: g.projectRoot, model: g.from, provider: g.provider });
157
+ return ['T1', 'T2'].every((tier) => !g.tiers[tier] || shared.some((r) => r.category === g.category && r.tier === tier));
158
+ };
155
159
  const known = new Set([...observed, ...rules.filter((r) => r.provider).map((r) => `${r.provider}|${r.model}`)]);
156
160
  const target = cfg?.codex?.delegateTarget?.model;
157
161
  if (target) {
158
162
  if (prices?.provider) known.add(`${prices.provider}|${target}`);
159
163
  if (isDirectOpenAI('openai', { home })) known.add(`openai|${target}`);
160
164
  }
165
+ for (const target of codexSharedTargets(cfg)) known.add(`${target.provider}|${target.model}`);
161
166
  const prev = readCodexRouteScan({ dir });
162
167
  const resolved = new Set(prev?.resolved || []);
163
168
  const candidates = [...groups.values()]
@@ -49,7 +49,7 @@ export async function run({ hasFlag }) {
49
49
  // differ. Claude Code sends `cwd` for that reason and the PreToolUse hook
50
50
  // already reads it (src/delegation-guard.js). A payload without the field
51
51
  // leaves root undefined, which is the behaviour this had before.
52
- const match = routeMatch(ctx.prompt, { sessionRank });
52
+ const match = routeMatch(ctx.prompt, { sessionRank, root: ctx.cwd });
53
53
  const hint = routeHint(ctx.prompt, { sessionRank, root: ctx.cwd, match });
54
54
  if (hint) parts.push(hint);
55
55
  // The delegation guard repeats these caps to the subagent, so the limit it