claude-token-saver 3.44.0 → 3.55.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 (84) hide show
  1. package/LICENSE +202 -21
  2. package/NOTICE +35 -0
  3. package/README.ko.md +150 -48
  4. package/README.md +205 -192
  5. package/bin/cli.js +108 -39
  6. package/docs/CODEX.ko.md +184 -0
  7. package/docs/CODEX.md +497 -0
  8. package/examples/statusline-command.ps1 +11 -5
  9. package/examples/statusline-command.sh +9 -4
  10. package/package.json +20 -6
  11. package/presets/delegation/bounds.md +29 -0
  12. package/presets/model-rules.json +50 -9
  13. package/presets/ratchet-rules.json +4 -1
  14. package/src/agent.js +33 -0
  15. package/src/caps-cache.js +6 -2
  16. package/src/changelog.js +70 -0
  17. package/src/cli-name.js +25 -0
  18. package/src/codex-brief.js +89 -0
  19. package/src/codex-budget.js +73 -0
  20. package/src/codex-cache-policy.js +203 -0
  21. package/src/codex-cache.js +46 -0
  22. package/src/codex-delegation.js +154 -0
  23. package/src/codex-doc2md-ledger.js +45 -0
  24. package/src/codex-doc2md.js +99 -0
  25. package/src/codex-doctor.js +53 -0
  26. package/src/codex-harness.js +200 -0
  27. package/src/codex-hooks.js +138 -0
  28. package/src/codex-installer.js +79 -0
  29. package/src/codex-ledger.js +143 -0
  30. package/src/codex-panel-launcher.js +48 -0
  31. package/src/codex-panel-runner.js +74 -0
  32. package/src/codex-panel-session.js +35 -0
  33. package/src/codex-panel-setup.js +98 -0
  34. package/src/codex-panel-shell.js +51 -0
  35. package/src/codex-panel-state.js +40 -0
  36. package/src/codex-panel.js +448 -0
  37. package/src/codex-parser.js +397 -0
  38. package/src/codex-route-scan.js +171 -0
  39. package/src/commands/brief.js +24 -1
  40. package/src/commands/codex-delegate.js +59 -0
  41. package/src/commands/codex.js +579 -0
  42. package/src/commands/delegate.js +133 -0
  43. package/src/commands/harness.js +16 -3
  44. package/src/commands/install.js +109 -12
  45. package/src/commands/korean.js +2 -2
  46. package/src/commands/mode.js +22 -2
  47. package/src/commands/profile-map.js +164 -0
  48. package/src/commands/route-scan.js +74 -7
  49. package/src/commands/seed.js +16 -12
  50. package/src/commands/uninstall.js +28 -4
  51. package/src/commands/upgrade.js +13 -0
  52. package/src/compact-window.js +7 -1
  53. package/src/config.js +22 -3
  54. package/src/cost.js +34 -0
  55. package/src/delegation-guard.js +278 -0
  56. package/src/doc2md-ledger.cjs +3 -3
  57. package/src/doc2md.cjs +72 -28
  58. package/src/effort-palette.js +145 -0
  59. package/src/formatters/statusline.js +352 -123
  60. package/src/formatters/table.js +5 -4
  61. package/src/gateway-auth.js +86 -0
  62. package/src/glyphs.js +103 -0
  63. package/src/handoff.js +27 -7
  64. package/src/harness-analyzer.cjs +7 -2
  65. package/src/harness-templates.js +33 -1
  66. package/src/harness.js +51 -17
  67. package/src/hook-manager.js +42 -14
  68. package/src/hook.cjs +34 -2
  69. package/src/installer.js +300 -16
  70. package/src/litellm-budget.js +38 -67
  71. package/src/litellm-models.js +264 -0
  72. package/src/model-alias.js +170 -16
  73. package/src/model-rules.js +53 -17
  74. package/src/parser.js +89 -1
  75. package/src/preset-ratchet.js +133 -0
  76. package/src/route-inject.js +161 -0
  77. package/src/route-scan.js +68 -8
  78. package/src/seed-rules.js +117 -55
  79. package/src/session-paths.js +242 -0
  80. package/src/statusline-mode.js +106 -0
  81. package/src/stdin-payload.js +40 -0
  82. package/src/ultracode.js +202 -0
  83. package/src/update-check.js +217 -3
  84. package/src/window-labels.js +19 -8
@@ -0,0 +1,397 @@
1
+ import { createReadStream, openSync, readSync, closeSync, fstatSync } from 'node:fs';
2
+ import { readdir, stat } from 'node:fs/promises';
3
+ import { createInterface } from 'node:readline';
4
+ import { join, resolve } from 'node:path';
5
+ import { codexUserDir } from './agent.js';
6
+
7
+ const count = (n) => Number.isSafeInteger(n) && n >= 0 ? n : 0;
8
+ const emptyTotals = () => ({ input: 0, cacheRead: 0, cacheCreation: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0 });
9
+
10
+ function updateSnapshot(session, e) {
11
+ const p = e?.payload;
12
+ if (!p || typeof p !== 'object') return;
13
+ const activity = Date.parse(e.timestamp);
14
+ if (Number.isFinite(activity)) session.lastActivity = new Date(activity);
15
+ if (e.type === 'session_meta') {
16
+ session.sessionId = p.id || p.session_id || null;
17
+ session.projectDir = typeof p.cwd === 'string' ? p.cwd : '';
18
+ session.source = p.source ?? null;
19
+ session.isSubagent = typeof p.source === 'object' && p.source !== null && 'subagent' in p.source;
20
+ session.branch = typeof p.git?.branch === 'string' ? p.git.branch : null;
21
+ session.provider = typeof p.model_provider === 'string' ? p.model_provider : null;
22
+ }
23
+ if (e.type === 'turn_context') {
24
+ if (typeof p.model === 'string') session.model = p.model;
25
+ const effort = p.effort ?? p.reasoning_effort;
26
+ if (typeof effort === 'string') session.effort = effort;
27
+ }
28
+ if (e.type !== 'event_msg') return;
29
+ if (p.type === 'task_started') session.status = 'working';
30
+ if (p.type === 'task_complete') session.status = 'idle';
31
+ if (p.type === 'turn_aborted') session.status = 'interrupted';
32
+ if (p.type !== 'token_count') return;
33
+ if (p.rate_limits && typeof p.rate_limits === 'object') {
34
+ session.rateLimits = p.rate_limits;
35
+ session.rateLimitsAt = Number.isFinite(activity) ? new Date(activity) : null;
36
+ }
37
+ if (Number.isSafeInteger(p.info?.last_token_usage?.input_tokens) && p.info.last_token_usage.input_tokens >= 0) {
38
+ session.lastContextTokens = p.info.last_token_usage.input_tokens;
39
+ session.contextAt = Number.isFinite(activity) ? new Date(activity) : null;
40
+ }
41
+ if (count(p.info?.model_context_window)) session.contextWindow = p.info.model_context_window;
42
+ }
43
+
44
+ /** The last `tailBytes` of a rollout as whole lines; a cut first line is dropped. */
45
+ export function readCodexTailLines(filePath, tailBytes = 256 * 1024) {
46
+ if (!filePath) return null;
47
+ let fd;
48
+ try {
49
+ fd = openSync(filePath, 'r');
50
+ const size = fstatSync(fd).size;
51
+ const start = Math.max(0, size - tailBytes);
52
+ const buffer = Buffer.alloc(size - start);
53
+ const length = readSync(fd, buffer, 0, buffer.length, start);
54
+ const lines = buffer.subarray(0, length).toString('utf8').split('\n');
55
+ if (start) lines.shift();
56
+ return lines;
57
+ } catch { return null; }
58
+ finally { if (fd !== undefined) closeSync(fd); }
59
+ }
60
+
61
+ /** Prompt hooks only need the newest measurements, never a full usage scan. */
62
+ export function readCodexSnapshot(filePath, { tailBytes = 256 * 1024 } = {}) {
63
+ const lines = readCodexTailLines(filePath, tailBytes);
64
+ if (!lines) return null;
65
+ const snapshot = {};
66
+ for (const line of lines) {
67
+ try { updateSnapshot(snapshot, JSON.parse(line)); } catch { /* Partial or malformed record. */ }
68
+ }
69
+ return snapshot;
70
+ }
71
+
72
+ const textOf = (content) => (Array.isArray(content) ? content : [])
73
+ .filter((c) => c && typeof c.text === 'string').map((c) => c.text).join('\n');
74
+ // Legacy outputs carry the exit status only as text inside the tool result.
75
+ const exitFromText = (text) => {
76
+ const m = /(?:^|\n)(?:Process exited with code|Exit code:)\s*(-?\d+)/.exec(String(text ?? ''));
77
+ return m ? Number(m[1]) : null;
78
+ };
79
+ const fileCwd = (value) => typeof value === 'string' ? value.replace(/^file:\/\//, '') : null;
80
+
81
+ /**
82
+ * Tool events of one rollout record, in a shape both formats share.
83
+ * Current rollouts carry structured `item_completed` items; older ones carry
84
+ * `exec_command_end`/`patch_apply_end`, and the oldest only a text exit line.
85
+ */
86
+ export function codexToolEvents(e) {
87
+ const p = e?.payload;
88
+ if (!p || typeof p !== 'object') return [];
89
+ const at = Date.parse(e.timestamp);
90
+ if (e.type === 'event_msg' && p.type === 'item_completed' && p.item && typeof p.item === 'object') {
91
+ const it = p.item;
92
+ const base = { turnId: p.turn_id || null, threadId: p.thread_id || null, callId: it.id || null, at };
93
+ if (it.type === 'UserMessage') return [{ kind: 'user', ...base, text: textOf(it.content) }];
94
+ if (it.type === 'CommandExecution') return [{ kind: 'command', ...base,
95
+ argv: Array.isArray(it.command) ? it.command.map(String) : [String(it.command ?? '')],
96
+ parsed: Array.isArray(it.parsed_cmd) ? it.parsed_cmd : [],
97
+ exit: Number.isInteger(it.exit_code) ? it.exit_code : null,
98
+ failed: it.status === 'failed' || (Number.isInteger(it.exit_code) && it.exit_code !== 0),
99
+ output: String(it.aggregated_output ?? it.formatted_output ?? it.stdout ?? ''),
100
+ cwd: fileCwd(it.cwd) }];
101
+ if (it.type === 'FileChange') return [{ kind: 'patch', ...base, failed: it.status === 'failed',
102
+ output: String(it.stderr || it.stdout || '') }];
103
+ if (it.type === 'McpToolCall') return [{ kind: 'mcp', ...base, failed: it.status === 'failed' }];
104
+ return [];
105
+ }
106
+ if (e.type === 'event_msg' && p.type === 'exec_command_end') {
107
+ return [{ kind: 'command', turnId: p.turn_id || null, threadId: null, callId: p.call_id || null, at,
108
+ argv: Array.isArray(p.command) ? p.command.map(String) : [String(p.command ?? '')],
109
+ parsed: Array.isArray(p.parsed_cmd) ? p.parsed_cmd : [],
110
+ exit: Number.isInteger(p.exit_code) ? p.exit_code : null,
111
+ failed: p.status === 'failed' || (Number.isInteger(p.exit_code) && p.exit_code !== 0),
112
+ output: String(p.aggregated_output ?? p.formatted_output ?? p.stdout ?? ''), cwd: fileCwd(p.cwd) }];
113
+ }
114
+ if (e.type === 'event_msg' && p.type === 'patch_apply_end') {
115
+ return [{ kind: 'patch', turnId: p.turn_id || null, threadId: null, callId: p.call_id || null, at,
116
+ failed: p.success === false || p.status === 'failed', output: String(p.stderr || p.stdout || '') }];
117
+ }
118
+ if (e.type === 'event_msg' && p.type === 'user_message' && typeof p.message === 'string') {
119
+ return [{ kind: 'user', turnId: p.turn_id || null, threadId: null, callId: null, at, text: p.message, legacy: true }];
120
+ }
121
+ if (e.type === 'response_item' && (p.type === 'function_call' || p.type === 'custom_tool_call')) {
122
+ return [{ kind: 'call', name: String(p.name ?? ''), callId: p.call_id || null, at,
123
+ input: typeof p.arguments === 'string' ? p.arguments : typeof p.input === 'string' ? p.input : '' }];
124
+ }
125
+ if (e.type === 'response_item' && (p.type === 'function_call_output' || p.type === 'custom_tool_call_output')) {
126
+ const text = typeof p.output === 'string' ? p.output : textOf(p.output);
127
+ return [{ kind: 'output', callId: p.call_id || null, at, exit: exitFromText(text), output: text }];
128
+ }
129
+ return [];
130
+ }
131
+
132
+ // Map each Codex tool call onto the Claude tool names route-scan's behavior
133
+ // pool counts, one name per call: lookup-only commands are lookups, anything
134
+ // else a shell command could have done is a run.
135
+ function behaviorName(ev) {
136
+ if (ev.kind === 'patch') return 'Edit';
137
+ if (ev.kind === 'mcp') return 'Mcp';
138
+ if (ev.kind !== 'command') return null;
139
+ const types = ev.parsed.map((c) => c?.type);
140
+ if (!types.length || types.includes('unknown')) return 'Bash';
141
+ if (types.every((t) => t === 'search')) return 'Grep';
142
+ if (types.every((t) => t === 'list_files')) return 'LS';
143
+ return 'Read';
144
+ }
145
+
146
+ /** Fill in a tool result only when no structured completion describes it. */
147
+ export function codexLegacyToolEvent(call, out) {
148
+ if (!/^(?:functions\.|tools\.)?(?:exec_command|shell_command|shell|exec|apply_patch)$/.test(call?.name || '')) return null;
149
+ if (/apply_patch$/.test(call.name)) return { ...out, kind: 'patch', failed: out.exit !== 0 };
150
+ let input;
151
+ try { input = JSON.parse(call.input); } catch { /* Older calls can carry plain commands. */ }
152
+ const command = input?.cmd ?? input?.command ?? call.input ?? '';
153
+ return { ...out, kind: 'command', argv: Array.isArray(command) ? command : [String(command)],
154
+ parsed: [], failed: out.exit !== 0, cwd: input?.workdir || null };
155
+ }
156
+
157
+ const emptyTurn = (turnId) => ({ turnId, startedAt: null, endedAt: null, aborted: false, model: null, effort: null, cwd: null,
158
+ text: '', userMessages: 0, calls: 0, out: 0, input: 0, cached: 0, cacheWrite: 0, tools: {}, mutating: 0, errors: 0,
159
+ delegated: 0, commands: [], patches: [], usageKnown: false });
160
+
161
+ /**
162
+ * One rollout as turns, for scans that need per-turn behavior rather than
163
+ * session totals. Legacy "Process exited" text only fills in a call no
164
+ * structured event described, so a call is never counted twice.
165
+ */
166
+ export async function parseCodexTurns(filePath, { cutoffMs = -Infinity } = {}) {
167
+ const session = { filePath, sessionId: null, projectDir: '', isSubagent: false, parentThreadId: null, provider: null, turns: [] };
168
+ const turns = new Map();
169
+ const turnOf = (id) => {
170
+ if (!id) return null;
171
+ if (!turns.has(id)) turns.set(id, emptyTurn(id));
172
+ return turns.get(id);
173
+ };
174
+ const seenCalls = new Set();
175
+ const calls = new Map();
176
+ const legacy = [];
177
+ const modernUsage = new Map();
178
+ let previousUsage = null;
179
+ let current = null;
180
+ let context = { model: null, effort: null, cwd: null };
181
+ const stream = createReadStream(filePath, { encoding: 'utf8' });
182
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
183
+ for await (const line of lines) {
184
+ let e;
185
+ try { e = JSON.parse(line); } catch { continue; }
186
+ const p = e?.payload;
187
+ if (!p || typeof p !== 'object') continue;
188
+ if (e.type === 'session_meta') {
189
+ session.sessionId = p.id || p.session_id || null;
190
+ session.projectDir = typeof p.cwd === 'string' ? p.cwd : '';
191
+ session.isSubagent = typeof p.source === 'object' && p.source !== null && 'subagent' in p.source;
192
+ session.parentThreadId = p.source?.subagent?.thread_spawn?.parent_thread_id || null;
193
+ session.provider = typeof p.model_provider === 'string' ? p.model_provider : null;
194
+ continue;
195
+ }
196
+ if (e.type === 'turn_context') {
197
+ context = { model: typeof p.model === 'string' ? p.model : context.model,
198
+ effort: typeof (p.effort ?? p.reasoning_effort) === 'string' ? (p.effort ?? p.reasoning_effort) : context.effort,
199
+ cwd: typeof p.cwd === 'string' ? p.cwd : context.cwd };
200
+ const t = turnOf(p.turn_id || current);
201
+ if (t) Object.assign(t, { model: context.model, effort: context.effort, cwd: context.cwd });
202
+ continue;
203
+ }
204
+ if (e.type === 'event_msg' && p.type === 'task_started') {
205
+ current = p.turn_id || null;
206
+ const t = turnOf(current);
207
+ if (t) {
208
+ t.startedAt ??= Number.isFinite(Number(p.started_at)) ? Number(p.started_at) * 1000 : Date.parse(e.timestamp);
209
+ t.model ??= context.model; t.effort ??= context.effort; t.cwd ??= context.cwd;
210
+ }
211
+ continue;
212
+ }
213
+ if (e.type === 'event_msg' && (p.type === 'task_complete' || p.type === 'turn_aborted')) {
214
+ const t = turnOf(p.turn_id || current);
215
+ if (t) {
216
+ t.endedAt = Number.isFinite(Number(p.completed_at)) ? Number(p.completed_at) * 1000 : Date.parse(e.timestamp);
217
+ if (p.type === 'turn_aborted') t.aborted = true;
218
+ }
219
+ continue;
220
+ }
221
+ if (e.type === 'token_usage_record') {
222
+ const t = turnOf(p.turn_id);
223
+ const u = p.turn_token_usage || p.usage;
224
+ if (t && Number.isSafeInteger(u?.input_tokens) && Number.isSafeInteger(u?.output_tokens)) {
225
+ const usage = { out: count(u.output_tokens), input: count(u.input_tokens),
226
+ cached: count(u.cached_input_tokens), cacheWrite: count(u.cache_write_input_tokens) };
227
+ const old = modernUsage.get(t.turnId);
228
+ if (!p.turn_token_usage && old) for (const key of Object.keys(usage)) usage[key] += old[key];
229
+ modernUsage.set(t.turnId, { ...usage, calls: (old?.calls || 0) + 1, usageKnown: true });
230
+ }
231
+ continue;
232
+ }
233
+ if (e.type === 'event_msg' && p.type === 'token_count') {
234
+ const u = p.info?.total_token_usage;
235
+ if (!Number.isSafeInteger(u?.input_tokens) || !Number.isSafeInteger(u?.output_tokens)) continue;
236
+ const usage = { input: count(u.input_tokens), out: count(u.output_tokens),
237
+ cached: count(u.cached_input_tokens), cacheWrite: count(u.cache_write_input_tokens) };
238
+ const reset = previousUsage && (usage.input < previousUsage.input || usage.out < previousUsage.out);
239
+ const base = reset ? null : previousUsage;
240
+ const delta = Object.fromEntries(Object.entries(usage).map(([key, value]) => [key, Math.max(0, value - (base?.[key] || 0))]));
241
+ previousUsage = usage;
242
+ const t = turnOf(p.turn_id || current);
243
+ if (t && (delta.input || delta.out)) {
244
+ for (const [key, value] of Object.entries(delta)) t[key] += value;
245
+ t.calls++;
246
+ t.usageKnown = true;
247
+ }
248
+ continue;
249
+ }
250
+ for (const ev of codexToolEvents(e)) {
251
+ if (ev.kind === 'call') {
252
+ if (ev.callId) calls.set(ev.callId, ev);
253
+ const t = turnOf(current);
254
+ if (t && /^(?:functions\.|tools\.)?spawn_agent$/.test(ev.name)) t.delegated++;
255
+ continue;
256
+ }
257
+ if (ev.kind === 'output') { if (ev.exit !== null && ev.callId) legacy.push({ ...ev, turnId: current }); continue; }
258
+ const t = turnOf(ev.turnId || current);
259
+ if (!t) continue;
260
+ if (ev.kind === 'user') {
261
+ t.userMessages++;
262
+ if (!t.text) t.text = ev.text.trim();
263
+ continue;
264
+ }
265
+ if (ev.callId) {
266
+ if (seenCalls.has(ev.callId)) continue;
267
+ seenCalls.add(ev.callId);
268
+ }
269
+ const name = behaviorName(ev);
270
+ if (name) t.tools[name] = (t.tools[name] || 0) + 1;
271
+ if (name === 'Bash' || name === 'Edit') t.mutating++;
272
+ if (ev.failed) t.errors++;
273
+ if (ev.kind === 'command') t.commands.push(ev);
274
+ if (ev.kind === 'patch') t.patches.push(ev);
275
+ }
276
+ }
277
+ for (const out of legacy) {
278
+ if (seenCalls.has(out.callId)) continue;
279
+ const t = turnOf(out.turnId);
280
+ const call = calls.get(out.callId);
281
+ // write_stdin/wait poll a command that is already running; only calls that
282
+ // start work count, or one long command becomes several.
283
+ const ev = codexLegacyToolEvent(call, out);
284
+ if (!t || !ev) continue;
285
+ seenCalls.add(out.callId);
286
+ const name = behaviorName(ev);
287
+ t.tools[name] = (t.tools[name] || 0) + 1;
288
+ t.mutating++;
289
+ if (ev.failed) t.errors++;
290
+ (ev.kind === 'patch' ? t.patches : t.commands).push(ev);
291
+ }
292
+ for (const [id, usage] of modernUsage) Object.assign(turns.get(id), usage);
293
+ session.turns = [...turns.values()].filter((t) => (t.startedAt ?? t.endedAt ?? 0) >= cutoffMs || !Number.isFinite(t.startedAt ?? t.endedAt));
294
+ return session;
295
+ }
296
+
297
+ /** Rollout JSONL is a best-effort local format, not a stable Codex API. */
298
+ export async function parseCodexSessionFile(filePath, { cutoffMs = -Infinity } = {}) {
299
+ const session = {
300
+ agent: 'codex', sessionId: null, filePath, projectDir: '', model: 'unknown',
301
+ startTime: null, endTime: null, requestCount: 0, totals: emptyTotals(),
302
+ maxContextPerRequest: 0, reasoningOutputTokens: 0, contextWindow: null,
303
+ lastContextTokens: null, rateLimits: null, lastActivity: null, effort: null,
304
+ cacheActivity: null,
305
+ };
306
+ let previous = null;
307
+ const stream = createReadStream(filePath, { encoding: 'utf8' });
308
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
309
+ for await (const line of lines) {
310
+ let e;
311
+ try { e = JSON.parse(line); } catch { continue; }
312
+ const p = e?.payload;
313
+ if (!p) continue;
314
+ updateSnapshot(session, e);
315
+ if (e.type !== 'event_msg' || p.type !== 'token_count') continue;
316
+ if (!p.info) continue;
317
+ const info = p.info;
318
+ const total = info.total_token_usage;
319
+ // Repeated token_count events also carry rate-limit updates. Only a
320
+ // change in cumulative usage represents newly consumed tokens.
321
+ if (!total || !Number.isSafeInteger(total.input_tokens) || !Number.isSafeInteger(total.output_tokens)) continue;
322
+ const current = {
323
+ input: count(total.input_tokens), cached: count(total.cached_input_tokens),
324
+ written: count(total.cache_write_input_tokens),
325
+ output: count(total.output_tokens), reasoning: count(total.reasoning_output_tokens),
326
+ };
327
+ const reset = previous && (current.input < previous.input || current.output < previous.output);
328
+ const base = reset || !previous ? { input: 0, cached: 0, written: 0, output: 0, reasoning: 0 } : previous;
329
+ const delta = Object.fromEntries(Object.keys(current).map((k) => [k, Math.max(0, current[k] - base[k])]));
330
+ previous = current;
331
+ if (!delta.input && !delta.output) continue;
332
+ const ts = Date.parse(e.timestamp);
333
+ // Quota-only snapshots repeat cumulative usage. Only newly observed cache
334
+ // reads/writes refresh the clock, never local tools or repeated snapshots.
335
+ if (Number.isFinite(ts) && delta.input > 0 && (delta.cached > 0 || delta.written > 0) &&
336
+ (!session.cacheActivity || ts >= session.cacheActivity.at.getTime())) {
337
+ session.cacheActivity = { at: new Date(ts), model: session.model, provider: session.provider,
338
+ cached: Math.min(delta.input, delta.cached), written: Math.min(delta.input, delta.written) };
339
+ }
340
+ if (!Number.isFinite(ts) || ts < cutoffMs) continue;
341
+ const cached = Math.min(delta.input, delta.cached);
342
+ session.totals.input += delta.input - cached;
343
+ session.totals.cacheRead += cached;
344
+ session.totals.output += delta.output;
345
+ // Reasoning is a subset of output, just as cached input is a subset of input.
346
+ session.reasoningOutputTokens += Math.min(delta.output, delta.reasoning);
347
+ session.requestCount++;
348
+ session.startTime ??= new Date(ts);
349
+ session.endTime = new Date(ts);
350
+ session.maxContextPerRequest = Math.max(session.maxContextPerRequest, count(info.last_token_usage?.input_tokens));
351
+ if (count(info.model_context_window)) session.contextWindow = info.model_context_window;
352
+ }
353
+ return session;
354
+ }
355
+
356
+ export async function discoverCodexSessionFiles({ home = codexUserDir(), days = 30, excludeSessionPath } = {}) {
357
+ const cutoff = Date.now() - days * 86400000;
358
+ const files = [];
359
+ const excluded = excludeSessionPath ? resolve(excludeSessionPath) : null;
360
+ async function walk(dir) {
361
+ let entries;
362
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
363
+ for (const entry of entries) {
364
+ const path = join(dir, entry.name);
365
+ if (entry.isDirectory()) await walk(path);
366
+ else if (entry.isFile() && entry.name.endsWith('.jsonl') && resolve(path) !== excluded) {
367
+ try {
368
+ const s = await stat(path);
369
+ if (s.mtimeMs >= cutoff) files.push({ path, mtime: s.mtimeMs, size: s.size });
370
+ } catch { /* A session may disappear during archival. */ }
371
+ }
372
+ }
373
+ }
374
+ await walk(join(home, 'sessions'));
375
+ await walk(join(home, 'archived_sessions'));
376
+ return files.sort((a, b) => a.mtime - b.mtime);
377
+ }
378
+
379
+ export async function parseAllCodexSessions(options = {}) {
380
+ const files = await discoverCodexSessionFiles(options);
381
+ const cutoffMs = Date.now() - (options.days ?? 30) * 86400000;
382
+ const results = [];
383
+ for (let i = 0; i < files.length; i += 10) {
384
+ const batch = await Promise.all(files.slice(i, i + 10).map(async ({ path }) => {
385
+ try { return await parseCodexSessionFile(path, { cutoffMs }); } catch { return null; }
386
+ }));
387
+ results.push(...batch.filter((s) => s?.requestCount && (!options.projectFilter || s.projectDir.includes(options.projectFilter))));
388
+ }
389
+ // An archive can briefly coexist with the original rollout.
390
+ const seen = new Set();
391
+ return results.reverse().filter((s) => {
392
+ const key = s.sessionId || s.filePath;
393
+ if (seen.has(key)) return false;
394
+ seen.add(key);
395
+ return true;
396
+ }).reverse();
397
+ }
@@ -0,0 +1,171 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { userDataDir } from './paths.js';
5
+ import { loadConfig } from './config.js';
6
+ import { findProjectRoot } from './harness.js';
7
+ import { discoverCodexSessionFiles, parseCodexTurns } from './codex-parser.js';
8
+ import { categorize, isSkippable, tierOf, calibrateThresholds, MIN_RECURRENCE,
9
+ 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 { codexRunCost, readCodexPrices } from './codex-cache-policy.js';
12
+ import { refreshCodexLedger } from './codex-ledger.js';
13
+
14
+ /*
15
+ * Codex route-scan: recurring easy turns become delegation-rule candidates.
16
+ * Kept apart from Claude's route-scan.json, model-rules.json and
17
+ * ratchet-model.md; approval goes through the Codex rule store with an
18
+ * explicit scope.
19
+ */
20
+
21
+ const cacheFile = (dir) => join(dir, 'codex-route-scan.json');
22
+ const MAX_CANDIDATES = 8;
23
+
24
+ export function readCodexRouteScan({ dir = userDataDir() } = {}) {
25
+ try {
26
+ const data = JSON.parse(readFileSync(cacheFile(dir), 'utf8'));
27
+ return data?.version === 1 && Array.isArray(data.candidates) ? data : null;
28
+ } catch { return null; }
29
+ }
30
+
31
+ function writeCache(data, dir) {
32
+ mkdirSync(dir, { recursive: true });
33
+ const file = cacheFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
34
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
35
+ renameSync(tmp, file);
36
+ }
37
+
38
+ /** Candidates not yet approved or dismissed; with `root`, only that project's. */
39
+ export function openCodexCandidates(cache, { root } = {}) {
40
+ if (!cache?.candidates) return [];
41
+ const resolved = new Set(cache.resolved || []);
42
+ const project = root ? resolve(root) : null;
43
+ return cache.candidates.filter((c) => !resolved.has(c.signature) && (!project || c.projectRoot === project));
44
+ }
45
+
46
+ export function findCodexCandidate(ref, { dir = userDataDir() } = {}) {
47
+ const m = /^R?(\d+)$/i.exec(String(ref || ''));
48
+ if (!m) return null;
49
+ return openCodexCandidates(readCodexRouteScan({ dir })).find((c) => c.id === Number(m[1])) || null;
50
+ }
51
+
52
+ /** Stop offering a candidate: it was approved or the user said no. */
53
+ export function resolveCodexCandidate(cand, { dir = userDataDir() } = {}) {
54
+ const cache = readCodexRouteScan({ dir });
55
+ if (!cache) return;
56
+ cache.resolved = [...new Set([...(cache.resolved || []), cand.signature])];
57
+ writeCache(cache, dir);
58
+ }
59
+
60
+ /** Same gate as Claude's: at most hourly, and only when enough new log data arrived. */
61
+ export async function shouldRescanCodex(cache, { home, days = 14, now = Date.now() } = {}) {
62
+ const ts = Date.parse(cache?.scannedAt);
63
+ if (!Number.isFinite(ts)) return true;
64
+ const age = now - ts;
65
+ if (age < RESCAN_MIN_INTERVAL_MS) return false;
66
+ let total = 0, anyNew = false;
67
+ try {
68
+ for (const f of await discoverCodexSessionFiles({ home, days })) {
69
+ total += f.size;
70
+ if (f.mtime > ts) anyNew = true;
71
+ }
72
+ } catch { return age >= RESCAN_MAX_AGE_MS; }
73
+ if (!anyNew) return false;
74
+ if (total - (cache.dataBytes || 0) >= RESCAN_BIG_DELTA_BYTES) return true;
75
+ return age >= RESCAN_MAX_AGE_MS;
76
+ }
77
+
78
+ /**
79
+ * Cheaper targets the user can actually run. "Known to work" means seen in
80
+ * this user's Codex sessions, set as the default delegate target, or already
81
+ * a rule target, so a gateway that also serves unrelated models (embeddings,
82
+ * other vendors) never has one suggested blind.
83
+ */
84
+ function pickTarget(group, { prices, known }) {
85
+ const cost = (model) => prices?.provider === group.provider ? codexRunCost(group.tokens, prices.prices[model]) : null;
86
+ const parent = cost(group.from);
87
+ if (parent === null) return { suggestedModel: null, alternatives: [], estSavedUsd: null };
88
+ const cheaper = Object.keys(prices.prices).filter((m) => m !== group.from && prices.prices[m])
89
+ .map((m) => ({ model: m, usd: cost(m) })).filter((x) => x.usd !== null && x.usd < parent)
90
+ .sort((a, b) => a.usd - b.usd);
91
+ const eligible = cheaper.filter((x) => known.has(`${group.provider}|${x.model}`));
92
+ // Simple work goes to the cheapest model; moderate work steps down one notch.
93
+ const pick = group.tiers.T2 >= group.tiers.T1 ? eligible[0] : eligible.at(-1);
94
+ return {
95
+ suggestedModel: pick?.model ?? null,
96
+ alternatives: pick ? [] : cheaper.slice(0, 3).map((x) => x.model),
97
+ estSavedUsd: pick ? parent - pick.usd : null,
98
+ };
99
+ }
100
+
101
+ export async function runCodexRouteScan({ days = 14, now = Date.now(), home, dir = userDataDir(),
102
+ prices = readCodexPrices({ now, home, dir }), cfg = loadConfig(), refreshLedger = true } = {}) {
103
+ const files = await discoverCodexSessionFiles({ home, days });
104
+ const cutoffMs = now - days * 86400000;
105
+ const episodes = [];
106
+ const observed = new Set();
107
+ const seen = new Set();
108
+ let dataBytes = 0;
109
+ for (const f of files.slice().reverse()) {
110
+ dataBytes += f.size;
111
+ let s;
112
+ try { s = await parseCodexTurns(f.path, { cutoffMs }); } catch { continue; }
113
+ const identity = s.sessionId || f.path;
114
+ if (seen.has(identity)) continue;
115
+ seen.add(identity);
116
+ // A model a subagent ran on is proven to work, even though its turns are
117
+ // the delegated work itself rather than requests to route.
118
+ for (const t of s.turns) if (t.model) observed.add(`${s.provider}|${t.model}`);
119
+ if (s.isSubagent) continue;
120
+ for (const t of s.turns) {
121
+ if (t.aborted || !Number.isFinite(t.endedAt) || !t.text || !t.model || isSkippable(t.text)) continue;
122
+ episodes.push({ ...t, provider: s.provider, projectRoot: findProjectRoot(t.cwd || s.projectDir || '.', { agent: 'codex' }) });
123
+ }
124
+ }
125
+ const thresholds = calibrateThresholds(episodes.map((e) => e.out));
126
+ const groups = new Map();
127
+ for (const ep of episodes) {
128
+ // A second user message mid-turn is steering, which is not a bounded task.
129
+ if (ep.userMessages > 1) continue;
130
+ // Group by the category the prompt hook would route, so an approved rule
131
+ // can fire. The tool mix only vetoes editing turns, which categorize()
132
+ // leaves unclassified.
133
+ const runtime = codexPromptCategory(ep.text);
134
+ if (!runtime || !categorize(ep.text, ep.tools)) continue;
135
+ const tier = tierOf(ep, runtime, thresholds);
136
+ if (tier !== 'T1' && tier !== 'T2') continue;
137
+ const key = `${runtime.id}|${ep.provider}|${ep.model}|${ep.projectRoot}`;
138
+ const g = groups.get(key) || { signature: key, category: runtime.id, label: runtime.label, labelEn: runtime.labelEn,
139
+ from: ep.model, provider: ep.provider, projectRoot: ep.projectRoot, count: 0, tiers: { T1: 0, T2: 0 }, example: ep.text,
140
+ tokens: { input: 0, cached: 0, cacheWrite: 0, output: 0 }, lastSeen: 0 };
141
+ g.count++;
142
+ g.tiers[tier]++;
143
+ if (ep.text.length < g.example.length) g.example = ep.text;
144
+ g.tokens.input += ep.input; g.tokens.cached += ep.cached; g.tokens.cacheWrite += ep.cacheWrite; g.tokens.output += ep.out;
145
+ g.lastSeen = Math.max(g.lastSeen, ep.endedAt ?? ep.startedAt ?? 0);
146
+ groups.set(key, g);
147
+ }
148
+ let rules = [];
149
+ try { rules = loadCodexModelRules({ dir }); } catch { /* An unreadable rule file only means candidates may repeat. */ }
150
+ const covered = (g) => rules.some((r) => r.category === g.category && r.from === g.from &&
151
+ (!r.provider || r.provider === g.provider) &&
152
+ (r.scope === 'global' || r.targetRoot === g.projectRoot));
153
+ 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}`);
155
+ const prev = readCodexRouteScan({ dir });
156
+ const resolved = new Set(prev?.resolved || []);
157
+ const candidates = [...groups.values()]
158
+ .filter((g) => g.count >= MIN_RECURRENCE && !covered(g) && !resolved.has(g.signature))
159
+ .sort((a, b) => b.count - a.count || b.lastSeen - a.lastSeen)
160
+ .slice(0, MAX_CANDIDATES)
161
+ .map((g, i) => ({ id: i + 1, signature: g.signature, category: g.category, label: g.label, labelEn: g.labelEn,
162
+ 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 }) }));
164
+ 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] };
166
+ writeCache(data, dir);
167
+ if (refreshLedger) {
168
+ try { await refreshCodexLedger({ dir, home, now, prices }); } catch { /* The ledger catches up on the next scan. */ }
169
+ }
170
+ return data;
171
+ }
@@ -22,10 +22,33 @@ export async function run({ hasFlag }) {
22
22
  process.exit(1);
23
23
  }
24
24
  const ctx = readStdinJson() || {};
25
+ const parts = [];
25
26
  try {
26
27
  const { runBrief } = await import('../brief.js');
27
28
  const out = await runBrief({ sessionId: ctx.session_id, transcriptPath: ctx.transcript_path });
28
- if (out) console.log(out);
29
+ if (out) parts.push(out);
29
30
  } catch (e) { debug('brief:hook', e); /* briefing is best-effort — never block a prompt */ }
31
+ // State the matching delegation rule as a fact about this request. Registered
32
+ // rules were going unused because applying one meant scanning a list and
33
+ // resolving it against a general "don't spawn subagents" instruction; naming
34
+ // the match here removes both steps. Silent unless it is sure — see
35
+ // src/route-inject.js.
36
+ try {
37
+ const { routeHint, sessionModelRank } = await import('../route-inject.js');
38
+ // The payload names the session but not its model, so the rank comes from
39
+ // the transcript: without it a Sonnet session is told to delegate to
40
+ // Sonnet, which saves nothing.
41
+ const sessionRank = sessionModelRank({ transcriptPath: ctx.transcript_path });
42
+ // `root` is the session's own cwd, not this process's. agentPhrase names the
43
+ // subagent only where `<root>/.claude/agents/<name>.md` exists, and with no
44
+ // root that lookup falls back to process.cwd() — whatever directory the hook
45
+ // was spawned in, which stops being the project root as soon as the two
46
+ // differ. Claude Code sends `cwd` for that reason and the PreToolUse hook
47
+ // already reads it (src/delegation-guard.js). A payload without the field
48
+ // leaves root undefined, which is the behaviour this had before.
49
+ const hint = routeHint(ctx.prompt, { sessionRank, root: ctx.cwd });
50
+ if (hint) parts.push(hint);
51
+ } catch (e) { debug('brief:route-hint', e); }
52
+ if (parts.length) console.log(parts.join('\n'));
30
53
  return;
31
54
  }
@@ -0,0 +1,59 @@
1
+ import { loadConfig, saveConfig } from '../config.js';
2
+ import { configureCodexHooks } from '../codex-installer.js';
3
+ import { validateCodexTarget, loadCodexModelRules, addCodexModelRule, removeCodexModelRule } from '../codex-delegation.js';
4
+ import { findCodexCandidate, resolveCodexCandidate } from '../codex-route-scan.js';
5
+ import { codexRoutingSavedTotals } from '../codex-ledger.js';
6
+ import { codexProviderName } from '../agent.js';
7
+
8
+ export function run({ args, getArg, hasFlag, root }) {
9
+ const sub = args[1] || 'status';
10
+ if (args.some((arg) => arg.startsWith('--hook'))) throw new Error('Use codex-hook for Codex hook payloads.');
11
+ for (const flag of ['--model', '--effort', '--from']) {
12
+ if (args.some((arg) => arg === flag || arg.startsWith(`${flag}=`)) && (!getArg(flag) || getArg(flag).startsWith('-'))) {
13
+ throw new Error(`${flag} requires a value.`);
14
+ }
15
+ }
16
+ const rules = loadCodexModelRules();
17
+ if (sub === 'rules') {
18
+ if (args[2] === 'add') {
19
+ if (hasFlag('--global') === hasFlag('--project')) throw new Error('Choose --global or --project for the rule.');
20
+ const scope = hasFlag('--project') ? 'project' : 'global';
21
+ // `add R<N>` approves a route-scan candidate; its project is where the pattern was seen.
22
+ const cand = /^R\d+$/i.test(args[3] || '') ? findCodexCandidate(args[3]) : null;
23
+ if (/^R\d+$/i.test(args[3] || '') && !cand) throw new Error(`No open Codex route candidate ${args[3]}. Run sprag route-scan --agent codex.`);
24
+ const model = getArg('--model') ?? cand?.suggestedModel;
25
+ if (cand && !model) throw new Error(`R${cand.id} has no priced target the user has run. Pass --model <id>${cand.alternatives?.length ? ` (priced cheaper: ${cand.alternatives.join(', ')})` : ''}.`);
26
+ const rule = addCodexModelRule({ category: cand?.category ?? args[3], model, effort: getArg('--effort'),
27
+ from: cand?.from ?? getArg('--from'), scope, root: cand?.projectRoot ?? root, provider: cand?.provider || codexProviderName() });
28
+ if (cand) resolveCodexCandidate(cand);
29
+ console.log(`Codex rule: ${rule.category} | ${rule.from} -> ${rule.model} | ${rule.scope}${rule.targetRoot ? ` ${rule.targetRoot}` : ''}`);
30
+ } else if (args[2] === 'rm') removeCodexModelRule(Number(args[3]));
31
+ else if (args[2]) throw new Error('Usage: delegate rules [add <category|R<N>> --from <model> --model <model> --global|--project | rm <N>]');
32
+ const rules = loadCodexModelRules();
33
+ 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}` : ''}`));
35
+ return;
36
+ }
37
+ if (!['on', 'off', 'status', 'model'].includes(sub)) throw new Error('Usage: delegate on|off|status|model <model>|rules --agent codex');
38
+ const cfg = loadConfig();
39
+ let target;
40
+ if (sub === 'model') target = args[2] === 'off' ? null : validateCodexTarget(args[2], getArg('--effort'));
41
+ else if (getArg('--model') !== undefined) target = validateCodexTarget(getArg('--model'), getArg('--effort'));
42
+ else if (hasFlag('--effort')) throw new Error('--effort requires a target model.');
43
+ if (target !== undefined && !['model', 'on'].includes(sub)) throw new Error('Set the target with delegate model or delegate on --model.');
44
+ if (sub === 'on') configureCodexHooks();
45
+ if (sub !== 'status') {
46
+ cfg.codex = { ...cfg.codex };
47
+ if (sub === 'on' || sub === 'off') cfg.codex.delegate = sub === 'on';
48
+ if (target !== undefined) cfg.codex.delegateTarget = target;
49
+ saveConfig(cfg);
50
+ }
51
+ console.log(`Codex delegate: ${cfg.codex?.delegate === true ? 'on' : 'off'} (model routing and guidance)`);
52
+ console.log(`Default target: ${cfg.codex?.delegateTarget?.model || 'inherit (no override)'}`);
53
+ console.log(`Codex rules: ${rules.length}`);
54
+ console.log('Explicit spawn models and custom roles are preserved. Targets must be available from your Codex provider.');
55
+ const saved = codexRoutingSavedTotals();
56
+ console.log(saved.priced
57
+ ? `Routing saved: $${saved.total.toFixed(4)} over ${saved.priced} priced run(s). Details: sprag route-scan savings --agent codex`
58
+ : `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
+ }