sprag-cli 3.55.0 → 3.56.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.
package/README.ko.md CHANGED
@@ -209,6 +209,10 @@ JetBrains IDE 내장 터미널에서는 이모지 대신 한 칸 글리프로
209
209
  | [한국어 문체 지침](https://github.com/rootstudioyaml/sprag/blob/main/docs/KOREAN-STYLE.ko.md) | 주입 내용, 쓰기 시점 검사, 적용 전후 비교 |
210
210
  | [라우터가 아닌 이유](https://github.com/rootstudioyaml/sprag/blob/main/docs/NOT-A-ROUTER.ko.md) | 실시간 라우팅이 캐시를 깨서 비용을 키우는 구조 |
211
211
  | [게이트웨이와 환경](https://github.com/rootstudioyaml/sprag/blob/main/docs/GATEWAYS.ko.md) | Bedrock·Vertex·LiteLLM, 가격표, FAQ, 동작 원리 |
212
+ | [클로드 코드 토큰 줄이는 법](https://sprag.io/docs/ko/reduce-claude-code-tokens/) | 압축·초기화 타이밍, 서브에이전트 탐색, 문서 변환, 캐시 TTL |
213
+ | [클로드 코드 사용 한도](https://sprag.io/docs/ko/claude-code-usage-limits/) | 5시간·7일 윈도우, 함께 지켜보는 법, 턴당 비용 줄이기 |
214
+ | [코덱스 토큰 사용량](https://sprag.io/docs/ko/codex-token-usage/) | 세션 범위 나누기, 네이티브 서브에이전트 위임, 코덱스 문서 변환 |
215
+ | [도구 비교](https://sprag.io/docs/ko/compare/) | ccusage·claude-usage·CCometixLine 같은 스테이터스라인 도구와 Sprag의 차이 |
212
216
 
213
217
  LiteLLM 게이트웨이 환경에서는 `sprag profile-map --refresh` 가 게이트웨이의 `GET /model/info` 에서 모델 별칭을 바로 읽어 오므로, route-scan 이 위임 투표가 쌓이길 기다리거나 `profile-map.json` 을 손으로 편집할 필요가 없어졌습니다. 이 갱신은 LiteLLM 예산 게이지와 같은 5분 점검·24시간 캐시 주기를 씁니다. 인증 토큰은 저장하지 않으며, 호출 시점마다 `apiKeyHelper` 에서 읽어 그 헬퍼의 수명 그대로 만료되게 둡니다. 자세한 내용은 [route-scan](https://github.com/rootstudioyaml/sprag/blob/main/docs/ROUTE_SCAN.md) 문서에 있습니다.
214
218
 
package/README.md CHANGED
@@ -254,6 +254,10 @@ Every subcommand, flag and the output-language setting: [command reference](http
254
254
  | [Korean style](https://github.com/rootstudioyaml/sprag/blob/main/docs/KOREAN-STYLE.md) | What gets injected, the write-time check, before/after |
255
255
  | [Not a router](https://github.com/rootstudioyaml/sprag/blob/main/docs/NOT-A-ROUTER.md) | Why realtime routing breaks the cache and costs more |
256
256
  | [Gateways & environment](https://github.com/rootstudioyaml/sprag/blob/main/docs/GATEWAYS.md) | Bedrock, Vertex, LiteLLM budgets, pricing table, FAQ, how it works |
257
+ | [Reduce Claude Code tokens](https://sprag.io/docs/reduce-claude-code-tokens/) | Compact/clear timing, subagent exploration, document conversion, cache TTL |
258
+ | [Claude Code usage limits](https://sprag.io/docs/claude-code-usage-limits/) | The 5-hour and 7-day windows, watching both, cutting per-turn cost |
259
+ | [Codex token usage](https://sprag.io/docs/codex-token-usage/) | Session scoping, native subagent delegation, document conversion for Codex |
260
+ | [Compare](https://sprag.io/docs/compare/) | Sprag vs ccusage, claude-usage, and statusline tools like CCometixLine |
257
261
 
258
262
  Behind a LiteLLM gateway, `sprag profile-map --refresh` now reads model aliases straight from the gateway's own `GET /model/info`, so route-scan no longer needs to wait on learned votes or a hand-edited `profile-map.json`. The refresh uses the same 5-minute check and 24-hour cache as the LiteLLM budget gauge. It never stores your auth token: each refresh reads it from `apiKeyHelper` at call time and lets it expire on the helper's own schedule. Details: [route-scan](https://github.com/rootstudioyaml/sprag/blob/main/docs/ROUTE_SCAN.md).
259
263
 
package/bin/cli.js CHANGED
@@ -52,7 +52,7 @@ import {
52
52
  sessionMetrics,
53
53
  diagnoseSession,
54
54
  } from '../src/stats.js';
55
- import { estimateCostAcross } from '../src/cost.js';
55
+ import { sessionCostAcross } from '../src/claude-price.js';
56
56
  import { chipForIssues } from '../src/advice.js';
57
57
  import { debug } from '../src/debug.js';
58
58
  import { createArgs } from '../src/cli-args.js';
@@ -592,7 +592,7 @@ async function main() {
592
592
  const ttl = ttlBreakdown(sessions);
593
593
  const sum = summary(sessions);
594
594
  const anomalies = detectAnomalies(trend);
595
- const cost = estimateCostAcross(sessions);
595
+ const cost = sessionCostAcross(sessions);
596
596
  const spikeReport = detectSpikes(sessions, { recentHours: 24, multiplier: 3 });
597
597
  const contextWindow = detectContextWindow(sessions, { recentHours: 24 });
598
598
 
package/docs/CODEX.ko.md CHANGED
@@ -51,7 +51,7 @@ macOS zsh 환경에서는 설치가 tmux 설치 여부를 먼저 확인합니다
51
51
  | 한국어 검사 | 파일 쓰기 후 문체 위반을 검사하며 block·warn·off를 지원합니다 |
52
52
  | 상태 표시 | 별도 패널, tmux 패널, 일회성 상태줄을 제공합니다 |
53
53
  | 브리핑·이력 | 최신 컨텍스트와 사용량 경고를 알리고 세션별로 저장합니다 |
54
- | 위임 원장 | Sprag가 위임한 하위 실행과 LiteLLM 단가로 예상 차액을 계산합니다 |
54
+ | 위임 원장 | Sprag가 위임한 하위 실행의 토큰에 호출 경로의 단가를 적용해 예상 차액을 계산합니다. 직접 호출은 OpenAI 정가를, LiteLLM 공급자는 그 게이트웨이의 단가를 씁니다 |
55
55
 
56
56
  ## 패널과 보고서
57
57
 
@@ -108,8 +108,34 @@ sprag seed reset --agent codex
108
108
  등록할 때 `--model ID`를 요구합니다. 모델 사용 가능 여부는 실제 공급자 설정에 달려 있습니다.
109
109
  명시한 모델과 사용자 정의 역할은 유지하며, 사용할 수 없는 하위 에이전트 도구를 만들어 내지 않습니다.
110
110
 
111
+ Codex 0.159.2에서는 `spawn_agent`가 `PreToolUse`를 거치지 않습니다(기록용 훅을 붙여 직접
112
+ 확인한 결과입니다). 그래서 실제로 동작하는 경로는 프롬프트 시점입니다. 규칙에 맞는 프롬프트가
113
+ 오면 `[Sprag model routing]` 안내가 붙고, `harness init`이 넣는 AGENTS.md 블록이 이 안내를
114
+ "사용자가 위임을 요청했다"는 뜻으로 읽게 합니다. 자식은 `fork_turns "none"`으로 뜨고, 부모는
115
+ 같은 일을 직접 하지 않고 `wait_agent`(제한 시간 120초 이상)로 자식이 끝나기를 기다립니다. 귀속은
116
+ 자식이 route id를 되받아 적는 것에 기대지 않습니다. 대신 `SubagentStart`가 대기 중인 기록(같은
117
+ 부모 세션, 같은 목표 모델, 30분 이내)에 새로 뜬 자식을 매칭해 묶습니다.
118
+
119
+ 이 안내는 부모 세션의 컨텍스트가 `codex.delegateMinContext`(기본 60000 토큰) 아래면 붙지
120
+ 않습니다. 2026-09-30 실측에서 작은 세션은 스폰·대기·검증이라는 조율 비용이 자식이 아끼는
121
+ 비용보다 커서, 문턱 아래에서는 안내를 붙이는 쪽이 오히려 손해였습니다. 아직 전용 CLI 명령은
122
+ 없고, `sprag mode` 실행 시 "Stored config file"로 나오는 설정 파일(맥은
123
+ `~/Library/Application Support/claude-token-saver/config.json`)에서 `codex.delegateMinContext`
124
+ 값을 직접 고치면 됩니다. `PreToolUse` 재작성은 spawn을 훅으로 보내는 Codex 버전을 위해 남아
125
+ 있을 뿐, 0.159.2에서는 호출되지 않습니다. 위임 원장은 자식이 쓴 토큰을 부모 모델 단가로 환산한
126
+ 추정치이며, 부모가 조율에 쓴 턴은 빼지 않습니다. 끝나기 전에 중단된 자식은 부모의 일을 대신하지
127
+ 못했으므로, 그 비용 전액을 손실로 기록하고 항목에 `aborted`를 표시합니다.
128
+
111
129
  SessionStart는 캐시된 후보를 읽고 필요할 때 별도 프로세스에서 갱신합니다. 명시적인 `--refresh`는
112
- 대기 주기를 건너뜁니다. `seed`는 Claude의 Haiku·Sonnet 프리셋을 가져오지 않으며,
130
+ 대기 주기를 건너뜁니다.
131
+
132
+ Codex 0.159.2는 훅 하나가 넘긴 컨텍스트를 약 2,450토큰까지만 보존하고, 그보다 길면 가운데를
133
+ 잘라 냅니다. 그래서 SessionStart는 추정치 2,000토큰 안에 들어가는 항목만 잘리지 않은 상태로
134
+ 넣습니다. 우선순위는 ratchet 룰, 한국어 지침, 응집성 지침, 문서 변환 안내, 제안 순입니다. 들어가지
135
+ 못한 룰과 지침은 Sprag 데이터 디렉터리의 프로젝트별 파일에 쓰고, 컨텍스트 첫머리에서 그 파일을
136
+ 먼저 읽으라고 안내합니다. rollout의 `originator`로 `codex exec` 실행을 알아보며, 답할 사람이
137
+ 없으므로 seed 제안과 route 안내를 넣지 않습니다.
138
+ `seed`는 Claude의 Haiku·Sonnet 프리셋을 가져오지 않으며,
113
139
  Codex의 수락·거절 기록도 Claude 기록과 분리합니다.
114
140
 
115
141
  ## 래칫 규칙
@@ -158,9 +184,18 @@ sprag capabilities --agent codex
158
184
  하위 실행의 토큰을 고정하고 부모·자식 단가를 각각 적용한 차이이므로, 실제 청구서의 절감액은
159
185
  아닙니다. 손해는 음수로 남기며 단가나 사용량이 없으면 0 대신 미상으로 표시합니다.
160
186
 
161
- 단가는 설정된 LiteLLM의 `/model/info`에서 읽습니다. 공급자가 다른 동명 모델을 섞지 않고,
162
- 같은 별칭 아래 배포 단가가 다르면 계산하지 않습니다. 단가 캐시는 7일 동안 사용하며 이미
163
- 계산한 실행은 단가 사본을 보존합니다. 직접 OpenAI 연결이나 구독 요금은 추정하지 않습니다.
187
+ 단가는 호출이 실제로 거친 경로로 정하며, 두 출처는 서로를 대신하지 않습니다.
188
+
189
+ - ChatGPT·OpenAI 로그인이나 `api.openai.com`으로 바로 가는 API 키는 내장된 OpenAI 정가를
190
+ 씁니다. 정가는 Standard 등급의 짧은 컨텍스트 단가이고 2026-10-01에 확인했습니다. 긴 컨텍스트
191
+ 할증은 반영하지 않으므로, 그 기준을 넘는 요청은 실제보다 낮게 계산됩니다.
192
+ - LiteLLM 공급자는 그 게이트웨이의 `/model/info` 단가를 씁니다. 같은 별칭 아래 배포 단가가
193
+ 다르면 계산하지 않고, 단가 캐시는 7일 동안 사용합니다. 게이트웨이가 단가를 주지 않는 모델은
194
+ 정가로 채우지 않고 미상으로 둡니다.
195
+
196
+ 공급자가 다른 동명 모델은 섞지 않으며, 이미 계산한 실행은 단가 사본을 보존합니다.
197
+ `sprag delegate rules --agent codex`는 룰마다 실측 위임 건수와 실패율, 절감액을 보여 줍니다.
198
+ 실패율의 95% 신뢰 하한이 20%를 넘으면(실측 5건 이상) 조건을 좁히거나 룰을 지우라고 알립니다.
164
199
  위임 후 `--refresh`로 원장을 갱신할 수 있고, 아직 일치하지 않은 기록은 7일 동안 찾습니다.
165
200
 
166
201
  캐시 타이머는 확인된 공급자 정책과 기록된 캐시 사용 시각을 보여 줍니다. 정확한 만료 시각이나
package/docs/CODEX.md CHANGED
@@ -76,14 +76,14 @@ shared hooks needed by other features.
76
76
  | Token report | Table, JSON, or CSV from local Codex rollout logs, with time and project filters |
77
77
  | Statusline | `--statusline` or `--format statusline` renders the compact panel once, with wrapping and label/color preferences |
78
78
  | Briefing | UserPromptSubmit reports fresh context/limit threshold crossings once per session and threshold cycle |
79
- | Warning history | `last` and `history` read Codex-only, per-session warning records |
79
+ | Warning history | `last` and `history` read Codex-only, per-session records of context (80%/95%) and rate-limit (90%) warnings and handoffs; cache and TTL chips are not recorded |
80
80
  | Handoff | Git state, session/model, recorded context/tokens/limits, and fillable remaining-work sections |
81
81
  | Subagents | Opt-in native spawn model routing, scoped category rules, and SubagentStart guidance |
82
82
  | Cache timer | Resolved provider retention policy after recorded cache usage; exact expiry is unknown |
83
83
  | Route discovery | Repeated simple turns grouped by project, provider, and parent model; no automatic registration |
84
84
  | Ratchet detection | Recent repeated command/patch failures produce `ratchet?` candidates |
85
85
  | Seed presets | Compatible ratchet rules, separate decisions, explicit registration scope |
86
- | Routing saved | Sprag-attributed child tokens priced with matching LiteLLM rates; an estimate, not billed savings |
86
+ | Routing saved | Sprag-attributed child tokens priced by the path the call took (OpenAI list prices for direct calls, the gateway's `/model/info` prices for a LiteLLM provider); signed, so a pricier child is a loss; an estimate, not billed savings |
87
87
  | Diagnostics | `doctor` checks user config, hook registration, harness, logs, panel activity, and preferences |
88
88
  | Capability matrix | `capabilities` lists supported, partial, native, and unsupported areas without invoking a model |
89
89
 
@@ -151,12 +151,39 @@ warning records. A handoff file is never overwritten and identifies quota
151
151
  values as recorded snapshots, not live responses. Fill in its remaining-work
152
152
  sections before using it to continue in another session.
153
153
 
154
- Delegation is independently opt-in via `delegate on`. PreToolUse rewrites native
155
- `spawn_agent` arguments using Codex's `message`, `model`, and `reasoning_effort`
156
- fields. It preserves explicit model choices and custom roles, including custom
157
- overrides of built-in roles. SubagentStart injects scoped ratchets and enabled
158
- writing guidance. Bounds are instructions, not an enforced tool-call budget.
159
- Normal Codex sandbox and approval rules still apply. Review new hooks in `/hooks`.
154
+ Delegation is independently opt-in via `delegate on`. As of Codex 0.159.2,
155
+ `spawn_agent` never reaches `PreToolUse` (measured directly: a recording hook
156
+ saw only `Bash` calls while a spawn happened), so the rewrite below cannot see
157
+ or steer it. The live path instead runs at prompt time: `UserPromptSubmit`
158
+ checks the prompt against your category rules and, on a match, appends a
159
+ `[Sprag model routing]` note naming the target model, effort, and a route id.
160
+ That note by itself changes nothing, because Codex's own instructions tell it not to
161
+ spawn subagents unless something explicitly asked for it. So `harness init`'s
162
+ AGENTS.md block adds the missing half: a `[Sprag model routing]` note *is* the
163
+ user's delegation request, and the agent should spawn exactly what it describes
164
+ with `fork_turns "none"`, then wait for it (`wait_agent` with a timeout of at least
165
+ 120 seconds) instead of doing the same task itself. Attribution does not depend on the child echoing the
166
+ route id back: `SubagentStart` binds the id to whichever spawned child matches
167
+ the pending route (same parent thread, same target model, within 30 minutes),
168
+ so a route is credited even when the child's own first message never repeats it.
169
+
170
+ The hint is withheld below `codex.delegateMinContext` (default 60000 input
171
+ tokens, read from the parent's own rollout). Measured 2026-09-30: a small
172
+ parent session spends more on the coordinating turn (spawning, waiting,
173
+ verifying) than a delegated child saves, so under the floor Sprag leaves the
174
+ request on the main agent instead of proposing a loss. There is no CLI flag for
175
+ it yet; set it by editing `codex.delegateMinContext` (an integer) in the config
176
+ file `sprag mode` prints under "Stored config file" (`~/Library/Application
177
+ Support/claude-token-saver/config.json` on macOS, `$XDG_CONFIG_HOME/claude-token-saver/config.json`
178
+ or `~/.config/claude-token-saver/config.json` on Linux).
179
+
180
+ The `PreToolUse` rewrite (native `spawn_agent` arguments using Codex's
181
+ `message`, `model`, and `reasoning_effort` fields, preserving explicit model
182
+ choices and custom roles) is kept for Codex versions that do route spawns
183
+ through hooks; on 0.159.2 it simply never fires. `SubagentStart` injects scoped
184
+ ratchets and enabled writing guidance regardless of which path routed the
185
+ child. Bounds are instructions, not an enforced tool-call budget. Normal Codex
186
+ sandbox and approval rules still apply. Review new hooks in `/hooks`.
160
187
 
161
188
  ```sh
162
189
  sprag delegate on --agent codex
@@ -174,6 +201,13 @@ exact parent model, and prefer project rules over global rules. Matching prompts
174
201
  request native delegation; unavailable subagent tools leave work on the main
175
202
  agent. Claude tier presets and history are not imported.
176
203
 
204
+ The routing ledger (`Routing Saved` below) prices a child's own tokens at the
205
+ parent model's rate as the counterfactual; it does not charge or subtract the
206
+ parent's coordination turn, so a delegation that barely clears the context
207
+ floor can still show a positive but overstated saving. A child that was
208
+ interrupted before it finished replaced no parent work, so its whole cost is
209
+ booked as a loss and the entry is marked `aborted`.
210
+
177
211
  ## Harness Commands
178
212
 
179
213
  ```sh
@@ -231,6 +265,15 @@ SessionStart reads cached candidates and schedules an eligible refresh outside
231
265
  the hook. Small log changes refresh at most daily; a 5 MiB increase can refresh
232
266
  after an hour. `--refresh` bypasses that schedule.
233
267
 
268
+ Codex 0.159.2 keeps about 2,450 tokens of one hook's context and cuts the
269
+ middle of anything longer. SessionStart therefore fits its parts whole within a
270
+ 2,000-token estimate, in priority order: ratchet rules, Korean guidance,
271
+ cohesion guidance, the document note, then offers. Rules and guidance that do
272
+ not fit are written to a per-project file under the Sprag data directory, and
273
+ the context opens with an instruction to read it. `codex exec` runs, recognized
274
+ from the rollout's `originator`, get no seed offer or route notice, since no one
275
+ can answer them.
276
+
234
277
  `seed` offers only compatible ratchet presets, not Claude model-tier presets.
235
278
  Accept and skip decisions are separate from Claude. Both seed acceptance and
236
279
  route approval require the user's choice of global or project scope. A project
@@ -306,10 +349,24 @@ subagent earns no credit. The estimate holds the child's measured tokens
306
349
  constant and prices them at the parent and child rates. Negative differences
307
350
  remain negative; this is not a comparison of actual billed totals.
308
351
 
309
- Rates come from the configured LiteLLM `/model/info` endpoint, with agreement
310
- required across deployments behind an alias. Price snapshots last seven days.
311
- Providers are kept separate; direct OpenAI/subscription pricing is not inferred.
312
- Missing rates or usage produce unpriced entries, never invented zeroes. A priced
352
+ Prices follow the path each call took, and the two sources never fall back to
353
+ each other:
354
+
355
+ - ChatGPT/OpenAI login or an API key straight to `api.openai.com` uses the
356
+ built-in OpenAI list prices (Standard tier, short-context rates, checked
357
+ 2026-10-01). Long-context surcharges are not modeled, so requests past the
358
+ long-context threshold are under-priced.
359
+ - A LiteLLM provider uses that gateway's `/model/info` prices, with agreement
360
+ required across deployments behind an alias. Price snapshots last seven days.
361
+ A model the gateway does not price stays unpriced; it is not filled in from
362
+ the list prices.
363
+
364
+ `sprag delegate rules --agent codex` shows each rule's measured runs, failure
365
+ rate and signed saving. A rule is flagged for review when the 95% lower bound
366
+ of its failure rate exceeds 20% over at least five runs, as on the Claude side.
367
+
368
+ Providers are kept separate. Negative values are losses: the child ran on a
369
+ pricier model than the parent, and the loss reduces the totals. Missing rates or usage produce unpriced entries, never invented zeroes. A priced
313
370
  entry keeps its rate snapshot across later outages. Pending route records are
314
371
  matched for seven days; saved ledger entries remain afterward. Run `--refresh`
315
372
  after delegation to update the ledger without waiting for the next route scan.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.55.0",
3
+ "version": "3.56.0",
4
4
  "description": "Cut the tokens Claude Code and Codex spend: routes simple work to cheaper models, converts documents to compact Markdown, and shows spend and savings live. Ratchet rules and Korean writing checks included.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Claude-side cost by the path the call took. A session on a LiteLLM gateway
3
+ * (gatewayBase(env) set) is billed by the gateway, so it is priced from its
4
+ * /model/info table and stays unpriced (null) when the table has no entry;
5
+ * the Anthropic list table is never a fallback there. Every other session is a
6
+ * direct call and uses the list table. Kept apart from cost.js because
7
+ * litellm-models.js imports cost.js, and this needs both.
8
+ */
9
+ import { estimateCost, estimateCostAcross } from './cost.js';
10
+ import { gatewayPriceFor } from './litellm-models.js';
11
+ import { gatewayBase } from './gateway-auth.js';
12
+
13
+ export function sessionCost(totals, model, { env = process.env, now } = {}) {
14
+ if (!gatewayBase(env)) return estimateCost(totals, model);
15
+ const price = gatewayPriceFor(model, env, now);
16
+ return price ? estimateCost(totals, model, { price }) : null;
17
+ }
18
+
19
+ /** Like estimateCostAcross, plus how many sessions had no price and were left out. */
20
+ export function sessionCostAcross(sessions, opts = {}) {
21
+ let unpriced = 0;
22
+ const total = estimateCostAcross(sessions, (totals, model) => {
23
+ const c = sessionCost(totals, model, opts);
24
+ if (!c) unpriced++;
25
+ return c;
26
+ });
27
+ return { ...total, unpriced };
28
+ }
@@ -77,6 +77,26 @@ export function runCodexBrief({ sessionId, transcriptPath, cwd, now = Date.now()
77
77
  return next.length ? ['[Sprag Codex briefing]', ...next.map((issue) => `${issue.message} ${issue.advice}`)].join('\n') : null;
78
78
  }
79
79
 
80
+ /**
81
+ * Append one event outside the warning dedup, such as a handoff the user
82
+ * wrote. Claude's history records handoffs too, so both agents' timelines
83
+ * show when work was preserved.
84
+ */
85
+ export function recordCodexHistoryEvent({ sessionId, project = '', key, level = 'info', message, advice = '', now = Date.now(), dir = userDataDir() } = {}) {
86
+ if (typeof message !== 'string' || !message) return false;
87
+ const owner = typeof sessionId === 'string' && sessionId ? sessionId : 'no-session';
88
+ const directory = stateDir(dir);
89
+ const file = join(directory, `${hash(owner)}.json`);
90
+ const state = readState(file);
91
+ const events = [...state.events, { key, tier: 0, signature: key, level, message, advice,
92
+ at: new Date(now).toISOString(), sessionId: owner, project }].slice(-200);
93
+ mkdirSync(directory, { recursive: true });
94
+ const tmp = `${file}.${process.pid}.tmp`;
95
+ writeFileSync(tmp, JSON.stringify({ ...state, events }) + '\n', { mode: 0o600 });
96
+ renameSync(tmp, file);
97
+ return true;
98
+ }
99
+
80
100
  export function readCodexHistory({ days = 7, sessionId, project, dir = userDataDir(), now = Date.now() } = {}) {
81
101
  const directory = stateDir(dir);
82
102
  let files;
@@ -6,6 +6,10 @@ import { codexUserDir } from './agent.js';
6
6
  import { userDataDir } from './paths.js';
7
7
  import { codexBudgetProvider, codexProviderKey } from './codex-budget.js';
8
8
  import { codexCachePolicy } from './codex-cache.js';
9
+ import { PRICE_MAX_AGE_MS, gatewayPrices } from './gateway-prices.js';
10
+ import { OPENAI_LIST_PRICES, OPENAI_PRICES_CHECKED_AT } from './openai-prices.js';
11
+
12
+ export { PRICE_MAX_AGE_MS, rate, deploymentPrice, gatewayPrices, codexRunCost } from './gateway-prices.js';
9
13
 
10
14
  export const CACHE_POLICY_MAX_AGE_MS = 300000;
11
15
  const OPENAI_POLICY = 'https://developers.openai.com/api/docs/guides/prompt-caching#cache-lifetime';
@@ -51,42 +55,6 @@ export function gatewayCachePolicies(payload) {
51
55
  policies[0] && policies.every((p) => JSON.stringify(p) === JSON.stringify(policies[0])) ? policies[0] : null]));
52
56
  }
53
57
 
54
- export const PRICE_MAX_AGE_MS = 7 * 86400000;
55
- const rate = (value) => Number.isFinite(value) && value >= 0 && value < 1 ? value : null;
56
-
57
- function deploymentPrice(row) {
58
- const info = row?.model_info || {};
59
- const mode = typeof info.mode === 'string' ? info.mode : null;
60
- if (mode && !['chat', 'responses', 'completion'].includes(mode)) return null;
61
- const input = rate(info.input_cost_per_token), output = rate(info.output_cost_per_token);
62
- if (input === null || output === null) return null;
63
- return { input, output, cacheRead: rate(info.cache_read_input_token_cost), cacheWrite: rate(info.cache_creation_input_token_cost) };
64
- }
65
-
66
- /** Per-token prices by alias. Load-balanced deployments must agree, like cache policy. */
67
- export function gatewayPrices(payload) {
68
- if (!Array.isArray(payload?.data)) throw new Error('LiteLLM model/info returned no model list');
69
- const groups = new Map();
70
- for (const row of payload.data) {
71
- if (typeof row?.model_name !== 'string' || row.model_name.length > 256) continue;
72
- groups.set(row.model_name, [...(groups.get(row.model_name) || []), deploymentPrice(row)]);
73
- }
74
- return Object.fromEntries([...groups].map(([name, prices]) => [name,
75
- prices[0] && prices.every((p) => JSON.stringify(p) === JSON.stringify(prices[0])) ? prices[0] : null]));
76
- }
77
-
78
- /**
79
- * USD for a token mix at one model's prices, or null when a needed rate is
80
- * missing. Cached input without a cache-read rate is unknown, not free.
81
- */
82
- export function codexRunCost({ input = 0, cached = 0, cacheWrite = 0, output = 0 }, price) {
83
- if (!price || price.input === null || price.output === null) return null;
84
- if (cached > 0 && price.cacheRead === null) return null;
85
- // cache_write_input_tokens is billed at the creation rate when one is published, else as input.
86
- const uncached = Math.max(0, input - cached - cacheWrite);
87
- return uncached * price.input + cached * (price.cacheRead ?? 0) + cacheWrite * (price.cacheWrite ?? price.input) + output * price.output;
88
- }
89
-
90
58
  function cacheFile(provider, { home = codexUserDir(), dir = userDataDir() } = {}) {
91
59
  const key = createHash('sha256').update(JSON.stringify([resolve(home), provider.name, provider.base])).digest('hex').slice(0, 24);
92
60
  return join(dir, 'codex-cache-policy', `${key}.json`);
@@ -97,14 +65,31 @@ function gatewayProvider(session, opts) {
97
65
  return codexBudgetProvider({ ...opts, providerName: session.provider });
98
66
  }
99
67
 
100
- function directPolicy(session, { home = codexUserDir(), now = Date.now() } = {}) {
101
- if (session?.provider !== 'openai' || !codexCachePolicy(session.model)) return null;
68
+ /** True when Codex's OpenAI provider talks to api.openai.com itself, not to a proxy. No config = direct. */
69
+ export function isDirectOpenAI(provider, { home = codexUserDir() } = {}) {
70
+ if (provider !== 'openai') return false;
102
71
  try {
103
72
  let cfg = {};
104
73
  try { cfg = parse(readFileSync(join(home, 'config.toml'), 'utf8')); }
105
- catch (e) { if (e.code !== 'ENOENT') return null; }
106
- if (cfg.openai_base_url && new URL(cfg.openai_base_url).hostname !== 'api.openai.com') return null;
107
- } catch { return null; }
74
+ catch (e) { if (e.code !== 'ENOENT') return false; }
75
+ if (cfg.openai_base_url && new URL(cfg.openai_base_url).hostname !== 'api.openai.com') return false;
76
+ } catch { return false; }
77
+ return true;
78
+ }
79
+
80
+ /**
81
+ * The price table a Codex provider's calls are billed by: OpenAI list prices for
82
+ * direct calls, the gateway's own /model/info prices for a LiteLLM provider.
83
+ * Never a fallback chain, so a model the gateway does not price stays unpriced.
84
+ */
85
+ export function codexPriceBook(provider, litellm, opts = {}) {
86
+ if (isDirectOpenAI(provider, opts)) return { source: 'list', provider: 'openai', checkedAt: OPENAI_PRICES_CHECKED_AT, prices: OPENAI_LIST_PRICES };
87
+ if (litellm?.provider === provider) return { ...litellm, source: 'litellm' };
88
+ return null;
89
+ }
90
+
91
+ function directPolicy(session, { home = codexUserDir(), now = Date.now() } = {}) {
92
+ if (!codexCachePolicy(session?.model) || !isDirectOpenAI(session?.provider, { home })) return null;
108
93
  return { backend: 'OpenAI', model: session.model, seconds: codexCachePolicy(session.model),
109
94
  requestedModel: session.model, provider: session.provider, checkedAt: now, reference: OPENAI_POLICY };
110
95
  }
@@ -7,6 +7,7 @@ import { codexUserDir, codexProviderName } from './agent.js';
7
7
  import { categorize, ESCALATE_RE, EDIT_RE } from './route-scan.js';
8
8
  import { looksPasted } from './route-inject.js';
9
9
  import { newRouteId, recordCodexDelegation } from './codex-ledger.js';
10
+ import { readCodexTailLines } from './codex-parser.js';
10
11
 
11
12
  export const CODEX_ROUTE_CATEGORIES = ['paste', 'translate', 'explore', 'read', 'check', 'run'];
12
13
  const MARKER = '<!-- sprag:codex:delegation -->';
@@ -88,13 +89,50 @@ export function matchCodexModelRule(text, { model, root, provider = codexProvide
88
89
  return usable.find((r) => r.scope === 'project') || usable[0] || null;
89
90
  }
90
91
 
92
+ /**
93
+ * Input tokens from the rollout's last token_count record, or 0 when the
94
+ * transcript is missing or unreadable. Only the tail is read: the newest
95
+ * measurement is always near the end of the file.
96
+ */
97
+ export function codexContextTokens(transcriptPath) {
98
+ const lines = readCodexTailLines(transcriptPath, 512 * 1024);
99
+ if (!lines) return 0;
100
+ for (let i = lines.length - 1; i >= 0; i--) {
101
+ const line = lines[i];
102
+ if (!line || !line.includes('token_count')) continue;
103
+ let e;
104
+ try { e = JSON.parse(line); } catch { continue; }
105
+ const n = e?.payload?.type === 'token_count' ? e.payload?.info?.last_token_usage?.input_tokens : undefined;
106
+ if (Number.isFinite(n) && n >= 0) return n;
107
+ }
108
+ return 0;
109
+ }
110
+
111
+ /**
112
+ * A prompt-time routing suggestion for Codex 0.159.2, where `spawn_agent`
113
+ * never reaches PreToolUse (see codexDelegationTool below). Gated on parent
114
+ * context size: a small parent's own coordination overhead outweighs the
115
+ * savings from a delegated child (measured 2026-09-30, see docs/CODEX.md).
116
+ */
91
117
  export function codexRouteHint(payload, opts = {}) {
92
- const rule = matchCodexModelRule(payload?.prompt, { provider: payload?.model_provider || codexProviderName(), ...opts, model: payload?.model, root: opts.root || payload?.cwd });
118
+ const provider = payload?.model_provider || codexProviderName();
119
+ const rule = matchCodexModelRule(payload?.prompt, { provider, ...opts, model: payload?.model, root: opts.root || payload?.cwd });
93
120
  if (!rule) return null;
94
- return `[Sprag model routing] This request matches the approved ${rule.category} rule: delegate the bounded task to model ${rule.model}`
95
- + (rule.effort ? ` with reasoning_effort ${rule.effort}` : '')
96
- + '. Use the available native Codex subagent tool and a built-in role. Wait for its result, verify it, and report unfinished work. '
97
- + 'Do not spawn another CLI process. If subagent tools or the target model are unavailable, keep the task on the main agent and state that delegation was unavailable.';
121
+ const minContext = opts.minContext ?? 60000;
122
+ const ctx = opts.contextTokens ?? codexContextTokens(payload?.transcript_path);
123
+ if (ctx < minContext) return null;
124
+ const id = newRouteId();
125
+ const record = opts.record ?? recordCodexDelegation;
126
+ try {
127
+ record({ id, parentSessionId: payload?.session_id ?? null, turnId: payload?.turn_id ?? null, via: 'prompt',
128
+ source: 'rule', category: rule.category, scope: rule.scope, from: payload?.model, to: rule.model,
129
+ provider, effort: rule.effort ?? null, contextTokens: ctx }, { dir: opts.dir });
130
+ } catch { /* Accounting is optional; the hint still applies. */ }
131
+ return `[Sprag model routing] This request matches the approved ${rule.category} rule (route ${id}). `
132
+ + `Spawn one sub-agent with model ${rule.model}${rule.effort ? `, reasoning_effort ${rule.effort}` : ''} and fork_turns "none". `
133
+ + `Give it a self-contained task message that ends with the line <!-- sprag:codex:route id=${id} -->. `
134
+ + 'Wait for it to finish (call wait_agent with timeout_ms of at least 120000, and again if it times out) and do not do the delegated task yourself while it runs; then verify its result and report unfinished work. Do not spawn another CLI process. '
135
+ + 'If sub-agent tools or the target model are unavailable, keep the task on the main agent and state that delegation was unavailable.';
98
136
  }
99
137
 
100
138
  function customRole(role, root, home) {
@@ -120,6 +158,8 @@ function customRole(role, root, home) {
120
158
  return false;
121
159
  }
122
160
 
161
+ // Kept for Codex versions that do send spawn_agent through PreToolUse; on
162
+ // 0.159.2 the hook never fires for it, so codexRouteHint above is the live path.
123
163
  export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd || process.cwd(), home = codexUserDir(), rules, dir = userDataDir(), record = recordCodexDelegation } = {}) {
124
164
  if (cfg.codex?.delegate !== true) return null;
125
165
  const tool = payload?.tool_name?.replace(/^(?:functions|tools)\./, '');
@@ -12,7 +12,7 @@ export const CODEX_CAPABILITIES = [
12
12
  { feature: 'reports', support: 'supported', command: 'sprag --agent codex --format json', note: 'Token usage, cached input, reasoning, session and project filters.' },
13
13
  { feature: 'harness', support: 'supported', command: 'sprag harness check --agent codex', note: 'AGENTS.md, overrides, explicit-scope ratchet rule management.' },
14
14
  { feature: 'brief', support: 'supported', command: 'sprag brief --agent codex', note: 'Fresh context and limit warnings; change-triggered prompt hook.' },
15
- { feature: 'history', support: 'supported', command: 'sprag history --agent codex', note: 'Codex-only session warning records; last shows the latest warning.' },
15
+ { feature: 'history', support: 'supported', command: 'sprag history --agent codex', note: 'Codex-only records of context (80%/95%) and 5h/weekly rate-limit (90%) warnings, plus handoffs. Cache and TTL chips are not recorded; last shows the latest event.' },
16
16
  { feature: 'handoff', support: 'supported', command: 'sprag handoff --agent codex', note: 'Git state and recorded Codex session usage; never reads Claude caps.' },
17
17
  { feature: 'korean', support: 'supported', command: 'sprag korean status --agent codex', note: 'Session guidance and recognized write-target lint; shared preference.' },
18
18
  { feature: 'cohesion', support: 'supported', command: 'sprag cohesion status --agent codex', note: 'Shared English guidance preference.' },
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { codexUserDir } from './agent.js';
5
- import { codexToolEvents, codexLegacyToolEvent, readCodexTailLines } from './codex-parser.js';
5
+ import { codexToolEvents, codexLegacyToolEvent, readCodexTailLines, forkedCopyFilter } from './codex-parser.js';
6
6
 
7
7
  export const CODEX_BEGIN = '<!-- sprag:codex:harness:begin -->';
8
8
  export const CODEX_END = '<!-- sprag:codex:harness:end -->';
@@ -23,7 +23,7 @@ export function codexHarnessPaths(root, scope = 'project') {
23
23
  }
24
24
 
25
25
  export function codexHarnessBlock() {
26
- return `${CODEX_BEGIN}\n## Sprag Harness\n\n${sections.map(([name, text], i) => `### ${i + 1}. ${name}\n${text}`).join('\n\n')}\n\nRead the global and project Codex ratchet files before working when present. Run \`sprag harness list --agent codex --global\` and \`sprag harness list --agent codex --project\` to retrieve them. These are instructions, not Claude-style @ imports.\nUse \`sprag doc2md <file> --agent codex\` before reading PDF, Office, or Figma documents. Delegate only through available Codex tools and configured roles; do not assume Claude model names or Task/Agent arguments.\n${CODEX_END}\n`;
26
+ return `${CODEX_BEGIN}\n## Sprag Harness\n\n${sections.map(([name, text], i) => `### ${i + 1}. ${name}\n${text}`).join('\n\n')}\n\nRead the global and project Codex ratchet files before working when present. Run \`sprag harness list --agent codex --global\` and \`sprag harness list --agent codex --project\` to retrieve them. These are instructions, not Claude-style @ imports.\nUse \`sprag doc2md <file> --agent codex\` before reading PDF, Office, or Figma documents. Delegate only through available Codex tools and configured roles; do not assume Claude model names or Task/Agent arguments. A [Sprag model routing] note on a request means the user has asked for that request to be delegated: spawn the sub-agent it describes (its model, reasoning_effort, fork_turns "none", and a self-contained message ending with the route line it gives), wait for it to finish instead of doing the same task yourself, then verify the result before replying.\n${CODEX_END}\n`;
27
27
  }
28
28
 
29
29
  function range(content) {
@@ -133,11 +133,8 @@ function countable(ev) {
133
133
  return true;
134
134
  }
135
135
 
136
- /**
137
- * Repeated failures in a rollout's recent turns, newest-first by count. Reads
138
- * only the file tail, which is where the current session's recent work is.
139
- */
140
- export function codexRatchetCandidates(filePath, { now = Date.now(), turns: window = 30, ttlMs = 30 * 60000, tailBytes = 2 * 1024 * 1024, home } = {}) {
136
+ /** Countable failures in one rollout's last `window` turns. Reads only the file tail. */
137
+ function rolloutFailures(filePath, { window, tailBytes }) {
141
138
  const lines = readCodexTailLines(filePath, tailBytes);
142
139
  if (!lines) return [];
143
140
  const order = [];
@@ -147,11 +144,14 @@ export function codexRatchetCandidates(filePath, { now = Date.now(), turns: wind
147
144
  const outputs = [];
148
145
  let cwd = null;
149
146
  let current = null;
147
+ const copied = forkedCopyFilter();
150
148
  for (const line of lines) {
151
149
  // Cheap prefilter: these candidate lines are a small share of a rollout.
152
- if (!/"(?:item_completed|exec_command_end|patch_apply_end|task_started|turn_context|session_meta|function_call|function_call_output|custom_tool_call|custom_tool_call_output)"/.test(line)) continue;
150
+ if (!/"(?:item_completed|exec_command_end|patch_apply_end|task_started|turn_context|session_meta|thread_settings_applied|function_call|function_call_output|custom_tool_call|custom_tool_call_output)"/.test(line)) continue;
153
151
  let e;
154
152
  try { e = JSON.parse(line); } catch { continue; }
153
+ // A forked child's copy of the parent history holds the parent's failures, not its own.
154
+ if (copied(e)) continue;
155
155
  const p = e?.payload;
156
156
  if (e?.type === 'session_meta' && typeof p?.cwd === 'string') cwd ??= p.cwd;
157
157
  if (e?.type === 'turn_context' && typeof p?.cwd === 'string') cwd = p.cwd;
@@ -178,9 +178,20 @@ export function codexRatchetCandidates(filePath, { now = Date.now(), turns: wind
178
178
  if (countable(ev)) failures.push(ev);
179
179
  }
180
180
  const recent = new Set(order.slice(-window));
181
+ return failures.filter((ev) => recent.has(ev.turn));
182
+ }
183
+
184
+ /**
185
+ * Repeated failures in recent turns, newest-first by count. `siblings` are
186
+ * other rollouts of the same project: every `codex exec` run writes its own
187
+ * file, so a mistake repeated across runs only shows up when they are read
188
+ * together. The TTL applies to all of them.
189
+ */
190
+ export function codexRatchetCandidates(filePath, { now = Date.now(), turns: window = 30, ttlMs = 30 * 60000, tailBytes = 2 * 1024 * 1024, home, siblings = [] } = {}) {
191
+ const failures = [filePath, ...siblings].filter(Boolean).flatMap((file) => rolloutFailures(file, { window, tailBytes }));
181
192
  const groups = new Map();
182
193
  for (const ev of failures) {
183
- if (!recent.has(ev.turn) || !Number.isFinite(ev.at) || now - ev.at > ttlMs) continue;
194
+ if (!Number.isFinite(ev.at) || now - ev.at > ttlMs) continue;
184
195
  const pattern = codexErrorSignature(ev, { cwd: ev.cwd, home });
185
196
  const g = groups.get(pattern) || { pattern, count: 0, lastAt: 0 };
186
197
  g.count++;