sprag-cli 3.67.0 → 3.69.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 (54) hide show
  1. package/README.ko.md +33 -66
  2. package/README.md +41 -74
  3. package/bin/cli.js +150 -62
  4. package/docs/CODEX.ko.md +32 -0
  5. package/docs/CODEX.md +34 -0
  6. package/package.json +15 -4
  7. package/src/{codex-brief.js → codex/brief.js} +7 -7
  8. package/src/{codex-budget.js → codex/budget.js} +2 -2
  9. package/src/{codex-cache-policy.js → codex/cache-policy.js} +9 -9
  10. package/src/{codex-delegation-diagnostics.js → codex/delegation-diagnostics.js} +2 -2
  11. package/src/{codex-delegation.js → codex/delegation.js} +11 -11
  12. package/src/{codex-doc2md-ledger.js → codex/doc2md-ledger.js} +5 -5
  13. package/src/{codex-doc2md.js → codex/doc2md.js} +4 -4
  14. package/src/{codex-doctor.js → codex/doctor.js} +4 -4
  15. package/src/{codex-harness.js → codex/harness.js} +5 -5
  16. package/src/{codex-hooks.js → codex/hooks.js} +20 -20
  17. package/src/{codex-installer.js → codex/installer.js} +2 -2
  18. package/src/{codex-ledger.js → codex/ledger.js} +6 -6
  19. package/src/{codex-panel-launcher.js → codex/panel-launcher.js} +2 -2
  20. package/src/{codex-panel-runner.js → codex/panel-runner.js} +2 -2
  21. package/src/{codex-panel-session.js → codex/panel-session.js} +1 -1
  22. package/src/{codex-panel-setup.js → codex/panel-setup.js} +1 -1
  23. package/src/{codex-panel-state.js → codex/panel-state.js} +3 -3
  24. package/src/{codex-panel.js → codex/panel.js} +43 -34
  25. package/src/{codex-parser.js → codex/parser.js} +5 -9
  26. package/src/{codex-reply-language.js → codex/reply-language.js} +2 -2
  27. package/src/{codex-route-scan.js → codex/route-scan.js} +10 -10
  28. package/src/{codex-rule-health.js → codex/rule-health.js} +4 -4
  29. package/src/commands/codex-delegate.js +6 -6
  30. package/src/commands/codex.js +44 -44
  31. package/src/commands/install.js +10 -7
  32. package/src/commands/korean.js +1 -1
  33. package/src/commands/litellm-budget.js +56 -0
  34. package/src/commands/saved.js +60 -5
  35. package/src/commands/uninstall.js +3 -3
  36. package/src/config.js +4 -0
  37. package/src/delegation-agents.js +13 -0
  38. package/src/first-run-note.js +16 -0
  39. package/src/formatters/statusline.js +725 -504
  40. package/src/formatters/table.js +33 -2
  41. package/src/glyphs.js +2 -0
  42. package/src/installer.js +77 -1
  43. package/src/model-alias.js +45 -60
  44. package/src/model-rules.js +25 -4
  45. package/src/model-share.js +136 -0
  46. package/src/parser.js +4 -13
  47. package/src/session-records.js +2 -8
  48. package/src/star-note.js +62 -0
  49. package/src/statusline-base.js +9 -3
  50. package/src/stdin-payload.js +1 -1
  51. package/src/transcript-lines.js +40 -0
  52. package/src/changelog.js +0 -70
  53. /package/src/{codex-cache.js → codex/cache.js} +0 -0
  54. /package/src/{codex-panel-shell.js → codex/panel-shell.js} +0 -0
package/bin/cli.js CHANGED
@@ -32,6 +32,7 @@ import { dirname, isAbsolute, join } from 'node:path';
32
32
 
33
33
  import {
34
34
  readStdinJson,
35
+ readStdinRaw,
35
36
  extractCaps,
36
37
  extractContextUsage,
37
38
  extractEffort,
@@ -58,6 +59,7 @@ import { chipForIssues } from '../src/advice.js';
58
59
  import { debug } from '../src/debug.js';
59
60
  import { createArgs } from '../src/cli-args.js';
60
61
  import { readUpdateChip } from '../src/update-notice.js';
62
+ import { readStarChip } from '../src/star-note.js';
61
63
  import { selectAgent } from '../src/agent.js';
62
64
 
63
65
  const selection = (() => {
@@ -87,12 +89,78 @@ function tableColor() {
87
89
  }
88
90
 
89
91
  // Subcommands this build knows how to run. Used only by the guard below.
92
+ /**
93
+ * `--segments a,b,c` as a list, or null for the default order. One parser for
94
+ * the demo, the populated line and the no-session line, so `--segments harness`
95
+ * means the same chip set on each of them.
96
+ */
97
+ function segmentsFromArgs() {
98
+ const raw = getArg('--segments');
99
+ return raw ? raw.split(',').map((s) => s.trim()).filter(Boolean) : null;
100
+ }
101
+
90
102
  const KNOWN_SUBCOMMANDS = new Set([
91
103
  'last', 'brief', 'history', 'handoff', 'install', 'uninstall', 'mode', 'korean', 'cohesion',
92
104
  'doc2md', 'delegate', 'harness', 'route-scan', 'compact-window', 'update-check', 'upgrade',
93
105
  'seed', 'litellm-budget', 'profile-map', 'feedback', 'saved', 'cowork',
94
106
  ]);
95
107
 
108
+ // Flags each entry path reads, collected from the hasFlag/getArg/numArg calls in
109
+ // this file, src/statusline-mode.js, src/hook.cjs and the install/uninstall
110
+ // commands. `value` flags take an argument (`--days 3` or `--days=3`); `bool`
111
+ // flags do not. --agent is not listed: selectAgent() removes it before `args`
112
+ // exists. Only the default path (report, statusline, hook management) and
113
+ // install/uninstall are checked; every other subcommand parses its own arguments.
114
+ // Keep these in step with the code that reads the flag: a flag read but not
115
+ // listed is rejected, and a flag listed but never read is silently ignored again.
116
+ const FLAG_SPECS = {
117
+ report: {
118
+ value: ['--format', '-f', '--project', '-p', '--days', '-d', '--hours', '--demo',
119
+ '--demo-cycle-sec', '--segments', '--exclude-session', '--threshold'],
120
+ bool: ['--statusline', '--verbose', '--no-verbose', '--compact', '--no-color', '--icon',
121
+ '--no-icon', '--text', '--narrow', '--no-timer', '--single-line', '--demo-label',
122
+ '--version', '-v', '--help', '-h', '--hook', '--hook-run', '--install-hook', '--uninstall-hook'],
123
+ },
124
+ install: { value: [], bool: ['--force', '--manual', '--yes', '--no-input', '--help', '-h'] },
125
+ // --yes and --no-input are the explicit spelling of "do not ask", and
126
+ // uninstall never asks, so they are accepted as no-ops.
127
+ uninstall: { value: [], bool: ['--purge', '--yes', '--no-input', '--help', '-h'] },
128
+ };
129
+
130
+ /** Which spec applies to this invocation, or null for a subcommand that parses its own arguments. */
131
+ function flagSpecFor(first) {
132
+ if (!first || first.startsWith('-')) return FLAG_SPECS.report;
133
+ return FLAG_SPECS[first] || null;
134
+ }
135
+
136
+ /**
137
+ * The flags in `argv` that `spec` does not list. A value flag swallows the next
138
+ * token the way getArg() does, so `--exclude-session --x` reads `--x` as a path,
139
+ * not as a flag, and both `--days 3` and `--days=3` are understood. A bool flag
140
+ * written `--no-color=1` is reported too: hasFlag() compares whole tokens, so
141
+ * that form was never honoured. Everything after a bare `--` is not a flag.
142
+ *
143
+ * @returns {string[]} the offending tokens as typed
144
+ */
145
+ function findUnknownFlags(argv, spec) {
146
+ const value = new Set(spec.value);
147
+ const bool = new Set(spec.bool);
148
+ const unknown = [];
149
+ for (let i = 0; i < argv.length; i++) {
150
+ const tok = argv[i];
151
+ if (tok === '--') break;
152
+ if (tok.length < 2 || !tok.startsWith('-')) continue;
153
+ const eq = tok.indexOf('=');
154
+ const name = eq === -1 ? tok : tok.slice(0, eq);
155
+ if (value.has(name)) {
156
+ if (eq === -1) i++;
157
+ } else if (!bool.has(name) || eq !== -1) {
158
+ unknown.push(tok);
159
+ }
160
+ }
161
+ return unknown;
162
+ }
163
+
96
164
  const USAGE = `sprag — Claude Code and Codex agent harness
97
165
 
98
166
  Usage:
@@ -171,6 +239,34 @@ async function main() {
171
239
  return;
172
240
  }
173
241
 
242
+ // Refuse what this build cannot honour, before anything is scanned or written.
243
+ // An unknown first word used to fall into the 30-day report, and an unknown
244
+ // flag was dropped without a word: `sprag install --dry-run` ran the real
245
+ // install and rewrote settings.json. Exit 2 is the usage-error code.
246
+ if (args[0] && !args[0].startsWith('-') && !KNOWN_SUBCOMMANDS.has(args[0])) {
247
+ console.error(`Unknown command: ${args[0]}`);
248
+ console.error('Run `sprag --help` for the list of commands.');
249
+ process.exitCode = 2;
250
+ return;
251
+ }
252
+ const flagSpec = flagSpecFor(args[0]);
253
+ const unknownFlags = flagSpec ? findUnknownFlags(args, flagSpec) : [];
254
+ if (unknownFlags.length > 0) {
255
+ // The statusline and the PostToolUse hook run from a fixed command in
256
+ // settings.json, and a typo or a flag from a newer release there must not
257
+ // blank the line or fail the hook. Warn and carry on; every other caller is
258
+ // a person at a prompt who is better told.
259
+ const lenient = hasFlag('--statusline') || getArg('--format') === 'statusline' || hasFlag('--hook-run');
260
+ if (lenient) {
261
+ for (const f of unknownFlags) console.error(`Warning: Unknown option: ${f} (ignored)`);
262
+ } else {
263
+ for (const f of unknownFlags) console.error(`Unknown option: ${f}`);
264
+ console.error('Run `sprag --help` for usage.');
265
+ process.exitCode = 2;
266
+ return;
267
+ }
268
+ }
269
+
174
270
  // Subcommand: last — print the most recent warning + how to handle it.
175
271
  // Designed for the auto-trigger skill so the user immediately sees
176
272
  // "what just fired and how to fix it" without having to read the whole
@@ -346,56 +442,9 @@ async function main() {
346
442
  }
347
443
 
348
444
  // Subcommand: litellm-budget · LiteLLM 게이트웨이 키의 max_budget/spend 조회.
349
- // sprag litellm-budget # 캐시된 예산을 게이지·금액으로 출력
350
- // sprag litellm-budget --json # 캐시 원본 JSON 출력
351
- // sprag litellm-budget --refresh # 지금 프록시에 물어봄 (detached 자식이 사용)
445
+ // sprag litellm-budget [--json | --refresh]
352
446
  if (args[0] === 'litellm-budget') {
353
- const { gatewayBase, readBudgetState, refreshBudgetState, formatBudgetReport, resolveKey } =
354
- await import('../src/litellm-budget.js');
355
- const quiet = hasFlag('--quiet');
356
- if (hasFlag('--refresh')) {
357
- try {
358
- // apiKeyHelper 가 토큰 수명(TTL)을 소유합니다. 헬퍼를 한 번만 불러 그
359
- // 키를 예산 갱신과 게이트웨이 모델맵 갱신 양쪽에 재사용합니다. 갱신
360
- // 경로마다 resolveKey() 를 다시 부르면 사본을 만드는 셈이 되고, 헬퍼
361
- // 호출 횟수도 그만큼 늘어납니다.
362
- const key = resolveKey();
363
- let next = null;
364
- if (key) {
365
- next = await refreshBudgetState(process.env, fetch, key);
366
- const { maybeRefreshGatewayModelMap } = await import('../src/litellm-models.js');
367
- await maybeRefreshGatewayModelMap({ base: gatewayBase(), key });
368
- }
369
- if (!quiet) console.log(JSON.stringify(next, null, 2));
370
- } catch (e) {
371
- debug('litellm-budget:refresh', e);
372
- if (!quiet) console.error(`litellm-budget refresh failed: ${e.message}`);
373
- process.exitCode = 1;
374
- }
375
- return;
376
- }
377
- // 읽기 경로는 캐시만 보므로 키를 요구하지 않습니다 (apiKeyHelper 환경 배려).
378
- const base = gatewayBase();
379
- if (!base) {
380
- console.log('게이트웨이가 감지되지 않았습니다 (ANTHROPIC_BASE_URL 필요).');
381
- return;
382
- }
383
- const state = readBudgetState();
384
- if (hasFlag('--json')) {
385
- console.log(JSON.stringify(state, null, 2));
386
- return;
387
- }
388
- if (state.base !== base || state.maxBudget === undefined) {
389
- console.log('예산 캐시가 비어 있습니다. `sprag litellm-budget --refresh` 로 먼저 조회하십시오.');
390
- return;
391
- }
392
- // Same resolver the statusline uses: this report draws a gauge too, and a
393
- // glyph the terminal's font lacks garbles here exactly as it does there.
394
- const { resolveLabelMode } = await import('../src/statusline-mode.js');
395
- const { statuslineDefaults } = await import('../src/config.js');
396
- const { mode: labelMode } = resolveLabelMode({ hasFlag, cfg: statuslineDefaults() });
397
- for (const line of formatBudgetReport(state, new Date(), labelMode)) console.log(line);
398
- return;
447
+ return (await import('../src/commands/litellm-budget.js')).run({ hasFlag });
399
448
  }
400
449
 
401
450
  // Subcommand: profile-map · LiteLLM 게이트웨이의 프로파일 ID → 모델 별칭
@@ -484,10 +533,7 @@ async function main() {
484
533
  : (hasFlag('--no-verbose') || hasFlag('--compact') ? false : cfg.verbose);
485
534
  const showTimer = hasFlag('--no-timer') ? false : cfg.timer;
486
535
  const colorOk = !hasFlag('--no-color') && !process.env.NO_COLOR && cfg.color;
487
- const segmentsArg = getArg('--segments');
488
- const segments = segmentsArg
489
- ? segmentsArg.split(',').map((s) => s.trim()).filter(Boolean)
490
- : null;
536
+ const segments = segmentsFromArgs();
491
537
  const out = formatReport(data, {
492
538
  color: colorOk,
493
539
  verbose: isVerbose,
@@ -591,8 +637,8 @@ async function main() {
591
637
  const { withBaseStatusline } = await import('../src/statusline-base.js');
592
638
  console.log(withBaseStatusline(formatNoSession(
593
639
  { caps, model, effort, windowLabel, version: PKG_VERSION, update: readUpdateChip(PKG_VERSION) },
594
- { color: colorOk, mode: labelMode },
595
- )));
640
+ { color: colorOk, mode: labelMode, segments: segmentsFromArgs() },
641
+ ), { input: readStdinRaw() }));
596
642
  return;
597
643
  }
598
644
  // JSON mode stays JSON. A caller that asked for machine-readable output
@@ -768,12 +814,13 @@ async function main() {
768
814
  // 이미 파싱한 세션을 재사용하고, 사용자가 창을 줄여 둔 경우(mode 1d 등)에만
769
815
  // 월초까지 다시 파싱한다. 파싱 결과는 세션 캐시가 받아 주므로 싸다.
770
816
  let monthSpendInfo = null;
817
+ let monthSessions = null;
771
818
  if (isStatusline) {
772
819
  try {
773
820
  const { monthSpend, monthStartMs } = await import('../src/month-spend.js');
774
821
  const since = monthStartMs();
775
822
  const daysNeeded = (Date.now() - since) / 86400000;
776
- const monthSessions = days >= daysNeeded
823
+ monthSessions = days >= daysNeeded
777
824
  ? sessions
778
825
  : await parseAllSessions({ days: Math.max(1, Math.ceil(daysNeeded)), projectFilter, sinceMs: since });
779
826
  // 월 경계에 걸친 세션은 월초 이후 요청만 남긴다. 그러지 않으면 1일 자정을
@@ -784,6 +831,22 @@ async function main() {
784
831
  }
785
832
  }
786
833
 
834
+ // 모델별 사용 비중: `sprag saved` 와 같은 기간(라우팅 초기화 기록, 없으면 월초)의
835
+ // 비용 기준 분포. 이미 파싱한 월간 세션을 재사용한다. 실패하면 칩만 빠진다.
836
+ let modelShare = null;
837
+ if (isStatusline) {
838
+ try {
839
+ const { statuslineDefaults } = await import('../src/config.js');
840
+ if (statuslineDefaults().modelShare) {
841
+ const { loadModelShare } = await import('../src/model-share.js');
842
+ const { userDataDir } = await import('../src/paths.js');
843
+ modelShare = await loadModelShare({ dir: userDataDir(), env: process.env, projectFilter, monthSessions: monthSessions || undefined });
844
+ }
845
+ } catch (e) {
846
+ debug('model-share', e);
847
+ }
848
+ }
849
+
787
850
  // Rolling week/month/lifetime totals from the delegation ledger — the
788
851
  // statusline's headline line. Falls back to null (chip hidden) on any error.
789
852
  // A lookup, never a scan: the statusline re-renders every few seconds and a
@@ -878,8 +941,14 @@ async function main() {
878
941
  options: { days, windowHours, windowLabel, version: PKG_VERSION },
879
942
  // Cached-only; the background refresh it may trigger lands on a later render.
880
943
  update: format === 'statusline' ? readUpdateChip(PKG_VERSION) : null,
944
+ // The totals are already in hand above; passing them spares a second read of
945
+ // both ledgers on every render.
946
+ star: format === 'statusline'
947
+ ? readStarChip({ savedUsd: delegationSaved + (Number(doc2mdTotals?.total) || 0) })
948
+ : null,
881
949
  lastActivity,
882
950
  monthSpend: monthSpendInfo,
951
+ modelShare,
883
952
  spikeReport,
884
953
  contextWindow,
885
954
  ctxLive,
@@ -922,10 +991,7 @@ async function main() {
922
991
  const colorOk =
923
992
  !hasFlag('--no-color') && !process.env.NO_COLOR && cfg.color;
924
993
 
925
- const segmentsArg = getArg('--segments');
926
- const segments = segmentsArg
927
- ? segmentsArg.split(',').map((s) => s.trim()).filter(Boolean)
928
- : null;
994
+ const segments = segmentsFromArgs();
929
995
  output = formatReport(data, {
930
996
  color: colorOk,
931
997
  verbose: isVerbose,
@@ -938,13 +1004,35 @@ async function main() {
938
1004
  singleLine: hasFlag('--single-line'),
939
1005
  });
940
1006
  } else {
1007
+ // The first screen is the pitch: what share of recent work a cheaper model
1008
+ // could take. The scan is local and writes only sprag's own cache file;
1009
+ // --project skips it because the scan covers all projects, not one.
1010
+ let routing = null;
1011
+ if (!projectFilter && process.env.CTS_NO_ROUTE_SCAN !== '1') {
1012
+ try {
1013
+ const rs = await import('../src/route-scan.js');
1014
+ let cache = rs.readRouteScan();
1015
+ if (!cache) {
1016
+ process.stderr.write('Scanning for delegation candidates (first run)...\n');
1017
+ cache = await rs.runRouteScan({ days: 14 });
1018
+ }
1019
+ routing = {
1020
+ days: cache.days,
1021
+ total: cache.totalEpisodes,
1022
+ easy: cache.easyEpisodes,
1023
+ scannedAt: cache.scannedAt,
1024
+ candidates: rs.openCandidates(cache).slice(0, 3),
1025
+ };
1026
+ } catch (e) { debug('report:routing', e); }
1027
+ }
941
1028
  const { formatReport } = await import('../src/formatters/table.js');
942
- output = formatReport(data, { color: tableColor() });
1029
+ output = formatReport({ ...data, routing }, { color: tableColor() });
943
1030
  }
944
1031
 
945
1032
  if (format === 'statusline') {
946
1033
  const { withBaseStatusline } = await import('../src/statusline-base.js');
947
- output = withBaseStatusline(output);
1034
+ // The payload was read above; the base gets the same text, not a second read.
1035
+ output = withBaseStatusline(output, { input: readStdinRaw() });
948
1036
  }
949
1037
  console.log(output);
950
1038
  }
package/docs/CODEX.ko.md CHANGED
@@ -38,6 +38,38 @@ macOS zsh 환경에서는 설치가 tmux 설치 여부를 먼저 확인합니다
38
38
  `sprag panel auto off --agent codex`로 끈 설정은 재설치해도 유지합니다.
39
39
  패널 실행 여부는 `sprag panel doctor --agent codex`로 확인하십시오.
40
40
 
41
+ ## 명령 한눈에 보기
42
+
43
+ 명령의 기본 대상은 Claude Code입니다. `--agent codex`를 붙이면 Codex를 고릅니다.
44
+
45
+ ```sh
46
+ npm i -g sprag-cli --ignore-scripts # Claude Code를 건드리지 않는 Codex 전용 설치
47
+ sprag install --agent codex
48
+ sprag --agent codex --days 7
49
+ sprag panel --agent codex # 별도 터미널에서 실시간 패널 실행
50
+ sprag --statusline --agent codex --text
51
+ sprag doctor --agent codex # 문제가 생겼을 때만
52
+ sprag route-scan --agent codex
53
+ sprag seed --agent codex
54
+ ```
55
+
56
+ 설치 후 Codex의 `/hooks`에서 새 훅을 검토하고 신뢰해야 실행됩니다. Codex가 요구하는
57
+ 절차입니다. Codex 기본 상태줄은 셸 명령을 실행하지 않으므로, Sprag는 터미널 상태줄과 패널을
58
+ 따로 제공합니다. 기능별 지원 여부는 `sprag capabilities --agent codex`로 확인하십시오.
59
+
60
+ ## Claude Code와 Codex 비교
61
+
62
+ | 기능 | Claude Code | Codex |
63
+ |---|---|---|
64
+ | 세션 지침 | `CLAUDE.md`와 훅 | `AGENTS.md`와 기본 훅 |
65
+ | 실시간 상태 | 기본 상태줄에 명령 등록 | 별도 패널 또는 tmux 구성 |
66
+ | 규칙 후보 | Claude 기록과 모델 등급 프리셋 | Codex 기록과 호환 래칫 프리셋 |
67
+ | 모델 위임 | Haiku·Sonnet 등급 규칙 | 공급자 모델 ID와 범위별 규칙 |
68
+ | 위임 원장 | Claude 실행별 예상 차액 | LiteLLM 단가와 하위 실행별 예상 차액 |
69
+
70
+ 두 연동을 함께 설치할 수 있습니다. `--agent codex`를 붙이지 않으면 기존 Claude 동작을 유지합니다.
71
+ README의 금액 수치는 Claude Code 세션 기록에서 나온 값이며 Codex 벤치마크가 아닙니다.
72
+
41
73
  ## 지원 기능
42
74
 
43
75
  | 기능 | Codex 동작 |
package/docs/CODEX.md CHANGED
@@ -72,6 +72,40 @@ fails, the preference stays unchanged. Review new or changed definitions in
72
72
  `/hooks`; registration does not grant trust. Turning a feature off keeps the
73
73
  shared hooks needed by other features.
74
74
 
75
+ ## Command Quick Reference
76
+
77
+ Commands default to Claude Code. Add `--agent codex` to select Codex.
78
+
79
+ ```sh
80
+ npm i -g sprag-cli --ignore-scripts # Codex-only install, leaves Claude Code alone
81
+ sprag install --agent codex
82
+ sprag --agent codex --days 7
83
+ sprag panel --agent codex # live companion in a separate terminal
84
+ sprag --statusline --agent codex --text
85
+ sprag doctor --agent codex # troubleshooting only
86
+ sprag route-scan --agent codex
87
+ sprag seed --agent codex
88
+ ```
89
+
90
+ Review and trust the new hooks in Codex's `/hooks` menu; Codex requires that
91
+ step. The native Codex footer does not accept shell commands, so Sprag provides
92
+ a terminal statusline and a panel instead. Run `sprag capabilities --agent codex`
93
+ for the support matrix.
94
+
95
+ ## Claude Code And Codex Compared
96
+
97
+ | Workflow | Claude Code | Codex |
98
+ |---|---|---|
99
+ | Session instructions | `CLAUDE.md` and hooks | `AGENTS.md` and native hooks |
100
+ | Live telemetry | Native statusline command | Companion panel or tmux layout |
101
+ | Rule candidates | Claude transcripts and tier presets | Codex rollouts and compatible ratchet presets |
102
+ | Model routing | Haiku/Sonnet tier rules | Explicit provider model IDs and scoped rules |
103
+ | Routing ledger | Claude run estimates | Attributed child estimates with LiteLLM prices |
104
+
105
+ Both integrations coexist. Commands without `--agent codex` retain Claude behavior.
106
+ The dollar figures in the README come from Claude Code sessions, not from a Codex
107
+ benchmark.
108
+
75
109
  ## Supported Workflows
76
110
 
77
111
  | Feature | Codex behavior |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.67.0",
4
- "description": "Lower your Claude Code bill without changing how you work: simple tasks go to cheaper models, documents go in as compact Markdown, and the statusline shows spend and savings live. Works with Codex too.",
3
+ "version": "3.69.0",
4
+ "description": "Model-delegation router for Claude Code and Codex: lookups, checks and test runs go to cheaper models, documents go in as compact Markdown, and a live statusline shows spend and savings.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "sprag": "bin/cli.js",
@@ -34,7 +34,7 @@
34
34
  "NOTICE"
35
35
  ],
36
36
  "engines": {
37
- "node": ">=18"
37
+ "node": ">=20"
38
38
  },
39
39
  "keywords": [
40
40
  "claude-code",
@@ -49,10 +49,21 @@
49
49
  "claude-code-statusline",
50
50
  "statusline",
51
51
  "model-routing",
52
+ "model-router",
53
+ "model-delegation",
54
+ "llm-router",
55
+ "claude-code-router",
52
56
  "subagent",
53
57
  "prompt-caching",
54
58
  "haiku",
55
- "cli"
59
+ "cli",
60
+ "token",
61
+ "tokens",
62
+ "cost",
63
+ "llm-cost",
64
+ "cost-optimization",
65
+ "openai-codex",
66
+ "pptx-to-markdown"
56
67
  ],
57
68
  "license": "Apache-2.0",
58
69
  "repository": {
@@ -1,13 +1,13 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readFileSync, mkdirSync, readdirSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
- import { codexUserDir } from './agent.js';
5
- import { userDataDir } from './paths.js';
6
- import { writeViaTmp } from './state-file.js';
7
- import { readCodexSnapshot } from './codex-parser.js';
8
- import { loadCodexModelRules } from './codex-delegation.js';
9
- import { loadCodexLedger } from './codex-ledger.js';
10
- import { codexRulesInReview, codexSharedRuleHealth } from './codex-rule-health.js';
4
+ import { codexUserDir } from '../agent.js';
5
+ import { userDataDir } from '../paths.js';
6
+ import { writeViaTmp } from '../state-file.js';
7
+ import { readCodexSnapshot } from './parser.js';
8
+ import { loadCodexModelRules } from './delegation.js';
9
+ import { loadCodexLedger } from './ledger.js';
10
+ import { codexRulesInReview, codexSharedRuleHealth } from './rule-health.js';
11
11
 
12
12
  const RESET_TOLERANCE_S = 300;
13
13
  const hash = (value) => createHash('sha256').update(value).digest('hex');
@@ -3,8 +3,8 @@ import { join } from 'node:path';
3
3
  import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
  import { parse } from 'smol-toml';
6
- import { codexUserDir } from './agent.js';
7
- import { pickBudgetSource } from './litellm-budget.js';
6
+ import { codexUserDir } from '../agent.js';
7
+ import { pickBudgetSource } from '../litellm-budget.js';
8
8
 
9
9
  const run = promisify(execFile);
10
10
 
@@ -2,15 +2,15 @@ import { readFileSync, mkdirSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
4
  import { parse } from 'smol-toml';
5
- import { codexUserDir } from './agent.js';
6
- import { userDataDir } from './paths.js';
7
- import { writeViaTmp } from './state-file.js';
8
- import { codexBudgetProvider, codexProviderKey } from './codex-budget.js';
9
- import { codexCachePolicy } from './codex-cache.js';
10
- import { PRICE_MAX_AGE_MS, gatewayPrices } from './gateway-prices.js';
11
- import { OPENAI_LIST_PRICES, OPENAI_PRICES_CHECKED_AT } from './openai-prices.js';
12
-
13
- export { PRICE_MAX_AGE_MS, rate, deploymentPrice, gatewayPrices, codexRunCost } from './gateway-prices.js';
5
+ import { codexUserDir } from '../agent.js';
6
+ import { userDataDir } from '../paths.js';
7
+ import { writeViaTmp } from '../state-file.js';
8
+ import { codexBudgetProvider, codexProviderKey } from './budget.js';
9
+ import { codexCachePolicy } from './cache.js';
10
+ import { PRICE_MAX_AGE_MS, gatewayPrices } from '../gateway-prices.js';
11
+ import { OPENAI_LIST_PRICES, OPENAI_PRICES_CHECKED_AT } from '../openai-prices.js';
12
+
13
+ export { PRICE_MAX_AGE_MS, rate, deploymentPrice, gatewayPrices, codexRunCost } from '../gateway-prices.js';
14
14
 
15
15
  export const CACHE_POLICY_MAX_AGE_MS = 300000;
16
16
  const OPENAI_POLICY = 'https://developers.openai.com/api/docs/guides/prompt-caching#cache-lifetime';
@@ -2,8 +2,8 @@ import { execFileSync } from 'node:child_process';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { parse } from 'smol-toml';
5
- import { codexUserDir, codexModelName } from './agent.js';
6
- import { codexDelegationStatus } from './codex-ledger.js';
5
+ import { codexUserDir, codexModelName } from '../agent.js';
6
+ import { codexDelegationStatus } from './ledger.js';
7
7
 
8
8
  /** Local introspection only. A CLI feature flag cannot prove a model received a tool. */
9
9
  export function codexDelegationDiagnostics({ home = codexUserDir(), model = codexModelName({ home }), sessionId, dir,
@@ -2,17 +2,17 @@ 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
- import { userDataDir } from './paths.js';
6
- import { writeViaTmp } from './state-file.js';
7
- import { codexUserDir, codexProviderName } from './agent.js';
8
- import { categorize, ESCALATE_RE, EDIT_RE } from './route-scan.js';
9
- import { looksPasted } from './route-inject.js';
10
- import { newRouteId, recordCodexDelegation } from './codex-ledger.js';
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';
14
- import { codexPriceBook, readCodexPrices } from './codex-cache-policy.js';
15
- import { codexRunCost } from './gateway-prices.js';
5
+ import { userDataDir } from '../paths.js';
6
+ import { writeViaTmp } from '../state-file.js';
7
+ import { codexUserDir, codexProviderName } from '../agent.js';
8
+ import { categorize, ESCALATE_RE, EDIT_RE } from '../route-scan.js';
9
+ import { looksPasted } from '../route-inject.js';
10
+ import { newRouteId, recordCodexDelegation } from './ledger.js';
11
+ import { readCodexTailLines } from './parser.js';
12
+ import { loadConfig } from '../config.js';
13
+ import { loadSharedModelRules, sharedModelRulesForProject, sharedRuleBounds, SHARED_RULE_TIERS } from '../shared-model-rules.js';
14
+ import { codexPriceBook, readCodexPrices } from './cache-policy.js';
15
+ import { codexRunCost } from '../gateway-prices.js';
16
16
 
17
17
  export const CODEX_ROUTE_CATEGORIES = ['paste', 'translate', 'explore', 'read', 'check', 'run'];
18
18
  const MARKER = '<!-- sprag:codex:delegation -->';
@@ -1,11 +1,11 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { mkdirSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
3
3
  import { extname, join, resolve } from 'node:path';
4
- import { codexUserDir } from './agent.js';
5
- import { userDataDir } from './paths.js';
6
- import { writeViaTmp } from './state-file.js';
7
- import { readResetMarks, isBeforeReset } from './saved-reset.cjs';
8
- import { codexPriceBook, readCodexPrices } from './codex-cache-policy.js';
4
+ import { codexUserDir } from '../agent.js';
5
+ import { userDataDir } from '../paths.js';
6
+ import { writeViaTmp } from '../state-file.js';
7
+ import { readResetMarks, isBeforeReset } from '../saved-reset.cjs';
8
+ import { codexPriceBook, readCodexPrices } from './cache-policy.js';
9
9
 
10
10
  const hash = (value) => createHash('sha256').update(value).digest('hex');
11
11
  const ledgerDir = ({ home = codexUserDir(), dir = userDataDir() } = {}) => join(dir, 'codex-doc2md', hash(resolve(home)));
@@ -2,12 +2,12 @@ import { createRequire } from 'node:module';
2
2
  import { realpathSync, readFileSync, writeFileSync, renameSync, rmSync, openSync, readSync, closeSync } from 'node:fs';
3
3
  import { basename, dirname, resolve } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { recordCodexDocument } from './codex-doc2md-ledger.js';
6
- import { readCodexSnapshot } from './codex-parser.js';
5
+ import { recordCodexDocument } from './doc2md-ledger.js';
6
+ import { readCodexSnapshot } from './parser.js';
7
7
 
8
8
  const require = createRequire(import.meta.url);
9
- const doc2md = require('./doc2md.cjs');
10
- const lint = require('./korean-lint.cjs');
9
+ const doc2md = require('../doc2md.cjs');
10
+ const lint = require('../korean-lint.cjs');
11
11
  const shellTools = new Set(['Bash', 'exec_command', 'shell_command', 'shell']);
12
12
 
13
13
  /** Hook identity, never the user's default model or another project's session. */
@@ -1,10 +1,10 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { parse } from 'smol-toml';
4
- import { codexUserDir } from './agent.js';
5
- import { codexHookStatus } from './codex-installer.js';
6
- import { createPanelReader } from './codex-panel.js';
7
- import { panelHealth } from './codex-panel-state.js';
4
+ import { codexUserDir } from '../agent.js';
5
+ import { codexHookStatus } from './installer.js';
6
+ import { createPanelReader } from './panel.js';
7
+ import { panelHealth } from './panel-state.js';
8
8
 
9
9
  export const CODEX_CAPABILITIES = [
10
10
  { feature: 'statusline', support: 'supported', command: 'sprag --statusline --agent codex', note: 'Local terminal output; native footer accepts only Codex item identifiers.' },
@@ -1,11 +1,11 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { codexUserDir } from './agent.js';
5
- import { loadConfig } from './config.js';
6
- import { koreanStyleEnabled, koreanStyleInjection } from './korean-style.js';
7
- import { codexToolEvents, codexLegacyToolEvent, readCodexTailLines, forkedCopyFilter } from './codex-parser.js';
8
- import { implementationDelegationRule } from './implementation-delegation.js';
4
+ import { codexUserDir } from '../agent.js';
5
+ import { loadConfig } from '../config.js';
6
+ import { koreanStyleEnabled, koreanStyleInjection } from '../korean-style.js';
7
+ import { codexToolEvents, codexLegacyToolEvent, readCodexTailLines, forkedCopyFilter } from './parser.js';
8
+ import { implementationDelegationRule } from '../implementation-delegation.js';
9
9
 
10
10
  export const CODEX_BEGIN = '<!-- sprag:codex:harness:begin -->';
11
11
  export const CODEX_END = '<!-- sprag:codex:harness:end -->';