sprag-cli 3.57.0 → 3.58.1

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
@@ -36,50 +36,25 @@ Sprag는 Claude Code와 Codex가 남기는 세션 기록을 읽어 반복 실패
36
36
  npm i -g sprag-cli # 예전 이름 claude-token-saver 와 같은 패키지입니다
37
37
  ```
38
38
 
39
- ## Codex
39
+ ## 설치하면 바로 달라지는 것
40
40
 
41
- Codex 기본 훅과 `AGENTS.md` 하네스, 범위별 래칫 규칙,
42
- 한국어 검사, 프롬프트의 문서 경로 자동 변환, 로컬 토큰 보고서를 사용할 수 있습니다.
43
- Codex가 설치되어 있으면 `npm i -g sprag-cli` 하나로 Codex도 설정하고, 추천 룰을 묻지 않고
44
- 전역으로 등록합니다(단계마다 확인하려면 `sprag install --manual`). Claude Code를 건드리지 않고
45
- Codex만 쓴다면 다음과 같이 설치하십시오.
41
+ 1. `npm i -g sprag-cli` 를 실행합니다. 훅과 상태줄, 추천 룰이 모든 프로젝트에
42
+ 설정되므로 프로젝트마다 따로 설정할 것은 없습니다. 이미 쓰던 상태줄이 있으면
43
+ 둘 다 표시할지, 교체할지, 그대로 둘지 설치 중에 묻습니다.
44
+ 2. 아무 프로젝트에서나 Claude Code를 열고 평소처럼 일합니다.
45
+ 3. 상태줄을 봅니다. 세션에서 쓴 금액과 아낀 금액이 나오고, 캐시나 컨텍스트,
46
+ 사용량 한도에 문제가 생기면 경고 칩이 줄 맨 앞에 나타납니다.
46
47
 
47
- ```bash
48
- npm i -g sprag-cli --ignore-scripts
49
- sprag install --agent codex
50
- sprag --agent codex --days 7
51
- sprag panel --agent codex # 별도 터미널에서 실시간 패널 실행
52
- sprag --statusline --agent codex --text
53
- sprag doctor --agent codex # 문제가 생겼을 때만
54
- sprag route-scan --agent codex
55
- sprag seed --agent codex
48
+ ```
49
+ 🔀 Routing saved $188 | opus→sonnet 67× $93.4 · fable→sonnet 26× $83.7 · opus→haiku 25× $7.41 · fable→haiku 6× $3.73
50
+ 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 🅷 5/5 · 📦 Ctx 47% of 1M
56
51
  ```
57
52
 
58
- 설치 후 Codex의 `/hooks`에서 새 훅을 검토하고 신뢰해야 실행됩니다. Codex가 요구하는
59
- 절차입니다. 명령의 기본 대상은 여전히 Claude Code이며, `--agent codex`를 붙이면 Codex를 고릅니다. Codex에서도 세션 브리핑, 경고 이력, 인수인계
60
- 파일을 지원합니다. `sprag delegate on --agent codex`로 모델 위임과 하위 에이전트
61
- 지침 전달을 켤 수 있습니다. 상태 표시는 Sprag 터미널 상태줄과 패널을 사용하십시오.
62
- Codex 기본 상태줄은 셸 명령을 실행하지 않습니다. 반복된 단순 작업은 위임 후보로,
63
- 반복된 도구 실패는 래칫 후보로 제안합니다. 규칙을 등록하려면 사용자의 승인이 필요하며,
64
- `seed`로 호환되는 기본 규칙을 선택할 수도 있습니다.
65
- `sprag route-scan savings --agent codex`는 Sprag가 위임한 실행만 집계하고,
66
- 일치하는 LiteLLM 단가가 있을 때 예상 차액을 계산합니다. 청구 금액을 측정한 값은 아니며,
67
- 단가를 확인하지 못하면 `n/a`로 표시합니다. Codex 문서 누계는 Claude 추정치와 분리합니다.
68
- 아래의 기존 절감 수치는 Claude Code 기록이며 Codex 벤치마크가 아닙니다.
69
- [Codex 지원 범위와 제한](https://sprag.io/docs/ko/codex/)이나
70
- `sprag capabilities --agent codex`에서 기능별 지원 여부를 확인하십시오.
71
-
72
- | 기능 | Claude Code | Codex |
73
- |---|---|---|
74
- | 세션 지침 | `CLAUDE.md`와 훅 | `AGENTS.md`와 기본 훅 |
75
- | 실시간 상태 | 기본 상태줄에 명령 등록 | 별도 패널 또는 tmux 구성 |
76
- | 규칙 후보 | Claude 기록과 모델 등급 프리셋 | Codex 기록과 호환 래칫 프리셋 |
77
- | 모델 위임 | Haiku·Sonnet 등급 규칙 | 공급자 모델 ID와 범위별 규칙 |
78
- | 위임 원장 | Claude 실행별 예상 차액 | LiteLLM 단가와 하위 실행별 예상 차액 |
79
-
80
- 두 연동을 함께 설치할 수 있습니다. `--agent codex`를 붙이지 않으면 기존 Claude 동작을 유지합니다.
53
+ Sprag는 사용자가 고른 모델을 바꾸지 않습니다. 분석은 로컬 세션 기록만 읽으며,
54
+ 추가 모델 호출이나 API 키가 필요하지 않습니다. `sprag uninstall` 은 설치 때
55
+ 추가한 것을 모두 지우고, 함께 표시하던 기존 상태줄을 되돌려 놓습니다.
81
56
 
82
- ![statusline 예시. 첫 줄은 라우팅 절감액, 둘째 줄은 문서 변환 절감액, 셋째 줄은 진단 칩입니다](https://raw.githubusercontent.com/rootstudioyaml/sprag/main/docs/statusline.png)
57
+ Codex를 쓴다면 아래 [Codex](#codex) 절을 보십시오.
83
58
 
84
59
  ## 작동 방식
85
60
 
@@ -99,7 +74,7 @@ Sprag 는 사용자가 지정한 모델을 바꾸지 않습니다. 이미 실행
99
74
  | 공개 벤치마크 정확도 | **59.1%** · 단일 최고 모델 57.9% | `▰▰▰▰▰▰▰▰▰▰▰▰` | [벤치마크](https://github.com/rootstudioyaml/sprag/blob/main/docs/BENCHMARK.md) |
100
75
  | 같은 문항 총비용 | **$268** · gpt-5 $388 · gemini-2.5-pro $734 | `▰▰▰▰▰▱▱▱▱▱▱▱` | [벤치마크](https://github.com/rootstudioyaml/sprag/blob/main/docs/BENCHMARK.md) |
101
76
  | 같은 문서들의 토큰 | **−95.9%** · 2,011,178 → 82,209 (12건) | `▰▱▱▱▱▱▱▱▱▱▱▱` | [doc2md](https://github.com/rootstudioyaml/sprag/blob/main/docs/DOC2MD.md) |
102
- | 위임 절감액 (원장) | **$44.67** · 위임 69건 | `▰▰▰▰▰▰▰▰▰▰▰▱` | `sprag route-scan savings` |
77
+ | 위임 절감액 (원장) | **$188.27** · 위임 124건 | `▰▰▰▰▰▰▰▰▰▰▰▱` | `sprag route-scan savings` |
103
78
  | 같은 프롬프트를 저가 등급에 | **비용 24%** · 여덟 건 중 한 건이 더 틀림 | `▰▰▰▱▱▱▱▱▱▱▱▱` | [A/B 실측](#같은-프롬프트를-저가-등급에-돌리면) |
104
79
 
105
80
  앞의 두 줄은 다음 반박에 답하려고 있습니다. 저가 모델에 일을 내려보내면 정확도가 떨어질 것이라는 반박입니다. 공개 데이터에서는 떨어지지 않았습니다. 같은 판정 기준을 라우터로 만들어 11,696건에 적용했더니 정확도가 단일 최고 모델보다 1.2%p 높고 비용은 3분의 1이었습니다. 정확도가 오른 까닭은 간단합니다. 모든 유형에서 1등인 모델은 없기 때문입니다.
@@ -108,15 +83,20 @@ LLMRouterBench(Findings of ACL 2026)에서 오프라인으로 측정했습니다
108
83
 
109
84
  다만 이렇게 확인한 것은 판정 기준이고, sprag 가 실제로 쓰는 모델 조합은 아닙니다. 데이터셋에 Claude 계열이 없어서 deepseek-v3 와 qwen3-235b, gpt-5 가 각각 저가·중간·상위 등급을 대신했습니다.
110
85
 
111
- 뒤의 세 줄은 이 머신에서 실제로 쌓인 기록이라 시간이 지나면 값이 커집니다. 아래 수치는 2026-09-18 에 뽑았습니다.
86
+ 뒤의 세 줄은 이 머신에서 실제로 쌓인 기록이라 시간이 지나면 값이 커집니다. 위임 수치는 2026-10-09 에, 문서 변환 수치는 2026-09-18 에 뽑았습니다. 모델 이름은 각 실행이 실제로 쓴 모델입니다.
112
87
 
113
88
  ### 지금 이 머신의 최근 30일
114
89
 
115
90
  ```
116
- 🔀 라우팅 절감 누적 $44.67 (최근 7일 $23.21 · 30일 $42.73)
117
-
118
- opus-5 → sonnet-5 59회 $39.75 ▰▰▰▰▰▰▰▰▰▰▱▱
119
- opus-5 → haiku-4.5 10회 $4.92 ▰▱▱▱▱▱▱▱▱▱▱▱
91
+ 🔀 라우팅 절감 누적 $188.27 (최근 7일 $13.31 · 30일 $150.64)
92
+
93
+ fable-5 → sonnet-5 24회 $81.19 ▰▰▰▰▰▰▰▰▰▰▰▰
94
+ opus-5 → sonnet-5 54회 $68.85 ▰▰▰▰▰▰▰▰▰▰▱▱
95
+ opus-5 → sonnet-5.5 13회 $24.58 ▰▰▰▰▱▱▱▱▱▱▱▱
96
+ opus-5 → haiku-4.5 25회 $7.41 ▰▱▱▱▱▱▱▱▱▱▱▱
97
+ fable-5 → haiku-4.5 5회 $3.22 ▰▱▱▱▱▱▱▱▱▱▱▱
98
+ fable-5 → sonnet-5.5 2회 $2.51 ▰▱▱▱▱▱▱▱▱▱▱▱
99
+ fable-5 → haiku-5.5 1회 $0.51 ▰▱▱▱▱▱▱▱▱▱▱▱
120
100
  ```
121
101
 
122
102
  | 함께 측정한 값 | 30일 기준 | |
@@ -147,8 +127,8 @@ LLMRouterBench(Findings of ACL 2026)에서 오프라인으로 측정했습니다
147
127
  ## statusline 한 줄로 보는 상태
148
128
 
149
129
  ```
150
- 🔀 라우팅 절감 $44.67 | opus→sonnet 59회 $39.75 · opus→haiku 10회 $4.92
151
- 🚨 5H ▰▰▰▰▰▰▰▰▰▰▰▱ 94% 🔄 12:36 · 🅷 5/5 · 🤖 Opus 5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 📅 weekly ▰▰▰▰▰▱▱▱▱▱▱▱ 38% · 💵 Sep $42 · 📦 Ctx 47% of 1M
130
+ 🔀 라우팅 절감 $188 | opus→sonnet 67회 $93.4 · fable→sonnet 26회 $83.7 · opus→haiku 25회 $7.41 · fable→haiku 6회 $3.73
131
+ 🚨 5H ▰▰▰▰▰▰▰▰▰▰▰▱ 94% 🔄 12:36 · 🅷 5/5 · 🤖 Opus 5.5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 📅 weekly ▰▰▰▰▰▱▱▱▱▱▱▱ 38% · 💵 Sep $42 · 📦 Ctx 47% of 1M
152
132
  ```
153
133
 
154
134
  문제가 생기면 경고 칩이 줄 맨 앞으로 옵니다. `🚨 5H/7D NN%`, `⚠ Ctx 500k+`, `⚠ Cache miss`, `⚠ Input spike`, `⚠ Output heavy`, `⚠ Call surge`, `⚠ Rebuild churn`, `⚠ 5m TTL` 여덟 가지입니다. 칩 문구를 Claude 에게 그대로 붙여 넣으면 Skill 이 원인 코드와 조치 순서를 꺼내 줍니다. 세그먼트마다 무엇을 뜻하는지는 [statusline 문서](https://github.com/rootstudioyaml/sprag/blob/main/docs/STATUSLINE.ko.md)에 정리했습니다.
@@ -176,11 +156,56 @@ JetBrains IDE 내장 터미널에서는 이모지 대신 한 칸 글리프로
176
156
  | 측정 항목 | 값 | 표본 | 기간 |
177
157
  |---|---|---|---|
178
158
  | 문서 변환 | 2,011,178 → 82,209 토큰 | 12건 (xlsx·pptx·pdf) | 2026-09-18 까지 누적 |
179
- | 위임 실행 | $44.67 절감 · 건당 $0.65 | 69건 | 2026-09-18 까지 누적 |
159
+ | 위임 실행 | $188.27 절감 · 건당 $1.52 | 124건 | 2026-10-09 까지 누적 |
180
160
  | 래칫 규칙 누적 | 37건 (글로벌 30 · 프로젝트 7) | 저장소 4곳 | 2026-05-08 ~ 09-16 |
181
161
 
182
162
  래칫 규칙과 위임 규칙은 늘어나는 방식이 다릅니다. 래칫 규칙은 사람이 저지른 실수에서 나오므로 계속 늘어납니다. 위임 규칙은 멈춥니다. 기준이 알아보는 작업 유형이 정해져 있어서, 유형마다 규칙이 하나 생기면 더 승격할 것이 없습니다.
183
163
 
164
+ ## Codex
165
+
166
+ Codex 기본 훅과 `AGENTS.md` 하네스, 범위별 래칫 규칙,
167
+ 한국어 검사, 프롬프트의 문서 경로 자동 변환, 로컬 토큰 보고서를 사용할 수 있습니다.
168
+ Codex가 설치되어 있으면 `npm i -g sprag-cli` 하나로 Codex도 설정하고, 추천 룰을 묻지 않고
169
+ 전역으로 등록합니다(단계마다 확인하려면 `sprag install --manual`). Claude Code를 건드리지 않고
170
+ Codex만 쓴다면 다음과 같이 설치하십시오.
171
+
172
+ ```bash
173
+ npm i -g sprag-cli --ignore-scripts
174
+ sprag install --agent codex
175
+ sprag --agent codex --days 7
176
+ sprag panel --agent codex # 별도 터미널에서 실시간 패널 실행
177
+ sprag --statusline --agent codex --text
178
+ sprag doctor --agent codex # 문제가 생겼을 때만
179
+ sprag route-scan --agent codex
180
+ sprag seed --agent codex
181
+ ```
182
+
183
+ 설치 후 Codex의 `/hooks`에서 새 훅을 검토하고 신뢰해야 실행됩니다. Codex가 요구하는
184
+ 절차입니다. 명령의 기본 대상은 여전히 Claude Code이며, `--agent codex`를 붙이면 Codex를 고릅니다. Codex에서도 세션 브리핑, 경고 이력, 인수인계
185
+ 파일을 지원합니다. `sprag delegate on --agent codex`로 모델 위임과 하위 에이전트
186
+ 지침 전달을 켤 수 있습니다. 상태 표시는 Sprag 터미널 상태줄과 패널을 사용하십시오.
187
+ Codex 기본 상태줄은 셸 명령을 실행하지 않습니다. 반복된 단순 작업은 위임 후보로,
188
+ 반복된 도구 실패는 래칫 후보로 제안합니다. 규칙을 등록하려면 사용자의 승인이 필요하며,
189
+ `seed`로 호환되는 기본 규칙을 선택할 수도 있습니다.
190
+ `sprag route-scan savings --agent codex`는 Sprag가 위임한 실행만 집계하고,
191
+ 일치하는 LiteLLM 단가가 있을 때 예상 차액을 계산합니다. 청구 금액을 측정한 값은 아니며,
192
+ 단가를 확인하지 못하면 `n/a`로 표시합니다. Codex 문서 누계는 Claude 추정치와 분리합니다.
193
+ 아래의 기존 절감 수치는 Claude Code 기록이며 Codex 벤치마크가 아닙니다.
194
+ [Codex 지원 범위와 제한](https://sprag.io/docs/ko/codex/)이나
195
+ `sprag capabilities --agent codex`에서 기능별 지원 여부를 확인하십시오.
196
+
197
+ | 기능 | Claude Code | Codex |
198
+ |---|---|---|
199
+ | 세션 지침 | `CLAUDE.md`와 훅 | `AGENTS.md`와 기본 훅 |
200
+ | 실시간 상태 | 기본 상태줄에 명령 등록 | 별도 패널 또는 tmux 구성 |
201
+ | 규칙 후보 | Claude 기록과 모델 등급 프리셋 | Codex 기록과 호환 래칫 프리셋 |
202
+ | 모델 위임 | Haiku·Sonnet 등급 규칙 | 공급자 모델 ID와 범위별 규칙 |
203
+ | 위임 원장 | Claude 실행별 예상 차액 | LiteLLM 단가와 하위 실행별 예상 차액 |
204
+
205
+ 두 연동을 함께 설치할 수 있습니다. `--agent codex`를 붙이지 않으면 기존 Claude 동작을 유지합니다.
206
+
207
+ ![statusline 예시. 첫 줄은 라우팅 절감액, 둘째 줄은 문서 변환 절감액, 셋째 줄은 진단 칩입니다](https://raw.githubusercontent.com/rootstudioyaml/sprag/main/docs/statusline.png)
208
+
184
209
  ## 자주 쓰는 명령
185
210
 
186
211
  | 명령 | 하는 일 |
package/README.md CHANGED
@@ -48,51 +48,27 @@ existing credentials. The CLI requires Node.js 18+ and one TOML parser dependenc
48
48
  npm i -g sprag-cli # Claude Code, plus Codex when it is installed
49
49
  ```
50
50
 
51
- ## Codex
51
+ ## After you install
52
52
 
53
- Native hooks, an `AGENTS.md` harness, scoped ratchet rules, Korean lint,
54
- document conversion, token reports, and a session-bound companion panel.
55
- `npm i -g sprag-cli` sets Codex up too when it is installed, with the rule
56
- presets registered globally and nothing asked (`sprag install --manual` asks at
57
- each step). For a Codex-only install that leaves Claude Code alone:
53
+ 1. Run `npm i -g sprag-cli`. Hooks, the statusline and the starter rules are set
54
+ up for every project; there is nothing to configure per project. If you
55
+ already have a statusline, the install asks whether to show both, replace
56
+ yours, or keep yours.
57
+ 2. Open Claude Code in any project and work as usual.
58
+ 3. Watch the statusline. It shows what the session spent and saved, and a
59
+ warning chip leads the line when the cache, the context or a rate limit
60
+ needs attention:
58
61
 
59
- ```bash
60
- npm i -g sprag-cli --ignore-scripts
61
- sprag install --agent codex
62
- sprag --agent codex --days 7
63
- sprag panel --agent codex # live companion in a separate terminal
64
- sprag --statusline --agent codex --text
65
- sprag doctor --agent codex # troubleshooting only
66
- sprag route-scan --agent codex
67
- sprag seed --agent codex
62
+ ```
63
+ 🔀 Routing saved $188 | opus→sonnet 67× $93.4 · fable→sonnet 26× $83.7 · opus→haiku 25× $7.41 · fable→haiku 6× $3.73
64
+ 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 🅷 5/5 · 📦 Ctx 47% of 1M
68
65
  ```
69
66
 
70
- Review and trust the new hooks in Codex's `/hooks` menu; Codex requires that
71
- step. Commands still default to Claude Code; add `--agent codex` to pick Codex.
72
- Codex also supports session briefings, warning history, handoffs, and opt-in
73
- model routing and subagent guidance (`sprag delegate on --agent codex`). The native Codex footer
74
- does not accept shell commands; Sprag provides a terminal statusline and panel.
75
- Recurring simple turns become route candidates; repeated tool failures become
76
- ratchet candidates. Both require your approval before a rule is registered.
77
- `seed` offers compatible starter rules with separate Codex decisions.
78
- `sprag route-scan savings --agent codex` records only Sprag-routed child runs
79
- and estimates their price difference when matching LiteLLM prices are available.
80
- It is not a billing measurement. Unknown prices remain `n/a`, and Codex document
81
- counts stay separate from Claude estimates. The historical dollar figures below
82
- are from Claude Code, not a Codex benchmark. See [Codex support and limits](https://sprag.io/docs/codex/), or run
83
- `sprag capabilities --agent codex` for the support matrix.
84
-
85
- | Workflow | Claude Code | Codex |
86
- |---|---|---|
87
- | Session instructions | `CLAUDE.md` and hooks | `AGENTS.md` and native hooks |
88
- | Live telemetry | Native statusline command | Companion panel or tmux layout |
89
- | Rule candidates | Claude transcripts and tier presets | Codex rollouts and compatible ratchet presets |
90
- | Model routing | Haiku/Sonnet tier rules | Explicit provider model IDs and scoped rules |
91
- | Routing ledger | Claude run estimates | Attributed child estimates with LiteLLM prices |
92
-
93
- Both integrations coexist. Commands without `--agent codex` retain Claude behavior.
67
+ Sprag never swaps the model you picked. Analysis reads your local session
68
+ logs only, with no extra model calls and no API key. `sprag uninstall` removes
69
+ everything it added and restores a statusline it merged with.
94
70
 
95
- ![statusline example: routing savings on row 1, document conversion savings on row 2, diagnostics on row 3](https://raw.githubusercontent.com/rootstudioyaml/sprag/main/docs/statusline.png)
71
+ Using Codex? See [Codex](#codex) below.
96
72
 
97
73
  ## How it works
98
74
 
@@ -119,7 +95,7 @@ holding.
119
95
  | Public benchmark accuracy | **59.1%** vs 57.9% best single model | `▰▰▰▰▰▰▰▰▰▰▰▰` | [benchmark](https://github.com/rootstudioyaml/sprag/blob/main/docs/BENCHMARK.md) |
120
96
  | Cost for the same queries | **$268** vs gpt-5 $388, gemini-2.5-pro $734 | `▰▰▰▰▰▱▱▱▱▱▱▱` | [benchmark](https://github.com/rootstudioyaml/sprag/blob/main/docs/BENCHMARK.md) |
121
97
  | Tokens for the same documents | **−95.9%**, 2,011,178 → 82,209 over 12 files | `▰▱▱▱▱▱▱▱▱▱▱▱` | [doc2md](https://github.com/rootstudioyaml/sprag/blob/main/docs/DOC2MD.md) |
122
- | Delegation savings, from the ledger | **$44.67** over 69 delegated runs | `▰▰▰▰▰▰▰▰▰▰▰▱` | `sprag route-scan savings` |
98
+ | Delegation savings, from the ledger | **$188.27** over 124 delegated runs | `▰▰▰▰▰▰▰▰▰▰▰▱` | `sprag route-scan savings` |
123
99
  | Cheap tier on the same prompts | **24% of the cost**, one answer worse out of eight | `▰▰▰▱▱▱▱▱▱▱▱▱` | [A/B run](#the-cheap-tier-on-the-same-prompts) |
124
100
 
125
101
  The first two rows answer the objection that comes up first: that moving work to
@@ -135,16 +111,22 @@ it again and you get the same figures. It validates the criteria, not sprag's ow
135
111
  routing: the dataset has no Claude models, so deepseek-v3, qwen3-235b and gpt-5
136
112
  stood in for cheap, mid and flagship.
137
113
 
138
- The last three rows are ledgers on this machine. They grow as it runs. These were
139
- read on 2026-09-18.
114
+ The last three rows are ledgers on this machine. They grow as it runs. The
115
+ routing figures were read on 2026-10-09, the document figures on 2026-09-18.
116
+ Model names are the ones each run actually used.
140
117
 
141
118
  ### Current, on one machine, last 30 days
142
119
 
143
120
  ```
144
- 🔀 Routing saved $44.67 total (last 7d $23.21 · last 30d $42.73)
145
-
146
- opus-5 → sonnet-5 59 runs $39.75 ▰▰▰▰▰▰▰▰▰▰▱▱
147
- opus-5 → haiku-4.5 10 runs $4.92 ▰▱▱▱▱▱▱▱▱▱▱▱
121
+ 🔀 Routing saved $188.27 total (last 7d $13.31 · last 30d $150.64)
122
+
123
+ fable-5 → sonnet-5 24 runs $81.19 ▰▰▰▰▰▰▰▰▰▰▰▰
124
+ opus-5 → sonnet-5 54 runs $68.85 ▰▰▰▰▰▰▰▰▰▰▱▱
125
+ opus-5 → sonnet-5.5 13 runs $24.58 ▰▰▰▰▱▱▱▱▱▱▱▱
126
+ opus-5 → haiku-4.5 25 runs $7.41 ▰▱▱▱▱▱▱▱▱▱▱▱
127
+ fable-5 → haiku-4.5 5 runs $3.22 ▰▱▱▱▱▱▱▱▱▱▱▱
128
+ fable-5 → sonnet-5.5 2 runs $2.51 ▰▱▱▱▱▱▱▱▱▱▱▱
129
+ fable-5 → haiku-5.5 1 run $0.51 ▰▱▱▱▱▱▱▱▱▱▱▱
148
130
  ```
149
131
 
150
132
  | Also measured | Over 30 days | |
@@ -179,8 +161,8 @@ arrives as fewer round-trips rather than as cheaper ones.
179
161
  ## The statusline in one line
180
162
 
181
163
  ```
182
- 🔀 Routing saved $44.67 | opus→sonnet 59× $39.75 · opus→haiku 10× $4.92
183
- 🚨 5H ▰▰▰▰▰▰▰▰▰▰▰▱ 94% 🔄 12:36 · 🅷 5/5 · 🤖 Opus 5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 📅 weekly ▰▰▰▰▰▱▱▱▱▱▱▱ 38% · 💵 Sep $42 · 📦 Ctx 47% of 1M
164
+ 🔀 Routing saved $188 | opus→sonnet 67× $93.4 · fable→sonnet 26× $83.7 · opus→haiku 25× $7.41 · fable→haiku 6× $3.73
165
+ 🚨 5H ▰▰▰▰▰▰▰▰▰▰▰▱ 94% 🔄 12:36 · 🅷 5/5 · 🤖 Opus 5.5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 📅 weekly ▰▰▰▰▰▱▱▱▱▱▱▱ 38% · 💵 Sep $42 · 📦 Ctx 47% of 1M
184
166
  ```
185
167
 
186
168
  When something is wrong the warning chip leads the line: `🚨 5H/7D NN%`, `⚠ Ctx 500k+`, `⚠ Cache miss`, `⚠ Input spike`, `⚠ Output heavy`, `⚠ Call surge`, `⚠ Rebuild churn`, `⚠ 5m TTL`. Paste the chip text into Claude and the Skill names the root-cause code and the fix. Every segment is explained in [the statusline reference](https://github.com/rootstudioyaml/sprag/blob/main/docs/STATUSLINE.md).
@@ -218,13 +200,59 @@ surfaces, and never send design or diagnosis.
218
200
  | Measured | Value | Sample | Window |
219
201
  |---|---|---|---|
220
202
  | Documents converted | 2,011,178 → 82,209 tokens | 12 files (xlsx, pptx, pdf) | to 2026-09-18 |
221
- | Delegated runs | $44.67 saved, $0.65 per run | 69 runs | to 2026-09-18 |
203
+ | Delegated runs | $188.27 saved, $1.52 per run | 124 runs | to 2026-10-09 |
222
204
  | Ratchet rules accumulated | 37 (30 global, 7 per-project) | 4 repositories | 2026-05-08 to 09-16 |
223
205
 
224
206
  The two counts grow differently. Ratchet rules come from mistakes a person made,
225
207
  so they keep coming. Delegation rules stop: the criteria recognize a fixed set of
226
208
  work types, and once each has a rule there is nothing left to promote.
227
209
 
210
+ ## Codex
211
+
212
+ Native hooks, an `AGENTS.md` harness, scoped ratchet rules, Korean lint,
213
+ document conversion, token reports, and a session-bound companion panel.
214
+ `npm i -g sprag-cli` sets Codex up too when it is installed, with the rule
215
+ presets registered globally and nothing asked (`sprag install --manual` asks at
216
+ each step). For a Codex-only install that leaves Claude Code alone:
217
+
218
+ ```bash
219
+ npm i -g sprag-cli --ignore-scripts
220
+ sprag install --agent codex
221
+ sprag --agent codex --days 7
222
+ sprag panel --agent codex # live companion in a separate terminal
223
+ sprag --statusline --agent codex --text
224
+ sprag doctor --agent codex # troubleshooting only
225
+ sprag route-scan --agent codex
226
+ sprag seed --agent codex
227
+ ```
228
+
229
+ Review and trust the new hooks in Codex's `/hooks` menu; Codex requires that
230
+ step. Commands still default to Claude Code; add `--agent codex` to pick Codex.
231
+ Codex also supports session briefings, warning history, handoffs, and opt-in
232
+ model routing and subagent guidance (`sprag delegate on --agent codex`). The native Codex footer
233
+ does not accept shell commands; Sprag provides a terminal statusline and panel.
234
+ Recurring simple turns become route candidates; repeated tool failures become
235
+ ratchet candidates. Both require your approval before a rule is registered.
236
+ `seed` offers compatible starter rules with separate Codex decisions.
237
+ `sprag route-scan savings --agent codex` records only Sprag-routed child runs
238
+ and estimates their price difference when matching LiteLLM prices are available.
239
+ It is not a billing measurement. Unknown prices remain `n/a`, and Codex document
240
+ counts stay separate from Claude estimates. The historical dollar figures below
241
+ are from Claude Code, not a Codex benchmark. See [Codex support and limits](https://sprag.io/docs/codex/), or run
242
+ `sprag capabilities --agent codex` for the support matrix.
243
+
244
+ | Workflow | Claude Code | Codex |
245
+ |---|---|---|
246
+ | Session instructions | `CLAUDE.md` and hooks | `AGENTS.md` and native hooks |
247
+ | Live telemetry | Native statusline command | Companion panel or tmux layout |
248
+ | Rule candidates | Claude transcripts and tier presets | Codex rollouts and compatible ratchet presets |
249
+ | Model routing | Haiku/Sonnet tier rules | Explicit provider model IDs and scoped rules |
250
+ | Routing ledger | Claude run estimates | Attributed child estimates with LiteLLM prices |
251
+
252
+ Both integrations coexist. Commands without `--agent codex` retain Claude behavior.
253
+
254
+ ![statusline example: routing savings on row 1, document conversion savings on row 2, diagnostics on row 3](https://raw.githubusercontent.com/rootstudioyaml/sprag/main/docs/statusline.png)
255
+
228
256
  ## Day-to-day commands
229
257
 
230
258
  | Command | What it does |
package/bin/cli.js CHANGED
@@ -590,10 +590,11 @@ async function main() {
590
590
  persistSnapshot({ caps, model });
591
591
  } catch (e) { debug('caps-cache:persist', e); }
592
592
  }
593
- console.log(formatNoSession(
593
+ const { withBaseStatusline } = await import('../src/statusline-base.js');
594
+ console.log(withBaseStatusline(formatNoSession(
594
595
  { caps, model, effort, windowLabel, version: PKG_VERSION, update: readUpdateChip() },
595
596
  { color: colorOk, mode: labelMode },
596
- ));
597
+ )));
597
598
  return;
598
599
  }
599
600
  // JSON mode stays JSON. A caller that asked for machine-readable output
@@ -901,6 +902,10 @@ async function main() {
901
902
  output = formatReport(data, { color: tableColor() });
902
903
  }
903
904
 
905
+ if (format === 'statusline') {
906
+ const { withBaseStatusline } = await import('../src/statusline-base.js');
907
+ output = withBaseStatusline(output);
908
+ }
904
909
  console.log(output);
905
910
  }
906
911
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.57.0",
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.",
3
+ "version": "3.58.1",
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": {
7
7
  "sprag": "bin/cli.js",
@@ -33,34 +33,22 @@
33
33
  "node": ">=18"
34
34
  },
35
35
  "keywords": [
36
- "sprag",
37
- "ratchet",
38
- "harness",
39
- "ai-agent",
40
- "claude",
41
36
  "claude-code",
42
- "codex",
43
- "openai-codex",
44
- "agents-md",
37
+ "claude",
45
38
  "anthropic",
46
- "model-routing",
47
- "llm-router",
48
- "delegation",
49
- "subagent",
50
- "haiku",
51
- "cost-savings",
39
+ "codex",
40
+ "token-usage",
52
41
  "token-saver",
53
42
  "reduce-tokens",
54
- "token-usage",
55
- "prompt-caching",
56
- "cache",
57
- "statusline",
58
- "cli",
59
- "1m-context",
60
- "claude-code-statusline",
43
+ "cost-savings",
61
44
  "claude-code-cost",
62
- "model-fitting",
63
- "agent-harness"
45
+ "claude-code-statusline",
46
+ "statusline",
47
+ "model-routing",
48
+ "subagent",
49
+ "prompt-caching",
50
+ "haiku",
51
+ "cli"
64
52
  ],
65
53
  "license": "Apache-2.0",
66
54
  "repository": {
package/src/agents.js CHANGED
@@ -45,22 +45,29 @@ export function agentExists(name, root = process.cwd()) {
45
45
 
46
46
  /**
47
47
  * Korean phrase for a delegation target, e.g.
48
- * agent present → `haiku-explore(model: haiku)`
48
+ * agent present → `haiku-explore(model 인자 생략)`
49
49
  * agent absent → `model: haiku`
50
+ *
51
+ * A named agent carries its model in its own definition, and the Agent tool's
52
+ * `model` argument overrides that definition. The old phrase
53
+ * `haiku-explore(model: haiku)` read as "pass model: haiku", and Claude Code
54
+ * resolves the bare `haiku` alias to an older Haiku than a definition pinned to
55
+ * a full id (claude-haiku-5-5), so following the rule undid the pin. The phrase
56
+ * now says to leave the argument out.
50
57
  */
51
58
  export function agentPhrase(name, { model, root } = {}) {
52
59
  const tier = model || AGENT_MODEL[name] || 'haiku';
53
- return agentExists(name, root) ? `${name}(model: ${tier})` : `model: ${tier}`;
60
+ return agentExists(name, root) ? `${name}(model 인자 생략)` : `model: ${tier}`;
54
61
  }
55
62
 
56
63
  /**
57
64
  * English phrase, including the article, e.g.
58
- * agent present → `the haiku-explore (model: haiku) subagent`
65
+ * agent present → `the haiku-explore subagent (omit the model argument)`
59
66
  * agent absent → `a model: haiku subagent`
60
67
  */
61
68
  export function agentPhraseEn(name, { model, root } = {}) {
62
69
  const tier = model || AGENT_MODEL[name] || 'haiku';
63
70
  return agentExists(name, root)
64
- ? `the ${name} (model: ${tier}) subagent`
71
+ ? `the ${name} subagent (omit the model argument)`
65
72
  : `a model: ${tier} subagent`;
66
73
  }
@@ -11,6 +11,11 @@
11
11
  * registered globally, and Codex is set up too when ~/.codex exists. `--manual`
12
12
  * restores the step-by-step questions (they still need a terminal attached).
13
13
  * `--yes` and `--no-input` are accepted as the explicit spelling of the default.
14
+ *
15
+ * One question survives the automatic mode: when a statusline that is not ours
16
+ * is already configured and a terminal is attached, the install asks whether to
17
+ * merge (theirs first, sprag below; default), replace, or keep. Without a
18
+ * terminal (postinstall, CI, pipes) it keeps theirs, as before.
14
19
  */
15
20
 
16
21
  import { debug } from '../debug.js';
@@ -21,7 +26,7 @@ class SkipStep extends Error {}
21
26
  export async function run({ hasFlag }) {
22
27
  const { installAll } = await import('../installer.js');
23
28
  const { userLanguage, languageDecided, setUserLanguage } = await import('../config.js');
24
- const { canPrompt, confirm } = await import('../prompt.js');
29
+ const { canPrompt, confirm, choose } = await import('../prompt.js');
25
30
  const force = hasFlag('--force');
26
31
  // Automatic unless asked otherwise. A question per step turned the install
27
32
  // into a dozen answers before any work, and every answer has a sensible
@@ -30,6 +35,9 @@ export async function run({ hasFlag }) {
30
35
  // attached, so postinstall, CI and pipes never wait on input.
31
36
  const manual = hasFlag('--manual') && !hasFlag('--yes') && !hasFlag('--no-input');
32
37
  const interactive = manual && canPrompt();
38
+ // The foreign-statusline question is asked in the automatic install too: it
39
+ // is the one decision with no safe default that preserves both parties.
40
+ const tty = canPrompt();
33
41
 
34
42
  // Output language, decided first because every line below it — and every
35
43
  // briefing the hooks inject from here on — is written in it. Until now it
@@ -82,25 +90,28 @@ export async function run({ hasFlag }) {
82
90
  let s = r.statusline;
83
91
  // A different statusline is already installed. Replacing it silently
84
92
  // would be rude and leaving it silently loses the tool's main surface,
85
- // so with a human attached the install asks. Default is "keep yours":
86
- // an accidental Enter must not clobber someone's custom statusline.
87
- if (s.action === 'skipped' && s.conflict && interactive) {
93
+ // so with a human attached the install asks. Default is "merge": their
94
+ // output stays and ours goes below it, so nobody loses anything.
95
+ if (s.action === 'skipped' && s.conflict && tty) {
96
+ const { installStatusline } = await import('../installer.js');
88
97
  console.log('');
89
98
  console.log(lang === 'ko'
90
99
  ? ` statusline: 기존 statusline이 이미 설정되어 있습니다: ${s.existingCommand}`
91
100
  : ` statusline: an existing statusline is already configured: ${s.existingCommand}`);
92
- console.log(lang === 'ko'
93
- ? ' 교체하면 토큰·캐시·상한 경고가 statusline에 표시됩니다. 기존 설정은 사라집니다.'
94
- : ' replacing it shows token/cache/cap warnings in the statusline; the current one is removed.');
95
- const replace = await confirm(lang === 'ko'
96
- ? ' sprag statusline으로 교체할까요?'
97
- : ' Replace it with the sprag statusline?', { defaultValue: false });
98
- if (replace) {
99
- const { installStatusline } = await import('../installer.js');
101
+ const pick = await choose(lang === 'ko'
102
+ ? ' statusline을 어떻게 표시할까요?'
103
+ : ' How should the statusline be shown?', [
104
+ { key: 'merge', label: lang === 'ko' ? '함께 표시: 기존 출력 아래에 sprag 줄을 붙입니다' : 'merge: your output first, sprag lines below' },
105
+ { key: 'replace', label: lang === 'ko' ? '교체: sprag statusline만 표시하고 기존 설정은 지웁니다' : 'replace: sprag statusline only, the old one is removed' },
106
+ { key: 'keep', label: lang === 'ko' ? '유지: 기존 statusline만 두고 sprag은 표시하지 않습니다' : 'keep: leave yours, no sprag statusline' },
107
+ ], { defaultIndex: 0, defaultHint: lang === 'ko' ? '(기본값)' : '(default)' });
108
+ if (pick === 'merge') {
109
+ s = installStatusline({ merge: true });
110
+ } else if (pick === 'replace') {
100
111
  s = installStatusline({ force: true });
101
112
  } else {
102
113
  s = { ...s, reason: lang === 'ko'
103
- ? '기존 statusline을 유지했습니다. 교체하려면 `sprag install --force`'
114
+ ? '기존 statusline을 유지했습니다. 바꾸려면 `sprag install --force`'
104
115
  : 'kept your statusline — replace later with `sprag install --force`' };
105
116
  }
106
117
  }
package/src/cost.js CHANGED
@@ -1,13 +1,29 @@
1
1
  /**
2
2
  * Cost impact estimation based on Anthropic pricing.
3
- * Source: https://docs.claude.com/en/docs/about-claude/pricing
4
- * Prices per million tokens (USD). Updated 2026-04 for Opus 4.7 release.
3
+ * Source: https://platform.claude.com/docs/en/about-claude/pricing
4
+ * Prices per million tokens (USD). Updated 2026-10 for Fable 5.1, Opus 5.5,
5
+ * Sonnet 5 / 5.5 and Haiku 5.5 (checked against the pricing page 2026-10-09).
5
6
  *
6
7
  * Note: Opus 4.5/4.6/4.7 use reduced pricing ($5/$25) vs. older Opus 4/4.1 ($15/$75).
7
8
  * Cache writes are now tracked separately for 5m and 1h TTLs, each with their own rate.
8
9
  */
9
10
 
11
+ // Haiku 5.5 charges a request whose prompt (input + cache writes + cache
12
+ // reads) exceeds this many tokens at the higher 'claude-haiku-5-5-long' rates.
13
+ // Each request is priced on its own, so callers accumulate such requests into
14
+ // `totals.long` (see parser.js) rather than relying on session-wide sums.
15
+ export const LONG_PROMPT_THRESHOLD = 100_000;
16
+
10
17
  const PRICING = {
18
+ // Fable 5.1 / Mythos 5.1 — same prices as Fable 5 except cache reads, which
19
+ // drop to $0.25.
20
+ 'claude-fable-5-1': {
21
+ input: 10.0,
22
+ cacheWrite5m: 12.5,
23
+ cacheWrite1h: 20.0,
24
+ cacheRead: 0.25,
25
+ output: 50.0,
26
+ },
11
27
  // Fable 5 / Mythos 5 — premium tier above Opus ($10/$50). Cache write
12
28
  // rates follow the standard multipliers (1.25x input for 5m, 2x for 1h),
13
29
  // cache read is 0.1x input.
@@ -18,6 +34,14 @@ const PRICING = {
18
34
  cacheRead: 1.0,
19
35
  output: 50.0,
20
36
  },
37
+ // Opus 5.5 — cheaper than the Opus 4.5-5 tier below.
38
+ 'claude-opus-5-5': {
39
+ input: 4.0,
40
+ cacheWrite5m: 5.0,
41
+ cacheWrite1h: 8.0,
42
+ cacheRead: 0.2,
43
+ output: 20.0,
44
+ },
21
45
  // Opus 4.5+ (new pricing tier — includes 4.5, 4.6, 4.7, 4.8, and future)
22
46
  'claude-opus-new': {
23
47
  input: 5.0,
@@ -34,6 +58,23 @@ const PRICING = {
34
58
  cacheRead: 1.5,
35
59
  output: 75.0,
36
60
  },
61
+ // Sonnet 5 — standard price is $2/$10. The rise to $3/$15 planned for
62
+ // 2026-09-01 was cancelled, so this is not an introductory rate.
63
+ 'claude-sonnet-5': {
64
+ input: 2.0,
65
+ cacheWrite5m: 2.5,
66
+ cacheWrite1h: 4.0,
67
+ cacheRead: 0.2,
68
+ output: 10.0,
69
+ },
70
+ // Sonnet 5.5 — same as Sonnet 5 except cache reads at $0.10.
71
+ 'claude-sonnet-5-5': {
72
+ input: 2.0,
73
+ cacheWrite5m: 2.5,
74
+ cacheWrite1h: 4.0,
75
+ cacheRead: 0.1,
76
+ output: 10.0,
77
+ },
37
78
  // Sonnet 4 / 4.5 / 4.6 / 3.7
38
79
  'claude-sonnet': {
39
80
  input: 3.0,
@@ -42,6 +83,23 @@ const PRICING = {
42
83
  cacheRead: 0.3,
43
84
  output: 15.0,
44
85
  },
86
+ // Haiku 5.5, prompt <= LONG_PROMPT_THRESHOLD tokens.
87
+ 'claude-haiku-5-5': {
88
+ input: 0.1,
89
+ cacheWrite5m: 0.125,
90
+ cacheWrite1h: 0.2,
91
+ cacheRead: 0.01,
92
+ output: 0.5,
93
+ },
94
+ // Haiku 5.5, prompt > LONG_PROMPT_THRESHOLD tokens. Not returned by
95
+ // detectPricingTier: estimateCost applies it to `totals.long` only.
96
+ 'claude-haiku-5-5-long': {
97
+ input: 0.5,
98
+ cacheWrite5m: 0.625,
99
+ cacheWrite1h: 1.0,
100
+ cacheRead: 0.05,
101
+ output: 2.5,
102
+ },
45
103
  // Haiku 4.5
46
104
  'claude-haiku-4-5': {
47
105
  input: 1.0,
@@ -72,16 +130,23 @@ const PRICING = {
72
130
  * Detect pricing tier from Claude model identifier.
73
131
  * Examples: 'claude-opus-4-7', 'claude-sonnet-4-5', 'claude-haiku-4-5'.
74
132
  */
75
- function detectPricingTier(model) {
133
+ export function detectPricingTier(model) {
76
134
  if (!model) return 'claude-sonnet';
77
135
  const m = model.toLowerCase();
78
136
 
79
137
  // Fable 5 / Mythos 5 — must be checked before the generic fallback:
80
138
  // without this, 'claude-fable-5' fell through to the Sonnet tier and
81
139
  // under-estimated costs ~3x ($3/$15 vs the real $10/$50).
82
- if (m.includes('fable') || m.includes('mythos')) return 'claude-fable-5';
140
+ if (m.includes('fable') || m.includes('mythos')) {
141
+ // `(?!\d)` keeps a date suffix (`fable-5-20261001`) from reading as 5.1.
142
+ if (/(fable|mythos)[-_.]?5[-_.]1(?!\d)/.test(m)) return 'claude-fable-5-1';
143
+ return 'claude-fable-5';
144
+ }
83
145
 
84
146
  if (m.includes('opus')) {
147
+ // 5.5 first: `opus-5-5` would otherwise match the generic 5+ pattern, and
148
+ // `(?!\d)` keeps `opus-5-20260101` (Opus 5 with a date) out of 5.5.
149
+ if (/opus[-_.]?5[-_.]5(?!\d)/.test(m)) return 'claude-opus-5-5';
85
150
  // Opus 4.5, 4.6, 4.7, and future 5+ use the new reduced pricing.
86
151
  if (/opus[-_.]?4[-_.]?[5-9]\b/.test(m)) return 'claude-opus-new';
87
152
  if (/opus[-_.]?[5-9]/.test(m)) return 'claude-opus-new';
@@ -90,6 +155,7 @@ function detectPricingTier(model) {
90
155
  }
91
156
 
92
157
  if (m.includes('haiku')) {
158
+ if (/haiku[-_.]?5[-_.]5(?!\d)/.test(m)) return 'claude-haiku-5-5';
93
159
  if (/haiku[-_.]?4[-_.]?5/.test(m)) return 'claude-haiku-4-5';
94
160
  // The 3.x ids put the version first (`claude-3-5-haiku-20241022`,
95
161
  // `claude-3-haiku-20240307`), so both orders are matched. With only the
@@ -99,6 +165,13 @@ function detectPricingTier(model) {
99
165
  return 'claude-haiku-4-5';
100
166
  }
101
167
 
168
+ if (m.includes('sonnet')) {
169
+ if (/sonnet[-_.]?5[-_.]5(?!\d)/.test(m)) return 'claude-sonnet-5-5';
170
+ // Sonnet 5 incl. date-suffixed ids; the 3.x/4.x ids never put a 5 right
171
+ // after the family name (`claude-3-5-sonnet-2024…` has it before).
172
+ if (/sonnet[-_.]?5(?!\d)/.test(m)) return 'claude-sonnet-5';
173
+ }
174
+
102
175
  // Sonnet (default fallback): 3.7, 4, 4.5, 4.6 all share the same pricing.
103
176
  return 'claude-sonnet';
104
177
  }
@@ -112,9 +185,14 @@ function detectPricingTier(model) {
112
185
  */
113
186
  const TIER_RANK = {
114
187
  'claude-fable-5': 3,
188
+ 'claude-fable-5-1': 3,
189
+ 'claude-opus-5-5': 2,
115
190
  'claude-opus-legacy': 2,
116
191
  'claude-opus-new': 2,
117
192
  'claude-sonnet': 1,
193
+ 'claude-sonnet-5': 1,
194
+ 'claude-sonnet-5-5': 1,
195
+ 'claude-haiku-5-5': 0,
118
196
  'claude-haiku-4-5': 0,
119
197
  'claude-haiku-3-5': 0,
120
198
  'claude-haiku-3': 0,
@@ -203,8 +281,54 @@ function tokensToMillions(n) {
203
281
  export function estimateCost(totals, model, { price } = {}) {
204
282
  if (price) return estimateCostAtPrice(totals, price);
205
283
  const tier = detectPricingTier(model);
206
- const p = PRICING[tier];
207
284
 
285
+ // Haiku 5.5 prices each request by its own prompt length, so the long-prompt
286
+ // part (`totals.long`, same shape) is priced at the long rates and the rest
287
+ // at the short rates. Every other tier ignores `long`.
288
+ let c;
289
+ if (tier === 'claude-haiku-5-5' && totals.long) {
290
+ const short = subtractTotals(totals, totals.long);
291
+ const a = priceTotals(short, PRICING[tier]);
292
+ const b = priceTotals(totals.long, PRICING['claude-haiku-5-5-long']);
293
+ c = {
294
+ actual: a.actual + b.actual,
295
+ noCacheCost: a.noCacheCost + b.noCacheCost,
296
+ scenario5mCost: a.scenario5mCost + b.scenario5mCost,
297
+ write1h: a.write1h + b.write1h,
298
+ };
299
+ } else {
300
+ c = priceTotals(totals, PRICING[tier]);
301
+ }
302
+ const { actual, noCacheCost, scenario5mCost, write1h } = c;
303
+
304
+ return {
305
+ tier,
306
+ actual: round(actual),
307
+ noCacheCost: round(noCacheCost),
308
+ savings: round(noCacheCost - actual),
309
+ savingsRate: noCacheCost > 0 ? (noCacheCost - actual) / noCacheCost : 0,
310
+ scenario5mCost: round(scenario5mCost),
311
+ extraCostIf5m: round(scenario5mCost - actual),
312
+ // The row asks a counterfactual: what would dropping to 5m-only cost you?
313
+ // With no 1h writes there is nothing to lose, and the arithmetically
314
+ // honest `+$0` it printed read as "5m-only is free" — the opposite of the
315
+ // truth for a gateway user already confined to 5m. The display layer has
316
+ // to change the question rather than the number.
317
+ extraCostIf5mApplicable: write1h > 0,
318
+ };
319
+ }
320
+
321
+ const TOTAL_FIELDS = ['input', 'cacheCreation', 'cacheRead', 'ephemeral5m', 'ephemeral1h', 'output'];
322
+
323
+ /** totals - part, field by field (clamped at 0), dropping the `long` key. */
324
+ function subtractTotals(totals, part) {
325
+ const out = {};
326
+ for (const f of TOTAL_FIELDS) out[f] = Math.max(0, (totals[f] ?? 0) - (part[f] ?? 0));
327
+ return out;
328
+ }
329
+
330
+ /** Unrounded cost figures for one set of totals at one rate card. */
331
+ function priceTotals(totals, p) {
208
332
  // Prefer explicit 5m/1h split when available; fall back to cacheCreation at 5m rate
209
333
  // (conservative — 5m is cheaper than 1h).
210
334
  const write5m = totals.ephemeral5m ?? 0;
@@ -235,21 +359,7 @@ export function estimateCost(totals, model, { price } = {}) {
235
359
  tokensToMillions(Math.max(0, totals.cacheRead - extra5mCreation)) * p.cacheRead +
236
360
  tokensToMillions(totals.output) * p.output;
237
361
 
238
- return {
239
- tier,
240
- actual: round(actual),
241
- noCacheCost: round(noCacheCost),
242
- savings: round(noCacheCost - actual),
243
- savingsRate: noCacheCost > 0 ? (noCacheCost - actual) / noCacheCost : 0,
244
- scenario5mCost: round(scenario5mCost),
245
- extraCostIf5m: round(scenario5mCost - actual),
246
- // The row asks a counterfactual: what would dropping to 5m-only cost you?
247
- // With no 1h writes there is nothing to lose, and the arithmetically
248
- // honest `+$0` it printed read as "5m-only is free" — the opposite of the
249
- // truth for a gateway user already confined to 5m. The display layer has
250
- // to change the question rather than the number.
251
- extraCostIf5mApplicable: write1h > 0,
252
- };
362
+ return { actual, noCacheCost, scenario5mCost, write1h };
253
363
  }
254
364
 
255
365
  /**
package/src/installer.js CHANGED
@@ -357,8 +357,10 @@ export function removeLegacyCommand() {
357
357
  // - No statusLine yet: insert ours with refreshInterval:STATUSLINE_REFRESH_INTERVAL.
358
358
  // - statusLine already points at sprag: ensure that refreshInterval
359
359
  // (this is the bit that makes the TTL countdown tick every second while idle).
360
- // - statusLine points at a different command: leave it alone unless --force.
361
- export function installStatusline({ force = false } = {}) {
360
+ // - statusLine points at a different command: leave it alone unless --force,
361
+ // or `merge`, which keeps their command running underneath ours (the original
362
+ // object is saved as config `statuslineBase` so uninstall can restore it).
363
+ export function installStatusline({ force = false, merge = false } = {}) {
362
364
  const dir = claudeUserDir();
363
365
  const file = join(dir, 'settings.json');
364
366
  mkdirSync(dir, { recursive: true });
@@ -395,12 +397,13 @@ export function installStatusline({ force = false } = {}) {
395
397
  return { path: file, action: 'updated', reason: `set refreshInterval=${STATUSLINE_REFRESH_INTERVAL}` };
396
398
  }
397
399
 
398
- if (!force) {
400
+ if (!force && !merge) {
399
401
  // `conflict` lets the interactive install distinguish "someone else's
400
402
  // statusline is here" (worth asking about) from other skip reasons
401
403
  // (unreadable JSON), which a prompt cannot fix.
402
404
  return { path: file, action: 'skipped', conflict: true, existingCommand: cur.command, reason: `existing statusLine command (${cur.command}) — re-run with --force to overwrite` };
403
405
  }
406
+ const base = cur;
404
407
  settings.statusLine = {
405
408
  type: 'command',
406
409
  command: STATUSLINE_COMMAND,
@@ -410,6 +413,17 @@ export function installStatusline({ force = false } = {}) {
410
413
  const writeProblem = writeSettings(file, settings);
411
414
  if (writeProblem) return { path: file, action: 'skipped', reason: writeProblem };
412
415
  }
416
+ // Config is touched only after settings were written, so a failed write
417
+ // never leaves a saved base for a statusline we did not take over.
418
+ const cfg = loadConfig();
419
+ if (merge) {
420
+ cfg.statuslineBase = base;
421
+ saveConfig(cfg);
422
+ return { path: file, action: 'updated', reason: 'merged with previous statusLine', merged: true };
423
+ }
424
+ // Replace means the old one is gone for good; a stale base would resurrect it
425
+ // (or keep running it) later.
426
+ if (cfg.statuslineBase) { delete cfg.statuslineBase; saveConfig(cfg); }
413
427
  return { path: file, action: 'updated', reason: 'replaced previous statusLine' };
414
428
  }
415
429
 
@@ -910,8 +924,18 @@ export function uninstallAll({ purge = false } = {}) {
910
924
  // where they put it.
911
925
  const cur = settings.statusLine;
912
926
  if (cur && isOurCommand(cur.command)) {
913
- delete settings.statusLine;
914
- result.removed.push('statusLine');
927
+ // A merged install saved the user's original statusline; give it back
928
+ // instead of leaving them with none.
929
+ const cfg = loadConfig();
930
+ if (cfg.statuslineBase) {
931
+ settings.statusLine = cfg.statuslineBase;
932
+ delete cfg.statuslineBase;
933
+ saveConfig(cfg);
934
+ result.removed.push('statusLine (restored previous)');
935
+ } else {
936
+ delete settings.statusLine;
937
+ result.removed.push('statusLine');
938
+ }
915
939
  } else if (cur) {
916
940
  result.kept.push(`statusLine (${cur.command || 'unrecognised'})`);
917
941
  }
package/src/parser.js CHANGED
@@ -5,6 +5,7 @@ import { join, isAbsolute, basename } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { loadCache, getCached, putCached, saveCache, getCachedSince, putCachedSince } from './session-cache.js';
7
7
  import { resolveModelAlias, isGatewayModelId } from './model-alias.js';
8
+ import { LONG_PROMPT_THRESHOLD } from './cost.js';
8
9
 
9
10
  const CLAUDE_DIR = join(homedir(), '.claude', 'projects');
10
11
 
@@ -135,6 +136,20 @@ function addRequest(acc, r) {
135
136
  acc.ephemeral5m += r.ephemeral5mTokens;
136
137
  acc.ephemeral1h += r.ephemeral1hTokens;
137
138
  acc.output += r.outputTokens;
139
+ // Haiku 5.5 bills a request whose prompt exceeds LONG_PROMPT_THRESHOLD at
140
+ // higher rates, per request. The sums above lose that, so such requests are
141
+ // also accumulated into `long` (same shape). The key only appears once a long
142
+ // request exists, which keeps the usual totals shape unchanged.
143
+ if (r.inputTokens + r.cacheCreationTokens + r.cacheReadTokens > LONG_PROMPT_THRESHOLD) {
144
+ if (!acc.long) acc.long = emptyTotals();
145
+ const l = acc.long;
146
+ l.input += r.inputTokens;
147
+ l.cacheCreation += r.cacheCreationTokens;
148
+ l.cacheRead += r.cacheReadTokens;
149
+ l.ephemeral5m += r.ephemeral5mTokens;
150
+ l.ephemeral1h += r.ephemeral1hTokens;
151
+ l.output += r.outputTokens;
152
+ }
138
153
  }
139
154
 
140
155
  /**
package/src/prompt.js CHANGED
@@ -50,3 +50,31 @@ export function confirm(question, { defaultValue = true, input = process.stdin,
50
50
  rl.on('close', () => { if (!answered) resolve(defaultValue); });
51
51
  });
52
52
  }
53
+
54
+ /**
55
+ * Ask a multiple-choice question and resolve to the chosen `key`.
56
+ *
57
+ * Choices are printed as a numbered list; the answer may be the 1-based number
58
+ * or the key itself. Like confirm(), anything unrecognized (empty line, typo,
59
+ * closed stream) resolves to the default, so an unattended prompt ends with
60
+ * the same choice the non-interactive path would have made.
61
+ */
62
+ export function choose(question, choices, { defaultIndex = 0, defaultHint = '(default)', input = process.stdin, output = process.stdout } = {}) {
63
+ const fallback = choices[defaultIndex]?.key ?? choices[0].key;
64
+ const lines = choices.map((c, i) => ` ${i + 1}) ${c.label}${i === defaultIndex ? ` ${defaultHint}` : ''}`);
65
+ return new Promise((resolve) => {
66
+ const rl = createInterface({ input, output });
67
+ let answered = false;
68
+ output.write(`${question}\n${lines.join('\n')}\n`);
69
+ rl.question(` [1-${choices.length}] `, (answer) => {
70
+ answered = true;
71
+ rl.close();
72
+ const a = String(answer).trim().toLowerCase();
73
+ const n = Number(a);
74
+ if (Number.isInteger(n) && n >= 1 && n <= choices.length) return resolve(choices[n - 1].key);
75
+ const byKey = choices.find((c) => String(c.key).toLowerCase() === a);
76
+ resolve(byKey ? byKey.key : fallback);
77
+ });
78
+ rl.on('close', () => { if (!answered) resolve(fallback); });
79
+ });
80
+ }
package/src/route-scan.js CHANGED
@@ -366,6 +366,7 @@ export function runSaving(run, mainModel) {
366
366
  ephemeral5m: run.ephemeral5m,
367
367
  ephemeral1h: run.ephemeral1h,
368
368
  output: run.out,
369
+ ...(run.long ? { long: run.long } : {}),
369
370
  };
370
371
  const actual = sessionCost(totals, run.model);
371
372
  const counterfactual = sessionCost(totals, mainModel);
@@ -35,7 +35,11 @@ const CACHE_PATH = join(userDataDir(), 'session-cache.json');
35
35
  // zero-token `<synthetic>` stubs no longer count as requests. A v3 entry has
36
36
  // neither, so a mixed-model session cached before the bump would go on being
37
37
  // priced at one rate until its transcript changed.
38
- const CACHE_VERSION = 4;
38
+ // 5: totals (and each modelTotals entry) may carry `long`, the part of the
39
+ // tokens from requests whose prompt exceeds 100k, which Haiku 5.5 prices at a
40
+ // higher rate. A v4 entry lacks it, so a Haiku 5.5 session cached before the
41
+ // bump would keep being priced at the short rate until its transcript changed.
42
+ const CACHE_VERSION = 5;
39
43
  // Entries for transcripts this old are pruned on write. Keeps the file
40
44
  // bounded without an existence check per entry (which would cost the syscalls
41
45
  // the cache exists to avoid).
package/src/stats.js CHANGED
@@ -10,6 +10,15 @@ function dateKey(date) {
10
10
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
11
11
  }
12
12
 
13
+ // Sum a session's optional `long` sub-totals (requests whose prompt exceeds
14
+ // 100k tokens; see parser.js) into an aggregate, creating it on first use.
15
+ function addLong(acc, totals) {
16
+ const l = totals.long;
17
+ if (!l) return;
18
+ const a = (acc.long ||= { input: 0, cacheCreation: 0, cacheRead: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0 });
19
+ for (const k of Object.keys(a)) a[k] += l[k] || 0;
20
+ }
21
+
13
22
  function hitRate(read, creation, input) {
14
23
  const total = read + creation + input;
15
24
  return total > 0 ? read / total : 0;
@@ -43,6 +52,7 @@ export function dailyTrend(sessions) {
43
52
  d.cacheRead += s.totals.cacheRead;
44
53
  d.ephemeral5m += s.totals.ephemeral5m;
45
54
  d.ephemeral1h += s.totals.ephemeral1h;
55
+ addLong(d, s.totals);
46
56
  d.output += s.totals.output;
47
57
  d.apiCalls += s.requestCount;
48
58
  d.sessions += 1;
@@ -377,6 +387,7 @@ export function summary(sessions) {
377
387
  acc.cacheRead += s.totals.cacheRead;
378
388
  acc.ephemeral5m += s.totals.ephemeral5m;
379
389
  acc.ephemeral1h += s.totals.ephemeral1h;
390
+ addLong(acc, s.totals);
380
391
  acc.output += s.totals.output;
381
392
  acc.apiCalls += s.requestCount;
382
393
  acc.sessions += 1;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * statusline-base — run the user's original statusline underneath ours.
3
+ *
4
+ * `sprag install` can merge with a statusline that was already configured: the
5
+ * original settings.json entry is saved as config `statuslineBase`, and each
6
+ * refresh prints that command's output first, sprag's lines after it.
7
+ * The statusline runs every few seconds, so nothing here may throw or stall.
8
+ */
9
+
10
+ import { spawnSync } from 'node:child_process';
11
+ import { loadConfig } from './config.js';
12
+ import { readStdinRaw } from './stdin-payload.js';
13
+
14
+ // A light name check instead of importing installer.js (heavy for a hot path).
15
+ // If the saved base were ourselves, running it would recurse without end.
16
+ const OURS = /(^|[\s/\\])(sprag|claude-token-saver)(-cli)?(\.cmd|\.exe|\.ps1)?(\s|$)/i;
17
+
18
+ // A wrapper script saved as the base may itself call `sprag --statusline`;
19
+ // that inner run must not start the base again, so the child carries a marker.
20
+ const NESTED = 'SPRAG_STATUSLINE_BASE';
21
+
22
+ export function withBaseStatusline(output) {
23
+ if (process.env[NESTED] === '1') return output;
24
+ try {
25
+ const cmd = loadConfig().statuslineBase?.command;
26
+ if (typeof cmd !== 'string' || !cmd.trim() || OURS.test(cmd)) return output;
27
+ const opts = { input: readStdinRaw(), encoding: 'utf8', timeout: 1500, env: { ...process.env, [NESTED]: '1' } };
28
+ const r = process.platform === 'win32'
29
+ ? spawnSync(cmd, { ...opts, shell: true })
30
+ : spawnSync('/bin/sh', ['-c', cmd], opts);
31
+ if (r.error || r.status !== 0) return output;
32
+ const base = String(r.stdout || '').replace(/[\r\n]+$/, '');
33
+ return base ? `${base}\n${output}` : output;
34
+ } catch {
35
+ return output;
36
+ }
37
+ }
@@ -25,10 +25,27 @@ import { readFileSync } from 'node:fs';
25
25
  * Anthropic adds (e.g. a Sonnet-only weekly bucket) flows through without code
26
26
  * changes — known keys get curated labels, unknowns get derived ones.
27
27
  */
28
+ // fd 0 can only be drained once. The statusline needs the payload twice: once
29
+ // to parse it, and once to forward it verbatim to a wrapped base statusline
30
+ // command, so the raw text is read on first use and cached for the process.
31
+ let rawCache = null;
32
+
33
+ /** The raw stdin text, read once. '' when stdin is a TTY or empty. */
34
+ export function readStdinRaw() {
35
+ if (rawCache !== null) return rawCache;
36
+ if (process.stdin.isTTY) return (rawCache = '');
37
+ try {
38
+ rawCache = readFileSync(0, 'utf8') || '';
39
+ } catch {
40
+ rawCache = '';
41
+ }
42
+ return rawCache;
43
+ }
44
+
28
45
  export function readStdinJson() {
29
46
  if (process.stdin.isTTY) return null;
30
47
  try {
31
- const raw = readFileSync(0, 'utf8');
48
+ const raw = readStdinRaw();
32
49
  if (!raw || !raw.trim()) return null;
33
50
  return JSON.parse(raw);
34
51
  } catch {
@@ -30,6 +30,7 @@
30
30
  import { readdir, readFile, stat } from 'node:fs/promises';
31
31
  import { join, dirname, basename } from 'node:path';
32
32
  import { collectSessionRecords, normalizeModelId } from './session-records.js';
33
+ import { LONG_PROMPT_THRESHOLD } from './cost.js';
33
34
 
34
35
  /** Directory holding a session's subagent transcripts (may not exist). */
35
36
  export function subagentDirFor(sessionPath) {
@@ -98,6 +99,17 @@ export async function collectSubagentRun(jsonlPath) {
98
99
  run.cacheRead += r.cache_read_tokens || 0;
99
100
  run.ephemeral5m += r.ephemeral5m || 0;
100
101
  run.ephemeral1h += r.ephemeral1h || 0;
102
+ // Haiku 5.5 long-prompt split (see cost.js LONG_PROMPT_THRESHOLD); only
103
+ // present once a request this large exists.
104
+ if ((r.prompt_tokens || 0) > LONG_PROMPT_THRESHOLD) {
105
+ const l = (run.long ||= { input: 0, cacheCreation: 0, cacheRead: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0 });
106
+ l.input += r.input_tokens || 0;
107
+ l.cacheCreation += r.cache_creation_tokens || 0;
108
+ l.cacheRead += r.cache_read_tokens || 0;
109
+ l.ephemeral5m += r.ephemeral5m || 0;
110
+ l.ephemeral1h += r.ephemeral1h || 0;
111
+ l.output += r.completion_tokens || 0;
112
+ }
101
113
  run.toolErrors += r.toolErrors || 0;
102
114
  const model = normalizeModelId(r.model);
103
115
  byModelOut.set(model, (byModelOut.get(model) || 0) + (r.completion_tokens || 0));