sprag-cli 3.59.0 → 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.ko.md +17 -0
  2. package/README.md +23 -0
  3. package/bin/cli.js +51 -3
  4. package/docs/CODEX.ko.md +69 -6
  5. package/docs/CODEX.md +84 -12
  6. package/package.json +1 -1
  7. package/src/agent.js +9 -0
  8. package/src/caps-cache.js +3 -3
  9. package/src/codex-brief.js +4 -5
  10. package/src/codex-cache-policy.js +3 -3
  11. package/src/codex-delegation.js +114 -15
  12. package/src/codex-doc2md-ledger.js +3 -3
  13. package/src/codex-doctor.js +2 -2
  14. package/src/codex-hooks.js +4 -5
  15. package/src/codex-installer.js +3 -3
  16. package/src/codex-ledger.js +25 -10
  17. package/src/codex-panel-session.js +3 -3
  18. package/src/codex-panel-state.js +51 -5
  19. package/src/codex-route-scan.js +12 -7
  20. package/src/commands/brief.js +1 -1
  21. package/src/commands/codex-delegate.js +81 -6
  22. package/src/commands/codex.js +8 -2
  23. package/src/commands/compact-window.js +6 -2
  24. package/src/commands/install.js +4 -1
  25. package/src/commands/korean.js +69 -3
  26. package/src/commands/route-scan.js +6 -4
  27. package/src/commands/saved.js +40 -1
  28. package/src/ctx-window-cache.js +14 -0
  29. package/src/formatters/statusline.js +167 -18
  30. package/src/harness-analyzer.cjs +8 -2
  31. package/src/harness.js +32 -23
  32. package/src/installer.js +52 -0
  33. package/src/model-alias.js +3 -3
  34. package/src/model-rules.js +14 -9
  35. package/src/preset-ratchet.js +3 -3
  36. package/src/reply-language.cjs +167 -0
  37. package/src/route-inject.js +57 -8
  38. package/src/route-scan.js +51 -11
  39. package/src/savings-ledger.js +3 -2
  40. package/src/seed-rules.js +32 -23
  41. package/src/session-cache.js +12 -4
  42. package/src/shared-model-rules.js +73 -0
  43. package/src/state-file.js +99 -2
  44. package/src/statusline-data.js +97 -0
  45. package/src/update-check.js +46 -6
package/README.ko.md CHANGED
@@ -109,6 +109,23 @@ LLMRouterBench(Findings of ACL 2026)에서 오프라인으로 측정했습니다
109
109
 
110
110
  위임된 실행은 한 건마다 단가 차액을 기록하므로, `sprag route-scan savings` 가 어떤 금액이든 그 원인이 된 규칙까지 역추적해 보여 줍니다. 집계에서 무엇을 빼는지는 [명령 문서](https://github.com/rootstudioyaml/sprag/blob/main/docs/COMMANDS.ko.md)에 적어 두었습니다.
111
111
 
112
+ ## 티어 기준의 근거가 된 연구
113
+
114
+ 티어 기준은 감으로 정한 값이 아닙니다. 기준마다 공개된 라우팅 연구를 근거로 삼았고, 룰 문구에 나오는 수치(지식 회상 28% 대 54%, 도구 체인 작업의 +63%p 격차)는 아래 데이터셋으로 sprag가 직접 오프라인 실측한 값입니다.
115
+
116
+ | 기준 | 논문이 밝힌 내용 | 논문 |
117
+ |---|---|---|
118
+ | 보수적으로 내려보낸다 | 학습형 라우터는 예산이 커질수록 더 싼 모델로 충분한 경우에도 가장 비싼 모델을 고르는 쪽으로 수렴합니다. | Lai & Ye, [When Routing Collapses: On the Degenerate Convergence of LLM Routers](https://arxiv.org/abs/2602.03478) (2026) |
119
+ | 난이도는 결과로 판정한다 | 기존 라우터는 모두 오라클에 못 미치며, 주된 원인은 작은 모델로 충분한 경우를 알아보지 못하는 데 있습니다. 문항 난이도는 42개 모델 중 실제로 맞힌 모델 수로 정의합니다. | Lu et al., [RouterArena: An Open Platform for Comprehensive Comparison of LLM Routers](https://arxiv.org/abs/2510.00202) (2025) |
120
+ | 임계값은 내 로그에서 정한다 | 예측한 승률을 비용 임계값과 비교해 라우팅하고, 결과는 강한 모델로 보낸 호출 비율로 읽습니다. sprag는 스캔할 때마다 최근 14일 분포에서 이 임계값을 다시 계산합니다. | Ong et al., [RouteLLM: Learning to Route LLMs with Preference Data](https://arxiv.org/abs/2406.18665) (2024) |
121
+ | 실패하면 위로 올린다 | 캐스케이드는 싼 모델부터 시도하고, 답을 검사한 결과로 상위 모델로 올릴지 정합니다. GPT-4와 같은 성능을 최대 98% 낮은 비용으로 냈습니다. | Chen, Zaharia & Zou, [FrugalGPT](https://arxiv.org/abs/2305.05176) (2023) |
122
+ | 답이 어디에 있나 | 짧은 사실 회상 능력만 따로 측정합니다. 지식 회상 28% 대 54% 격차는 이 계열의 데이터셋에서 나왔습니다. | Wei et al., [Measuring short-form factuality in large language models (SimpleQA)](https://arxiv.org/abs/2411.04368) (2024) |
123
+ | 도구 오케스트레이션은 내리지 않는다 | 에이전트가 공유 환경에서 사용자와 함께 도구 호출을 조율해야 하는 벤치마크입니다. +63%p 격차를 여기서 측정했습니다. | Barres et al., [τ²-Bench](https://arxiv.org/abs/2506.07982) (2025) |
124
+ | 공개 벤치마크 실측 | 40만 문항, 21개 데이터셋, 33개 모델 규모이며, 상용 제품을 포함한 여러 라우터가 단순한 기준선을 안정적으로 넘지 못했습니다. | Li et al., [LLMRouterBench](https://arxiv.org/abs/2601.07206) (Findings of ACL 2026) |
125
+ | 라우팅과 캐스케이드 | 라우팅 결정을 언제 내리는지, 무엇을 읽는지, 어떻게 계산하는지로 기존 방식을 분류합니다. | Moslem & Kelleher, [Dynamic Model Routing and Cascading for Efficient LLM Inference: A Survey](https://arxiv.org/abs/2603.04445) (2026) |
126
+
127
+ 논문 근거가 없는 기준도 두 개 있습니다. 같은 파일을 읽는 작업을 한 에이전트에 묶는 기준과, 설계와 코드 작성을 나누는 기준은 sprag의 비용 집계에서 나왔습니다. 서브에이전트에서는 생성보다 컨텍스트 적재 비용이 더 크기 때문입니다. 자세한 근거는 [티어 기준 문서](https://github.com/rootstudioyaml/sprag/blob/main/docs/TIER_CRITERIA.md)에 있습니다.
128
+
112
129
  ## 설치 한 번에 전부 들어 있고, 첫날부터 동작합니다
113
130
 
114
131
  | | 하는 일 | 더 보기 |
package/README.md CHANGED
@@ -140,6 +140,29 @@ ledger. Nothing here is a projection.
140
140
 
141
141
  Every delegated run records its own price difference, so `sprag route-scan savings` traces any amount back to the rule that caused it. What the ledger deliberately leaves out is written down in [the command reference](https://github.com/rootstudioyaml/sprag/blob/main/docs/COMMANDS.md).
142
142
 
143
+ ## Research behind the tier rules
144
+
145
+ The tier criteria are not a hunch. Each one traces back to published routing
146
+ research, and the percentages quoted in the rules (28% vs 54% recall, a +63pp
147
+ gap on chained tool work) are sprag's own offline measurement on the datasets
148
+ below.
149
+
150
+ | Criterion | What the paper found | Paper |
151
+ |---|---|---|
152
+ | Delegate down, conservatively | As budgets grow, learned routers default to the most expensive model even when a cheaper one already suffices. | Lai & Ye, [When Routing Collapses: On the Degenerate Convergence of LLM Routers](https://arxiv.org/abs/2602.03478) (2026) |
153
+ | Judge difficulty by outcome | Every router on the board falls short of the oracle, mainly because it cannot tell when a smaller model is enough. Query difficulty is defined by how many of 42 models actually answered it. | Lu et al., [RouterArena: An Open Platform for Comprehensive Comparison of LLM Routers](https://arxiv.org/abs/2510.00202) (2025) |
154
+ | Thresholds from your own logs | Routing compares a predicted win rate against a cost threshold, and results are read by the share of calls sent to the strong model. sprag sets that threshold from your last 14 days at every scan. | Ong et al., [RouteLLM: Learning to Route LLMs with Preference Data](https://arxiv.org/abs/2406.18665) (2024) |
155
+ | Escalate on failure | A cascade tries the cheap model first and lets a check on the answer decide whether to go up. It matched GPT-4 with up to 98% lower cost. | Chen, Zaharia & Zou, [FrugalGPT](https://arxiv.org/abs/2305.05176) (2023) |
156
+ | Where the answer lives | Short-form factual recall measured on its own. The 28% vs 54% recall gap comes from this class of set. | Wei et al., [Measuring short-form factuality in large language models (SimpleQA)](https://arxiv.org/abs/2411.04368) (2024) |
157
+ | Tool orchestration stays up | Agents must coordinate tool calls with a user in a shared environment. The +63pp gap was measured here. | Barres et al., [τ²-Bench](https://arxiv.org/abs/2506.07982) (2025) |
158
+ | The public benchmark run | 400K instances, 21 datasets, 33 models. Several routers, commercial ones included, fail to beat a simple baseline. | Li et al., [LLMRouterBench](https://arxiv.org/abs/2601.07206) (Findings of ACL 2026) |
159
+ | Routing vs cascading | A taxonomy of when a routing decision is made, what it reads, and how it is computed. | Moslem & Kelleher, [Dynamic Model Routing and Cascading for Efficient LLM Inference: A Survey](https://arxiv.org/abs/2603.04445) (2026) |
160
+
161
+ Two criteria have no paper behind them. Batching tasks that read the same files
162
+ and splitting design from code writing come from sprag's own cost accounting:
163
+ on a sub-agent, loading context costs more than generating. The full reasoning,
164
+ in Korean, is in [Tier criteria](https://github.com/rootstudioyaml/sprag/blob/main/docs/TIER_CRITERIA.md).
165
+
143
166
  ## Everything ships in one install, working from day one
144
167
 
145
168
  | | What it does | More |
package/bin/cli.js CHANGED
@@ -57,7 +57,7 @@ import { sessionCostAcross } from '../src/claude-price.js';
57
57
  import { chipForIssues } from '../src/advice.js';
58
58
  import { debug } from '../src/debug.js';
59
59
  import { createArgs } from '../src/cli-args.js';
60
- import { updateStatus, maybeSpawnUpdateCheck } from '../src/update-check.js';
60
+ import { updateStatus, maybeSpawnUpdateCheck, readUpdateState, updateCheckDisabled } from '../src/update-check.js';
61
61
  import { selectAgent } from '../src/agent.js';
62
62
 
63
63
  const selection = (() => {
@@ -86,7 +86,13 @@ const { getArg, hasFlag, numArg } = createArgs(args);
86
86
  function readUpdateChip() {
87
87
  try {
88
88
  maybeSpawnUpdateCheck(PKG_VERSION);
89
- return updateStatus(PKG_VERSION);
89
+ const status = updateStatus(PKG_VERSION);
90
+ // When the registry was last asked, for the verbose "✓6h" on an up-to-date
91
+ // version. updateStatus() does not carry it, and a second read of the
92
+ // 200-byte state file is cheaper than a second status computation. With
93
+ // checks switched off the file is a leftover, so it says nothing.
94
+ const checkedAt = updateCheckDisabled() ? null : (Number(readUpdateState().checkedAt) || null);
95
+ return { ...status, checkedAt };
90
96
  } catch (e) {
91
97
  debug('update-check:chip', e);
92
98
  return null;
@@ -823,9 +829,15 @@ async function main() {
823
829
  // the statusline shows the same blank for "no delegation happened" and for
824
830
  // "delegation happened and was silently discarded".
825
831
  let unresolvedRuns = 0;
832
+ // The same cached scan says how old the savings figures are: the ledger is
833
+ // filled by route-scan, so a stale scan means a stale total.
834
+ let routeScanAt = null;
826
835
  try {
827
836
  const { readRouteScan } = await import('../src/route-scan.js');
828
- unresolvedRuns = Number(readRouteScan()?.unresolvedRuns) || 0;
837
+ const scan = readRouteScan();
838
+ unresolvedRuns = Number(scan?.unresolvedRuns) || 0;
839
+ const scannedAt = Date.parse(scan?.scannedAt);
840
+ routeScanAt = Number.isFinite(scannedAt) ? scannedAt : null;
829
841
  } catch (e) {
830
842
  debug('route-scan:unresolved', e);
831
843
  }
@@ -840,6 +852,38 @@ async function main() {
840
852
  debug('config:ttlBucket', e);
841
853
  }
842
854
 
855
+ // Statusline-only additions. Each reuses data already in hand: the sessions
856
+ // parsed above (subagent runs ride on them) and the small model-rules.json
857
+ // the harness chip reads anyway.
858
+ //
859
+ // This session's subagent runs, matched to the session Claude Code is
860
+ // drawing for by the transcript path / session id in its payload.
861
+ let sessionDelegation;
862
+ // Delegation-rule counts, read only when `--segments` names `rules`: the
863
+ // chip is opt-in, so nothing else would look at them.
864
+ let ruleStats;
865
+ const rulesRequested = String(getArg('--segments') || '').split(',').some((s) => s.trim() === 'rules');
866
+ if (isStatusline) {
867
+ try {
868
+ const { sessionSubagentSummary } = await import('../src/statusline-data.js');
869
+ sessionDelegation = sessionSubagentSummary(sessions, {
870
+ transcriptPath: extractTranscriptPath(stdinJson),
871
+ sessionId: extractSessionId(stdinJson),
872
+ });
873
+ } catch (e) {
874
+ debug('statusline-data:session-delegation', e);
875
+ }
876
+ if (rulesRequested) {
877
+ try {
878
+ const { delegationRuleStats } = await import('../src/statusline-data.js');
879
+ const { findProjectRoot } = await import('../src/harness.js');
880
+ ruleStats = delegationRuleStats(findProjectRoot());
881
+ } catch (e) {
882
+ debug('statusline-data:rule-stats', e);
883
+ }
884
+ }
885
+ }
886
+
843
887
  const data = {
844
888
  summary: sum,
845
889
  trend,
@@ -862,6 +906,10 @@ async function main() {
862
906
  delegationTotals,
863
907
  doc2mdTotals,
864
908
  unresolvedRuns,
909
+ // Statusline-only; left undefined elsewhere so --format json keeps its shape.
910
+ routeScanAt: isStatusline ? routeScanAt : undefined,
911
+ sessionDelegation,
912
+ ruleStats,
865
913
  ttlBucket,
866
914
  };
867
915
 
package/docs/CODEX.ko.md CHANGED
@@ -122,10 +122,10 @@ Codex 0.159.2에서는 `spawn_agent`가 `PreToolUse`를 거치지 않습니다(
122
122
 
123
123
  이 안내는 부모 세션의 컨텍스트가 `codex.delegateMinContext`(기본 60000 토큰) 아래면 붙지
124
124
  않습니다. 2026-09-30 실측에서 작은 세션은 스폰·대기·검증이라는 조율 비용이 자식이 아끼는
125
- 비용보다 커서, 문턱 아래에서는 안내를 붙이는 쪽이 오히려 손해였습니다. 아직 전용 CLI 명령은
126
- 없고, `sprag mode` 실행 시 "Stored config file"로 나오는 설정 파일(맥은
127
- `~/Library/Application Support/claude-token-saver/config.json`)에서 `codex.delegateMinContext`
128
- 값을 직접 고치면 됩니다. `PreToolUse` 재작성은 spawn을 훅으로 보내는 Codex 버전을 위해 남아
125
+ 비용보다 커서, 문턱 아래에서는 안내를 붙이는 쪽이 오히려 손해였습니다. 기준은
126
+ `sprag delegate shared min-context <토큰 수|default> --agent codex`로 바꾸고, 현재 값은
127
+ `delegate shared status`에서 확인합니다. 이 값은 `sprag mode`가 "Stored config file"로 알려 주는
128
+ 설정 파일에 `codex.delegateMinContext`로 저장됩니다. `PreToolUse` 재작성은 spawn을 훅으로 보내는 Codex 버전을 위해 남아
129
129
  있을 뿐, 0.159.2에서는 호출되지 않습니다. 위임 원장은 자식이 쓴 토큰을 부모 모델 단가로 환산한
130
130
  추정치이며, 부모가 조율에 쓴 턴은 빼지 않습니다. 끝나기 전에 중단된 자식은 부모의 일을 대신하지
131
131
  못했으므로, 그 비용 전액을 손실로 기록하고 항목에 `aborted`를 표시합니다.
@@ -139,8 +139,71 @@ Codex 0.159.2는 훅 하나가 넘긴 컨텍스트를 약 2,450토큰까지만
139
139
  못한 룰과 지침은 Sprag 데이터 디렉터리의 프로젝트별 파일에 쓰고, 컨텍스트 첫머리에서 그 파일을
140
140
  먼저 읽으라고 안내합니다. rollout의 `originator`로 `codex exec` 실행을 알아보며, 답할 사람이
141
141
  없으므로 seed 제안과 route 안내를 넣지 않습니다.
142
- `seed`는 Claude의 Haiku·Sonnet 프리셋을 가져오지 않으며,
143
- Codex의 수락·거절 기록도 Claude 기록과 분리합니다.
142
+
143
+ `seed`는 공통 위임 정책과 Codex용 래칫 프리셋을 제안합니다.
144
+ 래칫과 거절 기록은 도구별로 분리하지만, 승인된 공통 정책은 원래 범위 안에서 중복 제안하지 않습니다.
145
+
146
+ ### 공통 위임 정책
147
+
148
+ 작업 유형, T1·T2 난도, 적용 범위와 상한은 Claude와 Codex가 같은 `model-rules.json`에서
149
+ 읽습니다. 기존 Claude 활성 규칙도 복사 없이 표시하며, 삭제되거나 비활성·검토 상태인 규칙은
150
+ Codex에서 위임에 사용하지 않습니다. 프로젝트 정책은 작업 디렉터리에 대해 두 도구가 각각 정하는
151
+ 프로젝트 루트 가운데 하나와 일치하면 적용합니다. Claude는 `CLAUDE.md`에서, Codex는 `AGENTS.md`에서
152
+ 루트를 정하며, 정책의 루트와 작업 디렉터리의 루트는 심볼릭 링크를 푼 실제 경로로 비교합니다. 모델 ID, 역할, 사용량과 절감액은
153
+ 도구별로 유지합니다.
154
+
155
+ Claude를 설치하지 않은 Codex 첫 사용자도 기록을 쌓기 전에 프리셋을 승인할 수 있습니다.
156
+
157
+ ```sh
158
+ sprag seed --agent codex
159
+ sprag seed accept all --global --agent codex
160
+ sprag delegate shared status --agent codex
161
+ sprag delegate shared map T2 --from PARENT_MODEL --model SMALL_MODEL --effort high --agent codex
162
+ sprag delegate shared map T1 --from PARENT_MODEL --model MEDIUM_MODEL --effort medium --agent codex
163
+ sprag delegate on --agent codex
164
+ ```
165
+
166
+ 등록 전에 전역과 프로젝트 범위를 선택합니다. 프로젝트에만 적용하려면 `--global` 대신
167
+ `--project`를 지정하십시오. 위 모델명은 예시 자리표시자이므로, 공급자에서 사용할 수 있고
168
+ 부모보다 저렴한 실제 모델 ID로 바꿔야 합니다. 매핑을 저장했다는 사실만으로 사용 가능 여부나
169
+ 절감 효과를 확인한 것은 아닙니다. 공급자는 Codex 설정에서 읽으며 `--provider ID`로 지정할 수도 있습니다.
170
+
171
+ 매핑은 출발 모델·공급자·난도가 일치할 때만 적용하고, 한 번 지정하면 승인된 모든 작업 유형에서
172
+ 사용합니다. 매핑하지 않은 난도는 메인 모델이 처리합니다. Codex에서 승인할 때 `.claude` 파일은
173
+ 만들지 않으며, 나중에 Claude를 시작하면 같은 정책을 Claude용 모델 표기로 생성합니다.
174
+
175
+ 프롬프트 훅은 단순 작업에 T2, 여러 단계가 필요한 작업에 T1을 제시하고, 매핑된 난도마다 route 줄을
176
+ 따로 줍니다. 부모는 작업에 맞는 모델 하나를 선택하고 독립적인 작업 명세, 상한, `fork_turns "none"`과
177
+ 선택한 난도의 route 줄만 전달한 뒤 결과를 기다려 검증합니다. `fork_turns "none"`은 반드시 지정해야
178
+ 합니다. 전체 기록을 넘기는 포크는 부모 모델을 그대로 물려받아 모델 지정을 무시하기 때문입니다.
179
+ Codex는 사용자가 요청할 때만 스폰 모델을 지정하므로, 안내 문구는 사용자가 정책과 매핑을 승인했다는
180
+ 사실을 함께 밝힙니다.
181
+
182
+ 제시한 난도는 각각 프롬프트 route로 기록합니다. 이 경로는 `spawn_agent`가 `PreToolUse`를 거치지 않는
183
+ Codex 0.159.2에서 Codex 전용 규칙이 쓰는 경로와 같습니다. SubagentStart는 자식이 실행하는 모델과 같은
184
+ route에 자식을 연결하고, 부모에게 다음 프롬프트가 들어오면 고르지 않은 난도의 route를 닫습니다. 그래서
185
+ 제시만 하고 연결된 자식이 없는 난도는 실행으로 집계하지 않습니다. `PreToolUse`가 실행되는 버전에서는
186
+ 공통 정책에 해당하는 스폰이 모델을 지정하지 않으면 스폰 훅이 매핑된 선택지를 알려 주며 그 스폰을
187
+ 거부하고, 난도는 부모가 고릅니다. 스폰 훅은 난도를 임의로 정하거나 명시된 모델·사용자 정의 역할을
188
+ 덮어쓰지 않으며, 부모가 자기 모델을 명시하면 작업은 그 모델에 남습니다.
189
+
190
+ 공통 정책 안내도 앞에서 설명한 Codex 전용 규칙과 같은 컨텍스트 기준을 따릅니다. 이 기준은 모델의
191
+ 컨텍스트 창 크기를 바꾸지 않으며, 컨텍스트 기록이 없으면 안내하지 않습니다. 매핑은 지정한 출발 모델의 세션에만 적용되므로,
192
+ `status`는 Codex 설정의 기본 모델을 기준으로 빠진 매핑을 알려 줍니다.
193
+
194
+ ```sh
195
+ sprag delegate rules --agent codex
196
+ sprag delegate shared off --agent codex
197
+ sprag delegate shared on --agent codex
198
+ sprag delegate shared unmap T2 --from PARENT_MODEL --agent codex
199
+ sprag delegate shared min-context 30000 --agent codex
200
+ ```
201
+
202
+ `shared off`는 공통 정책과 매핑을 보존하며 Codex 전용 규칙에는 영향을 주지 않습니다.
203
+ `unmap`은 해당 Codex 매핑만 제거합니다. 공통 정책은 `sprag route-scan rules [rm N]`에서
204
+ 관리하며, 원본 삭제는 두 도구에 반영됩니다. Codex 실행 기록에는 원본 규칙 식별자와 난도를
205
+ 남기지만 사용량·절감액을 Claude 통계에 더하지 않습니다. 안내 생성만으로 실행을 집계하지 않으며,
206
+ route 표지 또는 SubagentStart 연결이 매핑된 모델로 실행한 실제 자식 세션과 일치해야 기록을 연결합니다.
144
207
 
145
208
  ## 래칫 규칙
146
209
 
package/docs/CODEX.md CHANGED
@@ -176,11 +176,11 @@ The hint is withheld below `codex.delegateMinContext` (default 60000 input
176
176
  tokens, read from the parent's own rollout). Measured 2026-09-30: a small
177
177
  parent session spends more on the coordinating turn (spawning, waiting,
178
178
  verifying) than a delegated child saves, so under the floor Sprag leaves the
179
- request on the main agent instead of proposing a loss. There is no CLI flag for
180
- it yet; set it by editing `codex.delegateMinContext` (an integer) in the config
181
- file `sprag mode` prints under "Stored config file" (`~/Library/Application
182
- Support/claude-token-saver/config.json` on macOS, `$XDG_CONFIG_HOME/claude-token-saver/config.json`
183
- or `~/.config/claude-token-saver/config.json` on Linux).
179
+ request on the main agent instead of proposing a loss. Change it with
180
+ `sprag delegate shared min-context <tokens|default> --agent codex`;
181
+ `delegate shared status` shows the current value. It is stored as
182
+ `codex.delegateMinContext` in the config file `sprag mode` prints under
183
+ "Stored config file".
184
184
 
185
185
  The `PreToolUse` rewrite (native `spawn_agent` arguments using Codex's
186
186
  `message`, `model`, and `reasoning_effort` fields, preserving explicit model
@@ -204,7 +204,76 @@ spawns without an explicit model. Category rules (`paste`, `translate`, `explore
204
204
  `read`, `check`, `run`) reuse Sprag's classifier and escalation gates, require an
205
205
  exact parent model, and prefer project rules over global rules. Matching prompts
206
206
  request native delegation; unavailable subagent tools leave work on the main
207
- agent. Claude tier presets and history are not imported.
207
+ agent. Codex-only rules take precedence over shared policies.
208
+
209
+ ### Shared Delegation Policies
210
+
211
+ Work categories, T1/T2 difficulty, scope, and caps use the same
212
+ `model-rules.json` registry in Claude Code and Codex. Existing active Claude
213
+ policies are visible immediately; there is no copy or migration. Deleted,
214
+ disabled, and review-state policies are not routed by Codex. Project policies
215
+ remain project-scoped. A project policy applies when its root is the root either
216
+ agent assigns to the working directory (Claude stops at `CLAUDE.md`, Codex at
217
+ `AGENTS.md`), compared as real paths. Model IDs, roles, usage, and savings are
218
+ not shared.
219
+
220
+ A Codex-first user needs no Claude installation or transcript history:
221
+
222
+ ```sh
223
+ sprag seed --agent codex
224
+ sprag seed accept all --global --agent codex # or --project, after choosing scope
225
+ sprag delegate shared status --agent codex
226
+ sprag delegate shared map T2 --from PARENT_MODEL --model SMALL_MODEL --effort high --agent codex
227
+ sprag delegate shared map T1 --from PARENT_MODEL --model MEDIUM_MODEL --effort medium --agent codex
228
+ sprag delegate on --agent codex
229
+ ```
230
+
231
+ Replace the model placeholders with available, lower-cost models from your
232
+ provider; mappings do not prove availability or savings. Each mapping applies
233
+ to one exact parent model, provider, and tier, across every approved category.
234
+ `--provider ID` overrides the provider read from Codex config. Without a mapping,
235
+ that tier stays on the main agent. No Haiku/Sonnet-to-GPT equivalence is assumed.
236
+ Codex seed acceptance writes the common registry without creating `.claude`;
237
+ Claude renders its own view when it is installed or starts later.
238
+
239
+ The prompt hook offers T2 for a simple bounded task and T1 for multi-step work,
240
+ and gives each mapped tier its own route line. The caller chooses at most one
241
+ model and passes a self-contained task, the cap, `fork_turns "none"`, and only
242
+ the chosen tier's route line. `fork_turns "none"` is required: a full-history
243
+ fork inherits the parent model and ignores a model override. The hint states
244
+ that the user approved the policy and mapping, because Codex sets a spawn model
245
+ only on the user's request. The parent waits for and verifies the result.
246
+
247
+ Each offered tier is recorded as a prompt route, the same path Codex-only rules
248
+ use on Codex 0.159.2, where `spawn_agent` bypasses `PreToolUse`. SubagentStart
249
+ binds the child to the route whose model it runs, and the parent's next prompt
250
+ closes the tier it did not choose, so an offered tier never counts as a run
251
+ without a matching child. Where `PreToolUse` does run, a spawn that matches a
252
+ shared policy without naming a model is denied with the mapped choices so the
253
+ caller picks the tier. The spawn hook does not guess difficulty or override an
254
+ explicit model or custom role, and naming the parent's own model keeps the task
255
+ on that model.
256
+
257
+ Shared hints use the same context gate as Codex-only rules, described above;
258
+ it does not change any model's context window, and missing context stays below
259
+ the gate. `status` checks mappings against the default model in Codex config, because a
260
+ mapping routes only sessions on the parent model it names.
261
+
262
+ ```sh
263
+ sprag delegate rules --agent codex
264
+ sprag delegate shared off --agent codex
265
+ sprag delegate shared on --agent codex
266
+ sprag delegate shared unmap T2 --from PARENT_MODEL --agent codex
267
+ sprag delegate shared min-context 30000 --agent codex
268
+ ```
269
+
270
+ `shared off` retains policies and mappings and leaves Codex-only rules intact.
271
+ `unmap` removes only that Codex mapping. Edit/remove common policies through
272
+ `sprag route-scan rules [rm N]`; removal affects both consumers. Codex routing
273
+ records retain the source policy signature and tier, but their token and savings
274
+ totals never enter Claude's statistics. A hint is not proof that a child ran:
275
+ accounting requires the route marker or a SubagentStart binding to a matching
276
+ child rollout that ran the mapped model.
208
277
 
209
278
  The routing ledger (`Routing Saved` below) prices a child's own tokens at the
210
279
  parent model's rate as the counterfactual; it does not charge or subtract the
@@ -279,8 +348,10 @@ the context opens with an instruction to read it. `codex exec` runs, recognized
279
348
  from the rollout's `originator`, get no seed offer or route notice, since no one
280
349
  can answer them.
281
350
 
282
- `seed` offers only compatible ratchet presets, not Claude model-tier presets.
283
- Accept and skip decisions are separate from Claude. Both seed acceptance and
351
+ `seed` offers common delegation policies without Claude model names, plus
352
+ compatible Codex ratchet presets. Ratchet and skip decisions remain agent-specific;
353
+ an approved common policy suppresses duplicate offers within its scope in either
354
+ agent. Both seed acceptance and
284
355
  route approval require the user's choice of global or project scope. A project
285
356
  route is saved for the project where it was observed. Enable delegation with
286
357
  `sprag delegate on --agent codex` and trust the hooks before expecting rules to run.
@@ -348,9 +419,10 @@ sprag route-scan savings --refresh --agent codex
348
419
  sprag route-scan savings --format json --agent codex
349
420
  ```
350
421
 
351
- The separate Codex ledger joins a route ID recorded by Sprag at spawn time to
352
- the child's own rollout. An explicit model choice or an unrelated cheaper
353
- subagent earns no credit. The estimate holds the child's measured tokens
422
+ The separate Codex ledger joins a spawn-time route ID, or a validated shared
423
+ prompt-hint marker on clients that skip the spawn hook, to the child's own
424
+ rollout. An unrelated explicit model choice or cheaper subagent earns no credit.
425
+ The estimate holds the child's measured tokens
354
426
  constant and prices them at the parent and child rates. Negative differences
355
427
  remain negative; this is not a comparison of actual billed totals.
356
428
 
@@ -525,7 +597,7 @@ Document counts are not a measurement of Codex billing or avoided tokens.
525
597
 
526
598
  ## Limits And Removal
527
599
 
528
- Claude's cap/TTL heuristics, Haiku/Sonnet tier presets, and the Claude
600
+ Claude's cap/TTL heuristics, Haiku/Sonnet model identifiers, and the Claude
529
601
  `compact-window` command do not apply to Codex. Codex provides its own
530
602
  `model_auto_compact_token_limit` setting; Sprag does not apply Claude's 1M
531
603
  heuristic or change that setting. Codex's native `tui.status_line` accepts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.59.0",
3
+ "version": "3.61.0",
4
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.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -14,6 +14,15 @@ export function codexProviderName({ home = codexUserDir() } = {}) {
14
14
  } catch (e) { return e.code === 'ENOENT' ? 'openai' : null; }
15
15
  }
16
16
 
17
+ /** The default model from Codex config, or null when it names none or cannot be read. */
18
+ export function codexModelName({ home = codexUserDir() } = {}) {
19
+ try {
20
+ const cfg = parse(readFileSync(join(home, 'config.toml'), 'utf8'));
21
+ const model = cfg.profiles?.[cfg.profile]?.model || cfg.model;
22
+ return typeof model === 'string' && model ? model : null;
23
+ } catch { return null; }
24
+ }
25
+
17
26
  /** Remove the global selector before positional subcommand parsers see it. */
18
27
  export function selectAgent(argv) {
19
28
  const args = [];
package/src/caps-cache.js CHANGED
@@ -9,9 +9,10 @@
9
9
  * than `maxAgeMs` the loader returns null and the table view stays quiet.
10
10
  */
11
11
 
12
- import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
12
+ import { readFileSync, existsSync, mkdirSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import { userDataDir } from './paths.js';
15
+ import { writeViaTmp } from './state-file.js';
15
16
 
16
17
  const CACHE_PATH = join(userDataDir(), 'last-caps.json');
17
18
 
@@ -41,8 +42,7 @@ export function persistSnapshot(snapshot) {
41
42
  // tmp + rename: the statusline reads this every tick and must never see a
42
43
  // half-written file.
43
44
  const tmp = `${CACHE_PATH}.${process.pid}.tmp`;
44
- writeFileSync(tmp, JSON.stringify(payload) + '\n');
45
- renameSync(tmp, CACHE_PATH);
45
+ writeViaTmp(tmp, CACHE_PATH, JSON.stringify(payload) + '\n');
46
46
  } catch {
47
47
  // best-effort cache, never blocks the statusline
48
48
  }
@@ -1,8 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync } from 'node:fs';
2
+ import { readFileSync, mkdirSync, readdirSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { codexUserDir } from './agent.js';
5
5
  import { userDataDir } from './paths.js';
6
+ import { writeViaTmp } from './state-file.js';
6
7
  import { readCodexSnapshot } from './codex-parser.js';
7
8
  import { loadCodexModelRules } from './codex-delegation.js';
8
9
  import { loadCodexLedger } from './codex-ledger.js';
@@ -105,8 +106,7 @@ export function runCodexBrief({ sessionId, transcriptPath, cwd, root: projectRoo
105
106
  const events = [...state.events, ...next.map((issue) => ({ ...issue, at, sessionId, project: cwd || snapshot.projectDir || '' }))].slice(-200);
106
107
  mkdirSync(directory, { recursive: true });
107
108
  const tmp = `${file}.${process.pid}.tmp`;
108
- writeFileSync(tmp, JSON.stringify({ active: nextActive, events }) + '\n', { mode: 0o600 });
109
- renameSync(tmp, file);
109
+ writeViaTmp(tmp, file, JSON.stringify({ active: nextActive, events }) + '\n', { mode: 0o600 });
110
110
  return next.length ? ['[Sprag Codex briefing]', ...next.map((issue) => `${issue.message} ${issue.advice}`)].join('\n') : null;
111
111
  }
112
112
 
@@ -125,8 +125,7 @@ export function recordCodexHistoryEvent({ sessionId, project = '', key, level =
125
125
  at: new Date(now).toISOString(), sessionId: owner, project }].slice(-200);
126
126
  mkdirSync(directory, { recursive: true });
127
127
  const tmp = `${file}.${process.pid}.tmp`;
128
- writeFileSync(tmp, JSON.stringify({ ...state, events }) + '\n', { mode: 0o600 });
129
- renameSync(tmp, file);
128
+ writeViaTmp(tmp, file, JSON.stringify({ ...state, events }) + '\n', { mode: 0o600 });
130
129
  return true;
131
130
  }
132
131
 
@@ -1,9 +1,10 @@
1
- import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
1
+ import { readFileSync, mkdirSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
4
  import { parse } from 'smol-toml';
5
5
  import { codexUserDir } from './agent.js';
6
6
  import { userDataDir } from './paths.js';
7
+ import { writeViaTmp } from './state-file.js';
7
8
  import { codexBudgetProvider, codexProviderKey } from './codex-budget.js';
8
9
  import { codexCachePolicy } from './codex-cache.js';
9
10
  import { PRICE_MAX_AGE_MS, gatewayPrices } from './gateway-prices.js';
@@ -153,8 +154,7 @@ async function writeGatewaySnapshot(provider, { fetchImpl = fetch, execute, ...o
153
154
  // Persist only policy and price fields, never raw model/info (which can contain secrets).
154
155
  mkdirSync(join(opts.dir || userDataDir(), 'codex-cache-policy'), { recursive: true, mode: 0o700 });
155
156
  const tmp = `${file}.${randomUUID()}.tmp`;
156
- writeFileSync(tmp, JSON.stringify(snapshot) + '\n', { mode: 0o600 });
157
- renameSync(tmp, file);
157
+ writeViaTmp(tmp, file, JSON.stringify(snapshot) + '\n', { mode: 0o600 });
158
158
  return snapshot;
159
159
  }
160
160