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,138 @@
1
+ import { createRequire } from 'node:module';
2
+ import { spawn } from 'node:child_process';
3
+ import { readFileSync, statSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { loadConfig, userLanguage } from './config.js';
6
+ import { koreanStyleInjection, koreanStyleEnabled } from './korean-style.js';
7
+ import { cohesionInjection } from './cohesion.js';
8
+ import { codexHarnessPaths } from './codex-harness.js';
9
+ import { findProjectRoot } from './harness.js';
10
+ import { runCodexBrief } from './codex-brief.js';
11
+ import { codexDocumentTool, codexDocumentNote, convertCodexDocument } from './codex-doc2md.js';
12
+ import { codexDelegationTool, codexRouteHint } from './codex-delegation.js';
13
+ import { seedOfferBlock } from './seed-rules.js';
14
+ import { readCodexRouteScan, openCodexCandidates, shouldRescanCodex } from './codex-route-scan.js';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const lint = require('./korean-lint.cjs');
18
+ const doc2md = require('./doc2md.cjs');
19
+
20
+ function context(event, text) {
21
+ return text ? { hookSpecificOutput: { hookEventName: event, additionalContext: text } } : null;
22
+ }
23
+
24
+ /**
25
+ * Project route candidates from the last scan. Never scans inline: session
26
+ * start must stay fast, so a stale scan is refreshed in a detached process
27
+ * and its results show up next session.
28
+ */
29
+ async function routeNotice(root, { spawnRescan = defaultRescan } = {}) {
30
+ const cache = readCodexRouteScan();
31
+ if (await shouldRescanCodex(cache)) spawnRescan();
32
+ const open = openCodexCandidates(cache, { root });
33
+ if (!open.length) return null;
34
+ return [`[Sprag route-scan] ${open.length} recurring delegable pattern(s) in this project. Mention them once; do not register without the user's scope decision.`,
35
+ ...open.slice(0, 3).map((c) => ` R${c.id} ${c.category} x${c.count}: ${c.from} -> ${c.suggestedModel || '(choose a model)'}`),
36
+ ' Review: sprag route-scan --agent codex | approve: sprag delegate rules add R<N> --global|--project --agent codex'].join('\n');
37
+ }
38
+
39
+ function defaultRescan() {
40
+ if (process.env.CTS_NO_ROUTE_SCAN === '1') return;
41
+ spawn(process.execPath, [process.argv[1], 'route-scan', '--refresh', '--quiet', '--agent', 'codex'],
42
+ { detached: true, stdio: 'ignore', windowsHide: true }).unref();
43
+ }
44
+
45
+ export function lintCodexTool(payload, { scope = 'all' } = {}) {
46
+ if (!payload || typeof payload !== 'object') return null;
47
+ const input = payload.tool_input;
48
+ const command = typeof input === 'string' ? input : input?.command ?? input?.cmd ?? input?.content ?? input?.patch;
49
+ const tool = payload.tool_name?.replace(/^(?:functions|tools)\./, '');
50
+ let paths = [];
51
+ if (['Bash', 'exec_command', 'shell_command', 'shell'].includes(tool)) paths = lint.writtenPathsOfBash(command);
52
+ else if (['apply_patch', 'Edit', 'Write'].includes(tool) && typeof command === 'string' && command.startsWith('*** Begin Patch')) {
53
+ // Patch headers are the path-bearing records of Codex's patch grammar.
54
+ // Check the final file, including move destinations, not diff markers.
55
+ paths = [...command.matchAll(/^\*\*\* (?:Add File|Update File|Move to): (.+)\r?$/gm)].map((m) => m[1].trimEnd());
56
+ } else return lint.lintToolUse(payload, { scope });
57
+ const messages = [];
58
+ for (const path of new Set(paths)) {
59
+ const file = resolve(payload.cwd || process.cwd(), typeof input?.workdir === 'string' ? input.workdir : '.', path);
60
+ if (!lint.isLintTarget(file, scope)) continue;
61
+ try {
62
+ const s = statSync(file);
63
+ if (!s.isFile() || s.size > 512 * 1024) continue;
64
+ let text = readFileSync(file, 'utf8');
65
+ if (lint.isHtmlFile(file)) text = lint.stripHtml(text);
66
+ const findings = lint.lintKoreanText(text, { code: !lint.isProseFile(file) });
67
+ if (findings.length) messages.push(lint.formatFindings(file, findings));
68
+ } catch { /* Deleted, moved, or unreadable files need no feedback. */ }
69
+ }
70
+ return messages.length ? messages.join('\n\n') : null;
71
+ }
72
+
73
+ export async function codexHookOutput(event, payload, { cfg = loadConfig() } = {}) {
74
+ if (!payload || typeof payload !== 'object') return null;
75
+ if (event === 'session-start' || event === 'subagent-start') {
76
+ if (event === 'subagent-start' && cfg?.codex?.delegate !== true) return null;
77
+ const root = findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' });
78
+ const parts = [];
79
+ // Keep the preset file current with this version's wording before it is read.
80
+ if (event === 'session-start') {
81
+ try { (await import('./preset-ratchet.js')).preparePresetRatchet({ agent: 'codex' }); } catch { /* Presets are optional. */ }
82
+ }
83
+ const { presetRatchetPath } = await import('./preset-ratchet.js');
84
+ for (const [scope, ratchet] of [['global', codexHarnessPaths(root, 'global').ratchet],
85
+ ['preset', presetRatchetPath({ agent: 'codex' })], ['project', codexHarnessPaths(root, 'project').ratchet]]) {
86
+ try {
87
+ if (statSync(ratchet).size > 32 * 1024) {
88
+ parts.push(`Read the ${scope} Sprag ratchet rules at ${JSON.stringify(ratchet)} before working; they exceed the automatic injection limit.`);
89
+ } else {
90
+ const text = readFileSync(ratchet, 'utf8').trim();
91
+ // An empty preset file is only its header; injecting it would spend
92
+ // tokens every session on no rules.
93
+ if (text && (scope !== 'preset' || /^- /m.test(text))) parts.push(`[Sprag Codex ratchet: ${scope}]\n${text}`);
94
+ }
95
+ } catch { /* No rules registered in this scope. */ }
96
+ }
97
+ parts.push(koreanStyleInjection({ cfg }), await cohesionInjection({ cfg }));
98
+ if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) parts.push(codexDocumentNote());
99
+ if (event === 'session-start' && payload.source !== 'compact') {
100
+ try { parts.push(seedOfferBlock({ root, agent: 'codex' })); } catch { /* Offers are optional. */ }
101
+ try { parts.push(await routeNotice(root)); } catch { /* A missing scan only delays candidates. */ }
102
+ }
103
+ if (event === 'subagent-start') parts.push(
104
+ '[Sprag Codex delegation]\nStay within the assigned task and any caller-provided budget. Return findings with file references and actual verification results; state unfinished work. Use only available Codex tools and configured roles. Do not infer a model tier or invent a tool-call budget.');
105
+ return context(event === 'subagent-start' ? 'SubagentStart' : 'SessionStart', parts.filter(Boolean).join('\n\n'));
106
+ }
107
+ if (event === 'prompt') {
108
+ const parts = [];
109
+ if (cfg?.codex?.delegate === true) {
110
+ try { parts.push(codexRouteHint(payload, { root: findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' }) })); }
111
+ catch { /* Invalid routing state must not suppress briefing or conversion. */ }
112
+ }
113
+ if (cfg?.codex?.brief !== false) {
114
+ try { parts.push(runCodexBrief({ sessionId: payload.session_id, transcriptPath: payload.transcript_path, cwd: payload.cwd })); }
115
+ catch { /* Local telemetry must not prevent document conversion. */ }
116
+ }
117
+ if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) {
118
+ parts.push(doc2md.contextForPrompt(payload, { lang: userLanguage(), agent: 'codex', convert: convertCodexDocument }));
119
+ }
120
+ return context('UserPromptSubmit', parts.filter(Boolean).join('\n\n'));
121
+ }
122
+ if (event === 'pre-tool') {
123
+ if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) {
124
+ const result = codexDocumentTool(payload);
125
+ if (result) return result;
126
+ }
127
+ return codexDelegationTool(payload, { cfg, root: findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' }) });
128
+ }
129
+ if (event === 'post-tool') {
130
+ if (!koreanStyleEnabled(cfg) || cfg?.koreanStyle?.lint === 'off') return null;
131
+ const result = lintCodexTool(payload, { scope: cfg?.koreanStyle?.lintScope === 'prose' ? 'prose' : 'all' });
132
+ const text = typeof result === 'string' ? result : result ? lint.formatFindings(result.filePath, result.findings) : null;
133
+ if (!text) return null;
134
+ if (cfg?.koreanStyle?.lint === 'warn') return context('PostToolUse', text);
135
+ return { decision: 'block', reason: text };
136
+ }
137
+ throw new Error(`Unknown Codex hook event: ${event}`);
138
+ }
@@ -0,0 +1,79 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { codexUserDir } from './agent.js';
4
+
5
+ export const CODEX_HANDLERS = {
6
+ SessionStart: { matcher: 'startup|resume|clear|compact', command: 'sprag codex-hook --agent codex --event session-start', timeout: 15 },
7
+ UserPromptSubmit: { command: 'sprag codex-hook --agent codex --event prompt', timeout: 600 },
8
+ PreToolUse: { matcher: '^(Bash|Read|read_file|Edit|Write|Agent|spawn_agent|mcp__filesystem__read_(?:text_)?file|(?:functions\\.|tools\\.)?(?:exec_command|shell_command|shell|apply_patch|spawn_agent))$', command: 'sprag codex-hook --agent codex --event pre-tool', timeout: 600 },
9
+ PostToolUse: { matcher: '^(Bash|apply_patch|Edit|Write|(?:functions\\.|tools\\.)?(?:exec_command|shell_command|shell|apply_patch))$', command: 'sprag codex-hook --agent codex --event post-tool', timeout: 15 },
10
+ SubagentStart: { command: 'sprag codex-hook --agent codex --event subagent-start', timeout: 15 },
11
+ };
12
+ const ownCommands = new Set(Object.values(CODEX_HANDLERS).map((h) => h.command));
13
+ const PANEL_COMMAND = 'sprag codex-hook --agent codex --event panel-start';
14
+ ownCommands.add(PANEL_COMMAND);
15
+
16
+ export function codexPanelAutoEnabled() {
17
+ const data = readHooks(join(codexUserDir(), 'hooks.json'));
18
+ return (data.hooks?.SessionStart || []).some((g) => g.hooks.some((h) => h?.command === PANEL_COMMAND));
19
+ }
20
+
21
+ export function readCodexHooks(file = join(codexUserDir(), 'hooks.json')) {
22
+ if (!existsSync(file)) return {};
23
+ const data = JSON.parse(readFileSync(file, 'utf8'));
24
+ if (!data || typeof data !== 'object' || Array.isArray(data) ||
25
+ (data.hooks !== undefined && (!data.hooks || typeof data.hooks !== 'object' || Array.isArray(data.hooks)))) {
26
+ throw new Error(`Invalid Codex hooks configuration: ${file}`);
27
+ }
28
+ for (const groups of Object.values(data.hooks || {})) {
29
+ if (!Array.isArray(groups) || groups.some((g) => !g || !Array.isArray(g.hooks))) {
30
+ throw new Error(`Invalid Codex hook groups: ${file}`);
31
+ }
32
+ }
33
+ return data;
34
+ }
35
+
36
+ const readHooks = readCodexHooks;
37
+
38
+ export function codexHookStatus() {
39
+ const data = readCodexHooks();
40
+ return Object.entries(CODEX_HANDLERS).map(([event, expected]) => {
41
+ const groups = data.hooks?.[event] || [];
42
+ // Async hooks cannot enforce write guards or lint feedback. Conversion hooks
43
+ // also need time for interpreter probes and multiple bounded conversions.
44
+ const registered = groups.some((group) => group.matcher === expected.matcher && group.hooks.some((hook) =>
45
+ hook?.type === 'command' && hook.command === expected.command && hook.enabled !== false && hook.async !== true &&
46
+ Number.isFinite(hook.timeout ?? 600) && (hook.timeout ?? 600) >= expected.timeout));
47
+ return { event, registered };
48
+ });
49
+ }
50
+
51
+ export function configureCodexHooks({ remove = false, panelAuto } = {}) {
52
+ const file = join(codexUserDir(), 'hooks.json');
53
+ const data = readHooks(file);
54
+ const before = JSON.stringify(data);
55
+ const auto = panelAuto ?? (data.hooks?.SessionStart || []).some((g) => g.hooks.some((h) => h?.command === PANEL_COMMAND));
56
+ data.hooks ||= {};
57
+ for (const [event, groups] of Object.entries(data.hooks)) {
58
+ data.hooks[event] = groups.map((g) => ({ ...g, hooks: g.hooks.filter((h) => !ownCommands.has(h?.command)) }))
59
+ .filter((g) => g.hooks.length);
60
+ if (!data.hooks[event].length) delete data.hooks[event];
61
+ }
62
+ if (!remove) {
63
+ for (const [event, { matcher, command, timeout }] of Object.entries(CODEX_HANDLERS)) {
64
+ const group = { hooks: [{ type: 'command', command, timeout }] };
65
+ if (matcher) group.matcher = matcher;
66
+ (data.hooks[event] ||= []).push(group);
67
+ }
68
+ if (auto) data.hooks.SessionStart.push({ matcher: 'startup|resume', hooks: [{ type: 'command', command: PANEL_COMMAND, timeout: 10 }] });
69
+ }
70
+ if (remove && !Object.keys(data.hooks).length) delete data.hooks;
71
+ if (JSON.stringify(data) !== before) {
72
+ mkdirSync(dirname(file), { recursive: true });
73
+ if (existsSync(file)) writeFileSync(`${file}.bak-${Date.now()}`, readFileSync(file));
74
+ const tmp = `${file}.${process.pid}.tmp`;
75
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
76
+ renameSync(tmp, file);
77
+ }
78
+ return { file, action: remove ? 'removed' : 'configured' };
79
+ }
@@ -0,0 +1,143 @@
1
+ import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { randomBytes, randomUUID } from 'node:crypto';
4
+ import { userDataDir } from './paths.js';
5
+ import { discoverCodexSessionFiles, parseCodexTurns } from './codex-parser.js';
6
+ import { codexRunCost, readCodexPrices } from './codex-cache-policy.js';
7
+
8
+ /*
9
+ * Codex routing savings, kept apart from Claude's delegation ledger.
10
+ *
11
+ * A child rollout only counts when Sprag itself routed it: the spawn rewrite
12
+ * writes a random route id into the child's message and records the same id
13
+ * here at that moment. The marker text alone proves nothing, since a model can
14
+ * write it by hand; the pending record is what shows a rule existed at spawn time.
15
+ */
16
+
17
+ export const ROUTE_ID_RE = /<!-- sprag:codex:route id=([0-9a-f]{16}) -->/;
18
+ const PENDING_TTL_MS = 7 * 86400000;
19
+ const pendingFile = (dir) => join(dir, 'codex-delegations.jsonl');
20
+ const ledgerFile = (dir) => join(dir, 'codex-delegation-ledger.json');
21
+
22
+ export function newRouteId() {
23
+ return randomBytes(8).toString('hex');
24
+ }
25
+
26
+ /** One append per spawn. Each line stays well under PIPE_BUF, so parallel spawns do not interleave. */
27
+ export function recordCodexDelegation(entry, { dir = userDataDir() } = {}) {
28
+ mkdirSync(dir, { recursive: true });
29
+ const line = JSON.stringify({ ...entry, at: entry.at ?? Date.now() });
30
+ if (line.length > 3500) throw new Error('Codex delegation record too large');
31
+ appendFileSync(pendingFile(dir), line + '\n', { mode: 0o600 });
32
+ }
33
+
34
+ export function readCodexDelegations({ dir = userDataDir() } = {}) {
35
+ let text;
36
+ try { text = readFileSync(pendingFile(dir), 'utf8'); } catch { return []; }
37
+ const out = [];
38
+ for (const line of text.split('\n')) {
39
+ try {
40
+ const r = JSON.parse(line);
41
+ if (r && /^[0-9a-f]{16}$/.test(r.id) && Number.isFinite(r.at) && typeof r.to === 'string') out.push(r);
42
+ } catch { /* Partial trailing line. */ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ export function loadCodexLedger({ dir = userDataDir() } = {}) {
48
+ try {
49
+ const data = JSON.parse(readFileSync(ledgerFile(dir), 'utf8'));
50
+ if (data?.version === 1 && data.events && typeof data.events === 'object') return data;
51
+ } catch { /* Missing or unreadable reads as empty. */ }
52
+ return { version: 1, events: {} };
53
+ }
54
+
55
+ function saveLedger(data, dir) {
56
+ mkdirSync(dir, { recursive: true });
57
+ const file = ledgerFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
58
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
59
+ renameSync(tmp, file);
60
+ }
61
+
62
+ /** The route id a child rollout carries, and its own tokens, or null. */
63
+ async function childRun(path, cutoffMs) {
64
+ const s = await parseCodexTurns(path, { cutoffMs });
65
+ if (!s.isSubagent) return null;
66
+ let id = null;
67
+ const tokens = { input: 0, cached: 0, cacheWrite: 0, output: 0 };
68
+ let model = null, startedAt = null, endedAt = null, complete = false, usageKnown = false, mixedModels = false;
69
+ for (const t of s.turns) {
70
+ // Only the child's own first message may carry the id; copied history does not count.
71
+ if (!id && t.text) { const m = ROUTE_ID_RE.exec(t.text); if (m) id = m[1]; }
72
+ tokens.input += t.input; tokens.cached += t.cached; tokens.cacheWrite += t.cacheWrite; tokens.output += t.out;
73
+ if (model && t.model && model !== t.model) mixedModels = true;
74
+ model ??= t.model;
75
+ usageKnown ||= t.usageKnown;
76
+ if (Number.isFinite(t.startedAt)) startedAt = Math.min(startedAt ?? t.startedAt, t.startedAt);
77
+ if (Number.isFinite(t.endedAt)) { endedAt = Math.max(endedAt ?? t.endedAt, t.endedAt); complete = !t.aborted; }
78
+ }
79
+ return id ? { id, sessionId: s.sessionId, parentThreadId: s.parentThreadId, provider: s.provider,
80
+ model, tokens, startedAt, endedAt, complete, usageKnown: usageKnown && !mixedModels } : null;
81
+ }
82
+
83
+ /**
84
+ * Join child rollouts to pending routes and price them. Savings hold the
85
+ * child's tokens constant and price them at the parent model, the same
86
+ * approximation Claude's ledger states. A route to a pricier model is a loss,
87
+ * shown as one rather than clamped away.
88
+ */
89
+ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date.now(), prices = readCodexPrices({ now, home, dir }) } = {}) {
90
+ const pending = readCodexDelegations({ dir }).filter((r) => now - r.at < PENDING_TTL_MS);
91
+ const ledger = loadCodexLedger({ dir });
92
+ if (!pending.length) return ledger;
93
+ const byId = new Map(pending.map((r) => [r.id, r]));
94
+ const oldest = Math.min(...pending.map((r) => r.at));
95
+ const files = await discoverCodexSessionFiles({ home, days: Math.max(1, (now - oldest) / 86400000 + 1) });
96
+ let changed = false;
97
+ const seen = new Set();
98
+ for (const f of files.slice().reverse()) {
99
+ if (f.mtime < oldest) continue;
100
+ let run;
101
+ try { run = await childRun(f.path, oldest - 60000); } catch { continue; }
102
+ const route = run && byId.get(run.id);
103
+ // The id must come from a spawn this parent made, after the record was written.
104
+ if (!route || (route.parentSessionId && run.parentThreadId && route.parentSessionId !== run.parentThreadId)) continue;
105
+ if (route.provider && route.provider !== run.provider) continue;
106
+ if (Number.isFinite(run.startedAt) && run.startedAt < route.at - 5000) continue;
107
+ if (seen.has(route.id)) continue;
108
+ seen.add(route.id);
109
+ const to = run.model || route.to;
110
+ const previous = ledger.events[route.id];
111
+ const provider = route.provider || run.provider;
112
+ const sameRun = previous?.childSessionId === run.sessionId && previous.to === to && previous.from === route.from;
113
+ const rates = sameRun && previous.rates ? previous.rates
114
+ : prices?.provider === provider ? { from: prices.prices[route.from], to: prices.prices[to], checkedAt: prices.checkedAt } : null;
115
+ const actual = rates && run.usageKnown ? codexRunCost(run.tokens, rates.to) : null;
116
+ const counterfactual = rates && run.usageKnown ? codexRunCost(run.tokens, rates.from) : null;
117
+ const usd = actual === null || counterfactual === null ? null : counterfactual - actual;
118
+ const event = { ts: run.endedAt ?? route.at, parentSessionId: route.parentSessionId || run.parentThreadId || null,
119
+ childSessionId: run.sessionId, source: route.source, category: route.category ?? null, scope: route.scope ?? null,
120
+ from: route.from, to, provider, tokens: run.tokens, usd, rates: usd === null ? null : rates,
121
+ priceCheckedAt: rates?.checkedAt ?? null, complete: run.complete };
122
+ // A transient price outage must not erase a completed, unchanged priced run.
123
+ if (usd === null && sameRun && previous.complete && Number.isFinite(previous.usd) &&
124
+ JSON.stringify(previous.tokens) === JSON.stringify(run.tokens)) continue;
125
+ if (JSON.stringify(ledger.events[route.id]) !== JSON.stringify(event)) { ledger.events[route.id] = event; changed = true; }
126
+ }
127
+ if (changed) saveLedger(ledger, dir);
128
+ return ledger;
129
+ }
130
+
131
+ /** Signed totals over priced runs; unpriced runs are counted, never summed as zero. */
132
+ export function codexRoutingSavedTotals({ dir = userDataDir(), now = Date.now() } = {}) {
133
+ const events = Object.values(loadCodexLedger({ dir }).events);
134
+ const totals = { week: 0, month: 0, total: 0, runs: events.length, priced: 0 };
135
+ for (const e of events) {
136
+ if (!Number.isFinite(e.usd)) continue;
137
+ totals.priced++;
138
+ totals.total += e.usd;
139
+ if (now - e.ts < 7 * 86400000) totals.week += e.usd;
140
+ if (now - e.ts < 30 * 86400000) totals.month += e.usd;
141
+ }
142
+ return totals;
143
+ }
@@ -0,0 +1,48 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { resolve } from 'node:path';
5
+ import { codexUserDir } from './agent.js';
6
+
7
+ const quote = (value) => `'${String(value).replace(/'/g, `'"'"'`)}'`;
8
+
9
+ export function panelLaunchCommand(root, { node = process.execPath, cli = fileURLToPath(new URL('../bin/cli.js', import.meta.url)), home = codexUserDir(), sessionId, sessionFile } = {}) {
10
+ return ['env', `CODEX_HOME=${home}`, node, cli, 'panel', '--agent', 'codex', '--project', resolve(root),
11
+ ...(sessionId ? ['--session', sessionId] : []), ...(sessionFile ? ['--session-file', sessionFile] : [])]
12
+ .map(quote).join(' ');
13
+ }
14
+
15
+ export function openCodexPanel(root, { platform = process.platform, exec = execFileSync, ...options } = {}) {
16
+ if (platform !== 'darwin') throw new Error('Automatic panel windows currently require macOS Terminal. Run sprag panel --agent codex in a separate terminal.');
17
+ const identity = [options.home || codexUserDir(), options.sessionId ? `session\0${options.sessionId}` : `project\0${resolve(root)}`].join('\0');
18
+ const title = `Sprag | Codex | ${createHash('sha256').update(identity).digest('hex').slice(0, 16)}`;
19
+ // Arguments travel through osascript argv, never through AppleScript source.
20
+ // Reuse only the same session (or an explicitly project-following window).
21
+ const script = `on run argv
22
+ set panelTitle to item 1 of argv
23
+ set panelCommand to item 2 of argv
24
+ tell application "Terminal"
25
+ repeat with w in windows
26
+ repeat with t in tabs of w
27
+ if custom title of t is panelTitle then
28
+ set miniaturized of w to false
29
+ set visible of w to true
30
+ set selected tab of w to t
31
+ set index of w to 1
32
+ activate
33
+ if busy of t then return "already-open"
34
+ do script panelCommand in t
35
+ return "restarted"
36
+ end if
37
+ end repeat
38
+ end repeat
39
+ set t to do script panelCommand
40
+ set custom title of t to panelTitle
41
+ activate
42
+ return "opened"
43
+ end tell
44
+ end run`;
45
+ return exec('/usr/bin/osascript', ['-e', script, title, panelLaunchCommand(root, options)], {
46
+ encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'pipe'],
47
+ }).trim();
48
+ }
@@ -0,0 +1,74 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir, homedir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { panelLaunchCommand } from './codex-panel-launcher.js';
7
+ import { createPanelBinding } from './codex-panel-session.js';
8
+
9
+ const quote = (s) => `'${String(s).replace(/'/g, `'"'"'`)}'`;
10
+
11
+ export function resizeInlinePanel(rows, { exec = execFileSync, env = process.env, state } = {}) {
12
+ if (env.SPRAG_CODEX_PANEL !== '1' || !env.TMUX_PANE || !env.TMUX) return;
13
+ try {
14
+ const info = exec('tmux', ['display-message', '-p', '-t', env.TMUX_PANE, '#{window_height} #{pane_height} #{window_width}'], { encoding: 'utf8', timeout: 1000 }).trim().split(/\s+/).map(Number);
15
+ const size = `${info[2]}x${info[0]}`;
16
+ // Keep the high-water mark until the terminal is resized. Timer labels and
17
+ // disappearing warnings must not repeatedly resize the prompt above us.
18
+ const contentRows = state && state.size === size ? Math.max(rows, state.rows) : rows;
19
+ const wanted = Math.max(1, Math.min(contentRows + 1, info[0] - 11));
20
+ if (state) { state.size = size; state.rows = contentRows; }
21
+ if (Number.isFinite(wanted) && wanted !== info[1]) exec('tmux', ['resize-pane', '-t', env.TMUX_PANE, '-y', String(wanted)], { stdio: 'ignore' });
22
+ } catch { /* A disappearing client must not stop the panel. */ }
23
+ }
24
+
25
+ export function createInlinePanelResizer(options = {}) {
26
+ const state = {};
27
+ return (rows) => resizeInlinePanel(rows, { ...options, state });
28
+ }
29
+
30
+ // A private server leaves the user's tmux configuration and sessions untouched.
31
+ export function runCodexWithPanel(args, { root = process.cwd(), codex = 'codex', tmux = 'tmux',
32
+ exec = execFileSync, spawn = spawnSync, tty = !!process.stdin.isTTY && !!process.stdout.isTTY,
33
+ env = process.env, columns = process.stdout.columns || 100, rows = process.stdout.rows || 30,
34
+ keepNativeStatusline = false } = {}) {
35
+ if (!tty) throw new Error('Panel run requires an interactive terminal. Use codex directly for scripts.');
36
+ const options = args.slice(0, args.indexOf('--') < 0 ? args.length : args.indexOf('--'));
37
+ if (options.some((arg) => arg === '--remote' || arg.startsWith('--remote='))) {
38
+ throw new Error('Inline panels require a local Codex process. For remote Codex, run codex directly and use a separate panel with --session ID and local logs.');
39
+ }
40
+ try { exec(tmux, ['-V'], { stdio: 'pipe' }); }
41
+ catch { throw new Error('The inline Codex panel requires tmux. Install it with brew install tmux on macOS.'); }
42
+ const socket = `sprag-${randomUUID()}`;
43
+ const base = ['-L', socket, '-f', '/dev/null'];
44
+ const dir = mkdtempSync(join(tmpdir(), 'sprag-codex-'));
45
+ const status = join(dir, 'exit');
46
+ const sessionFile = join(dir, 'session.json');
47
+ const home = resolve(env.CODEX_HOME || join(env.HOME || homedir(), '.codex'));
48
+ const childEnv = { ...env, TMUX: '', SPRAG_CODEX_PANEL: '1', SPRAG_CODEX_PANEL_BINDING: sessionFile };
49
+ // Hide duplicate model/effort only in this layout. Later caller overrides win;
50
+ // putting the default before args also preserves subcommands and `--` prompts.
51
+ const launchArgs = [...(options.includes('--no-daemon') ? [] : ['--no-daemon']),
52
+ ...(keepNativeStatusline ? [] : ['-c', 'tui.status_line=[]']), ...args];
53
+ const call = (argv) => exec(tmux, [...base, ...argv], { env: childEnv, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
54
+ try {
55
+ createPanelBinding(sessionFile);
56
+ const tmuxCommand = [tmux, ...base].map(quote).join(' ');
57
+ const command = `${tmuxCommand} wait-for ready; ${[codex, ...launchArgs].map(quote).join(' ')}; result=$?; printf '%s' "$result" > ${quote(status)}; ${tmuxCommand} kill-session -t codex`;
58
+ const top = call(['new-session', '-d', '-P', '-F', '#{pane_id}', '-s', 'codex', '-c', process.cwd(),
59
+ '-x', String(columns), '-y', String(Math.max(16, rows)), command]).trim();
60
+ call(['set-option', '-t', 'codex', 'status', 'off']);
61
+ call(['set-option', '-t', 'codex', 'destroy-unattached', 'off']);
62
+ call(['split-window', '-d', '-v', '-l', '6', '-t', top,
63
+ `${panelLaunchCommand(resolve(root), { home, sessionFile })} --compact`]);
64
+ call(['select-pane', '-t', top]);
65
+ call(['wait-for', '-S', 'ready']);
66
+ const attached = spawn(tmux, [...base, 'attach-session', '-t', 'codex'], { env: childEnv, stdio: 'inherit' });
67
+ if (attached.error) throw attached.error;
68
+ try { return Number(readFileSync(status, 'utf8')); }
69
+ catch { return attached.status || 0; }
70
+ } finally {
71
+ try { call(['kill-server']); } catch { /* Already gone after normal exit. */ }
72
+ rmSync(dir, { recursive: true, force: true });
73
+ }
74
+ }
@@ -0,0 +1,35 @@
1
+ import { readFileSync, writeFileSync, renameSync } from 'node:fs';
2
+ import { isAbsolute } from 'node:path';
3
+
4
+ export function createPanelBinding(file) {
5
+ writeFileSync(file, JSON.stringify({ version: 1, sessionId: null, transcriptPath: null }), { mode: 0o600 });
6
+ }
7
+
8
+ export function readPanelBinding(file) {
9
+ try {
10
+ const data = JSON.parse(readFileSync(file, 'utf8'));
11
+ if (data.version !== 1) return null;
12
+ if (typeof data.sessionId !== 'string' || !data.sessionId.trim()) return null;
13
+ return { sessionId: data.sessionId,
14
+ transcriptPath: typeof data.transcriptPath === 'string' && isAbsolute(data.transcriptPath) ? data.transcriptPath : null };
15
+ } catch { return null; }
16
+ }
17
+
18
+ // The inline runner uses --no-daemon so this path belongs to this Codex process,
19
+ // not an environment inherited by a shared server from a different terminal.
20
+ export function bindPanelSession(event, payload, { file = process.env.SPRAG_CODEX_PANEL_BINDING } = {}) {
21
+ if (!file || !['session-start', 'panel-start'].includes(event)) return false;
22
+ if (!['startup', 'resume', 'clear', 'compact'].includes(payload?.source)) return false;
23
+ if (typeof payload.session_id !== 'string' || !payload.session_id.trim()) return false;
24
+ try {
25
+ // Never recreate a binding after its launcher has exited and cleaned up.
26
+ const current = JSON.parse(readFileSync(file, 'utf8'));
27
+ if (current.version !== 1) return false;
28
+ if (payload.source === 'compact' && current.sessionId && current.sessionId !== payload.session_id) return false;
29
+ const tmp = `${file}.${process.pid}.tmp`;
30
+ writeFileSync(tmp, JSON.stringify({ version: 1, sessionId: payload.session_id,
31
+ transcriptPath: typeof payload.transcript_path === 'string' && isAbsolute(payload.transcript_path) ? payload.transcript_path : null }), { mode: 0o600 });
32
+ renameSync(tmp, file);
33
+ return true;
34
+ } catch { return false; }
35
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Sets up the Codex companion panel to run inline, inside the same terminal,
3
+ * instead of opening a separate macOS Terminal window. Codex's native footer
4
+ * cannot run a command, so the panel is the closest thing Codex has to a
5
+ * statusline; showing it below the Codex pane in tmux (via a zsh `codex()`
6
+ * wrapper) means a user does not get a new window per session. Install falls
7
+ * back to the separate window when tmux cannot be set up, the login shell is
8
+ * not zsh, or the user already has their own `codex` alias/function.
9
+ */
10
+
11
+ import { execFileSync } from 'node:child_process';
12
+ import { existsSync } from 'node:fs';
13
+ import { basename, dirname, join } from 'node:path';
14
+ import { installPanelShell } from './codex-panel-shell.js';
15
+
16
+ /** Whether a tmux binary answers `-V`. */
17
+ export function tmuxAvailable({ exec = execFileSync } = {}) {
18
+ try { exec('tmux', ['-V'], { stdio: 'pipe' }); return true; }
19
+ catch { return false; }
20
+ }
21
+
22
+ export function ensureTmux({ platform = process.platform, env = process.env, exec = execFileSync, exists = existsSync } = {}) {
23
+ // Checked first, before probing anything, so tests and CI never spawn brew.
24
+ if (env.CTS_NO_TMUX === '1' || (env.CI && env.CI !== 'false')) return { status: 'skipped' };
25
+ if (tmuxAvailable({ exec })) return { status: 'present' };
26
+ if (platform !== 'darwin') {
27
+ return { status: 'unavailable', detail: 'install tmux with your package manager (e.g. sudo apt install tmux)' };
28
+ }
29
+ let brew;
30
+ try { exec('brew', ['--prefix'], { stdio: 'pipe' }); brew = 'brew'; }
31
+ catch {
32
+ for (const p of ['/opt/homebrew/bin/brew', '/usr/local/bin/brew']) {
33
+ if (exists(p)) { brew = p; break; }
34
+ }
35
+ }
36
+ if (!brew) {
37
+ return { status: 'unavailable', detail: 'Homebrew not found; install tmux from https://github.com/tmux/tmux/wiki/Installing or MacPorts (sudo port install tmux)' };
38
+ }
39
+ try {
40
+ // brew update alone can take minutes; postinstall cannot afford that here.
41
+ exec(brew, ['install', 'tmux'], { stdio: 'inherit', timeout: 600_000,
42
+ env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1', HOMEBREW_NO_INSTALL_CLEANUP: '1' } });
43
+ } catch (e) {
44
+ return { status: 'failed', via: 'brew', detail: String(e.message).split('\n')[0] };
45
+ }
46
+ // No npm-package fallback: no official npm tmux exists, and a third-party
47
+ // prebuilt binary would be an unaudited supply-chain risk.
48
+ const fallback = join(dirname(brew === 'brew' ? '/opt/homebrew/bin/brew' : brew), 'tmux');
49
+ if (tmuxAvailable({ exec }) || exists(fallback)) return { status: 'installed', via: 'brew' };
50
+ return { status: 'failed', via: 'brew', detail: 'tmux not on PATH after brew install' };
51
+ }
52
+
53
+ export function setupCodexPanel({ platform = process.platform, env = process.env, config = {}, noPanel = false,
54
+ exec, exists, installShell = installPanelShell, tmux = ensureTmux } = {}) {
55
+ if (noPanel) return { panelAuto: false, mode: 'off' };
56
+ // Panel auto and the zsh integration are macOS-only today.
57
+ if (platform !== 'darwin') return { panelAuto: false, mode: 'off' };
58
+ const windowAuto = config?.codex?.panelAuto !== false;
59
+ if (config?.codex?.panelShell === false) {
60
+ return { panelAuto: windowAuto, mode: windowAuto ? 'window' : 'off', reason: 'shell integration turned off' };
61
+ }
62
+ if (basename(env.SHELL || '') !== 'zsh') {
63
+ return { panelAuto: windowAuto, mode: windowAuto ? 'window' : 'off', reason: 'login shell is not zsh' };
64
+ }
65
+ const t = tmux({ platform, env, ...(exec && { exec }), ...(exists && { exists }) });
66
+ if (!['present', 'installed'].includes(t.status)) {
67
+ return { panelAuto: windowAuto, mode: windowAuto ? 'window' : 'off', reason: t.detail || t.status, tmux: t };
68
+ }
69
+ let shellFile;
70
+ try { shellFile = installShell(); }
71
+ catch (e) {
72
+ return { panelAuto: windowAuto, mode: windowAuto ? 'window' : 'off', reason: e.message, tmux: t };
73
+ }
74
+ // The inline panel replaces the separate window by default; an explicit
75
+ // `panel auto on` keeps both.
76
+ return { panelAuto: config?.codex?.panelAuto === true, mode: 'inline', tmux: t, shellFile };
77
+ }
78
+
79
+ /** Formats a `setupCodexPanel` result as lines a caller prefixes and prints. */
80
+ export function describePanelSetup(result, lang = 'en') {
81
+ if (result.mode === 'off') return [];
82
+ if (result.mode === 'inline') {
83
+ const lines = [lang === 'ko'
84
+ ? `패널: tmux 인라인 (${result.shellFile}). 새 터미널에서 codex 를 실행하면 같은 창 아래쪽에 패널이 붙습니다.`
85
+ : `panel: inline tmux (${result.shellFile}). Open a new terminal and run codex; the panel attaches below it.`];
86
+ if (result.tmux?.status === 'installed') {
87
+ lines.push(lang === 'ko' ? 'tmux 를 Homebrew 로 설치했습니다.' : 'installed tmux with Homebrew.');
88
+ }
89
+ return lines;
90
+ }
91
+ const lines = [lang === 'ko'
92
+ ? `패널: 별도 Terminal 창${result.reason ? ` (${result.reason})` : ''}`
93
+ : `panel: separate Terminal window${result.reason ? ` (${result.reason})` : ''}`];
94
+ if (result.tmux?.detail && ['unavailable', 'failed'].includes(result.tmux.status)) {
95
+ lines.push(`tmux: ${result.tmux.detail}`);
96
+ }
97
+ return lines;
98
+ }