claude-token-saver 3.24.0 โ†’ 3.25.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.en.md CHANGED
@@ -130,6 +130,8 @@ With an empty ledger (no measured delegation yet) row 1 is not drawn and the lay
130
130
  | `โœฆ current` / `๐Ÿ“… weekly` | 5-hour / 7-day rate-limit window usage + reset time |
131
131
  | `๐Ÿ“ฆ` | Context usage (e.g. `Ctx 68% of 1M`) โ€” colored by fill. Current models default to 1M with no premium, but token volume itself drives per-turn cost and 5H/7D burn |
132
132
  | `๐Ÿ’ฐ` | Cumulative savings from prompt caching โ€” a **different** number from row 1's `๐Ÿ”€` (model routing) |
133
+ | `v3.24.0` | The version you are running. Gray, at the tail, when it is the latest one |
134
+ | `โฌ† v3.24.0 โ†’ 3.25.0` | A newer release exists. Actionable, so it moves to the front of the line ([Update notifications](#-update-notifications)) |
133
135
 
134
136
  When something is wrong, a **warning chip leads the line**:
135
137
 
@@ -161,6 +163,9 @@ Run these in your shell (inside Claude Code, the `/claude-token-saver` Skill is
161
163
  | `claude-token-saver korean on\|off\|status` | Inject Korean writing guidance at session start and install the write-time check (below) |
162
164
  | `claude-token-saver korean lint block\|warn\|off` | How the write-time check handles findings |
163
165
  | `claude-token-saver korean lint scope all\|prose` | Check every text file, or documents only |
166
+ | `claude-token-saver --version` | Print the installed version |
167
+ | `claude-token-saver update-check` | Is a newer version out? (`--refresh` to ask now, `--dismiss` to mute this version's offer) |
168
+ | `claude-token-saver upgrade` | Install the latest release with the package manager that installed this copy (`--print` shows the command only) |
164
169
  | `claude-token-saver install` | Manually register Skill + statusline |
165
170
 
166
171
  Switch output language with `mode ko` / `mode en` (English default; statusline chips stay symbolic).
@@ -184,6 +189,16 @@ Switch output language with `mode ko` / `mode en` (English default; statusline c
184
189
  | `--install-hook` / `--uninstall-hook` | Manage the PostToolUse hook | โ€“ |
185
190
  </details>
186
191
 
192
+ ## โฌ† Update notifications
193
+
194
+ A statusline cannot open a dialog, and it re-renders every ~300ms, so it can never touch the network while drawing. The notification is therefore split in two:
195
+
196
+ - **The statusline tells you.** Up to date: a quiet gray `v3.24.0` at the tail. Newer release out: `โฌ† v3.24.0 โ†’ 3.25.0` in yellow, moved to the front. Never red โ€” nothing is broken.
197
+ - **Session start asks you.** On a new session or `/clear`, the SessionStart hook injects one line telling the model a newer version exists and to ask before installing anything. Only after you agree does it run `claude-token-saver upgrade`.
198
+ - **Declining sticks.** `claude-token-saver update-check --dismiss` mutes the offer for that version; the next release asks again. The statusline chip stays โ€” you declined the question, not the fact.
199
+
200
+ The registry lookup runs at most once every 24h in a detached background process and only ever writes a cache file (`update-check.json`) โ€” the same shape npm's `update-notifier` uses. A failed check still stamps its timestamp, so an offline machine backs off instead of retrying on every render. Turn checks off entirely with `CTS_NO_UPDATE_CHECK=1` or `NO_UPDATE_NOTIFIER`.
201
+
187
202
  ## ๐Ÿ…ท Harness mode
188
203
 
189
204
  Bootstrap five engineering principles (Ratchet ยท Evidence ยท PEV ยท Structured Task ยท Default Safe Path) into `CLAUDE.md` with one command; the statusline scores it as `๐Ÿ…ท 5/5`. When the same error keeps recurring, a `๐Ÿ…ทโš  ratchet?` nudge appears so you can promote it to a rule.
@@ -439,6 +454,11 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
439
454
 
440
455
  ## Release notes
441
456
 
457
+ ### v3.25.0 (2026-09-04)
458
+ - **The statusline now shows which version is running** โ€” until now the version lived only in the table report's footer, so "which version am I on" meant running a full report. A `--version` flag was added alongside it.
459
+ - **A new release asks at session start** โ€” a statusline cannot open a dialog, so telling and asking are split. The statusline only reports (`โฌ† v3.24.0 โ†’ 3.25.0`); the SessionStart hook injects a line telling the model to ask the user whether to upgrade. On yes it runs `claude-token-saver upgrade`, which uses the package manager that installed this copy; on no, `update-check --dismiss` mutes that version until a newer one ships.
460
+ - **The check never blocks a render** โ€” the registry lookup runs at most once every 24h in a detached background process, and the render path only reads the cache file. A failed check still stamps its timestamp, so an offline machine backs off instead of retrying every render. Disable with `CTS_NO_UPDATE_CHECK=1` or `NO_UPDATE_NOTIFIER`.
461
+
442
462
  ### v3.21.0 (2026-08-22)
443
463
  - **The install shows what it is about to enable, then asks** โ€” the harness 5 principles and the Korean writing guidance used to be switched on by the installer, leaving the user with the result rather than the choice. The install now prints the five principle headings, and for the Korean guidance what it changes plus its per-session cost and source, before asking. Locale detection is demoted from an answer to the question's default, so an English-locale machine used for Korean work can enable it on the spot.
444
464
  - **Unattended installs behave exactly as before** โ€” npm `postinstall`, CI, piped stdin and `CTS_NO_INPUT=1` skip the question and apply the old defaults, because a blocked prompt hangs an install. `--yes` and `--no-input` force that path explicitly. A non-interactive install on a non-Korean machine now leaves the setting undecided instead of recording an answer nobody gave, so a later run at a terminal still asks.
package/README.md CHANGED
@@ -129,6 +129,8 @@ npm i -g claude-token-saver
129
129
  | `โœฆ current` / `๐Ÿ“… weekly` | 5์‹œ๊ฐ„ / 7์ผ rate-limit ์œˆ๋„ ์‚ฌ์šฉ๋ฅ  + ๋ฆฌ์…‹ ์‹œ๊ฐ |
130
130
  | `๐Ÿ“ฆ` | ์ปจํ…์ŠคํŠธ ์‚ฌ์šฉ๋ฅ ์ž…๋‹ˆ๋‹ค(์˜ˆ: `Ctx 68% of 1M`). ์‚ฌ์šฉ๋ฅ ์— ๋”ฐ๋ผ ๋…น์ƒ‰ยท๋…ธ๋ž€์ƒ‰ยท๋นจ๊ฐ„์ƒ‰์œผ๋กœ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. ์ตœ์‹  ๋ชจ๋ธ์€ 1M ์ปจํ…์ŠคํŠธ๊ฐ€ ๊ธฐ๋ณธ์ด๊ณ  ๋ณ„๋„ ์š”๊ธˆ์ด ๋ถ™์ง€ ์•Š์ง€๋งŒ, ํ† ํฐ๋Ÿ‰ ์ž์ฒด๊ฐ€ ํ„ด๋‹น ๋น„์šฉ๊ณผ 5์‹œ๊ฐ„ยท7์ผ ํ•œ๋„๋ฅผ ๋น ๋ฅด๊ฒŒ ์†Œ๋ชจ์‹œํ‚ต๋‹ˆ๋‹ค |
131
131
  | `๐Ÿ’ฐ` | ํ”„๋กฌํ”„ํŠธ ์บ์‹œ๊ฐ€ ์ ˆ์•ฝํ•ด ์ค€ ๋ˆ„์  ๊ธˆ์•ก์ž…๋‹ˆ๋‹ค. ์ฒซ์งธ ์ค„์˜ `๐Ÿ”€`(๋ชจ๋ธ ๋ผ์šฐํŒ… ์ ˆ๊ฐ์•ก)์™€๋Š” **์„œ๋กœ ๋‹ค๋ฅธ ์ˆ˜์น˜์ž…๋‹ˆ๋‹ค** |
132
+ | `v3.24.0` | ์ง€๊ธˆ ์‹คํ–‰ ์ค‘์ธ claude-token-saver์˜ ๋ฒ„์ „์ž…๋‹ˆ๋‹ค. ์ตœ์‹ ์ด๋ฉด ํšŒ์ƒ‰์œผ๋กœ ์ค„ ๋์— ์กฐ์šฉํžˆ ๋†“์ž…๋‹ˆ๋‹ค |
133
+ | `โฌ† v3.24.0 โ†’ 3.25.0` | ์ƒˆ ๋ฒ„์ „์ด ๋ฐฐํฌ๋˜์–ด ์žˆ๋‹ค๋Š” ํ‘œ์‹œ์ž…๋‹ˆ๋‹ค. ์กฐ์น˜๊ฐ€ ํ•„์š”ํ•œ ์นฉ์ด๋ฏ€๋กœ ์ค„ ์•ž์ชฝ์œผ๋กœ ์˜ฌ๋ผ์˜ต๋‹ˆ๋‹ค ([์—…๋ฐ์ดํŠธ ์•ˆ๋‚ด](#-์—…๋ฐ์ดํŠธ-์•ˆ๋‚ด)) |
132
134
 
133
135
  ๋ฌธ์ œ๊ฐ€ ๊ฐ์ง€๋˜๋ฉด **๊ฒฝ๊ณ  ์นฉ์„ ์ค„ ๋งจ ์•ž์—** ๋ถ™์ž…๋‹ˆ๋‹ค.
134
136
 
@@ -160,10 +162,23 @@ Claude Code ์•ˆ์—์„œ `/claude-token-saver` Skill์„ ์‹คํ–‰ํ•˜๊ฑฐ๋‚˜, ์นฉ์— ์ 
160
162
  | `claude-token-saver korean on\|off\|status` | ํ•œ๊ตญ์–ด ๋ฌธ์ฒด ์ง€์นจ์„ ์„ธ์…˜ ์‹œ์ž‘ ์‹œ ์ฃผ์ž…ํ•˜๊ณ , ์“ฐ๊ธฐ ์‹œ์  ๊ฒ€์‚ฌ๋ฅผ ํ•จ๊ป˜ ์„ค์น˜ํ•ฉ๋‹ˆ๋‹ค (์•„๋ž˜ ์ฐธ๊ณ ) |
161
163
  | `claude-token-saver korean lint block\|warn\|off` | ์“ฐ๊ธฐ ์‹œ์  ๊ฒ€์‚ฌ๊ฐ€ ์œ„๋ฐ˜์„ ์–ด๋–ป๊ฒŒ ์ฒ˜๋ฆฌํ• ์ง€ ์ •ํ•ฉ๋‹ˆ๋‹ค |
162
164
  | `claude-token-saver korean lint scope all\|prose` | ๊ฒ€์‚ฌ ๋ฒ”์œ„๋ฅผ ๋ชจ๋“  ํ…์ŠคํŠธ ํŒŒ์ผ๊ณผ ๋ฌธ์„œ ์ „์šฉ ์‚ฌ์ด์—์„œ ๊ณ ๋ฆ…๋‹ˆ๋‹ค |
165
+ | `claude-token-saver --version` | ์„ค์น˜๋œ ๋ฒ„์ „์„ ์ถœ๋ ฅํ•ฉ๋‹ˆ๋‹ค |
166
+ | `claude-token-saver update-check` | ์ƒˆ ๋ฒ„์ „์ด ์žˆ๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค (`--refresh`๋กœ ์ฆ‰์‹œ ์กฐํšŒ, `--dismiss`๋กœ ๊ทธ ๋ฒ„์ „ ์•ˆ๋‚ด ๋„๊ธฐ) |
167
+ | `claude-token-saver upgrade` | ์„ค์น˜ ๊ฒฝ๋กœ์— ๋งž๋Š” ๋ช…๋ น์œผ๋กœ ์ตœ์‹  ๋ฒ„์ „์„ ์„ค์น˜ํ•ฉ๋‹ˆ๋‹ค (`--print`๋กœ ์‹คํ–‰ ์—†์ด ๋ช…๋ น๋งŒ ํ™•์ธ) |
163
168
  | `claude-token-saver install` | Skillยทstatusline ์ˆ˜๋™ ๋“ฑ๋ก |
164
169
 
165
170
  ์ถœ๋ ฅ ์–ธ์–ด๋Š” `mode ko`์™€ `mode en`์œผ๋กœ ์ „ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ๊ธฐ๋ณธ๊ฐ’์€ ์˜์–ด์ด๋ฉฐ, statusline์˜ ์นฉ์€ ์–ธ์ œ๋‚˜ ๊ธฐํ˜ธ๋กœ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. ์ „์ฒด ์˜ต์…˜์€ [์˜๋ฌธ README](./README.en.md#options)๋ฅผ ์ฐธ๊ณ ํ•˜์‹ญ์‹œ์˜ค.
166
171
 
172
+ ## โฌ† ์—…๋ฐ์ดํŠธ ์•ˆ๋‚ด
173
+
174
+ statusline์€ ๋Œ€ํ™” ์ƒ์ž๋ฅผ ๋„์šธ ์ˆ˜ ์—†๊ณ , 300๋ฐ€๋ฆฌ์ดˆ๋งˆ๋‹ค ๋‹ค์‹œ ๊ทธ๋ ค์ง€๊ธฐ ๋•Œ๋ฌธ์— ๊ทธ๋ฆฌ๋Š” ์‹œ์ ์— ๋„คํŠธ์›Œํฌ๋ฅผ ์“ธ ์ˆ˜๋„ ์—†์Šต๋‹ˆ๋‹ค. ๊ทธ๋ž˜์„œ ์•ˆ๋‚ด๋ฅผ ๋‘ ์ง€์ ์œผ๋กœ ๋‚˜๋ˆ„์—ˆ์Šต๋‹ˆ๋‹ค.
175
+
176
+ - **statusline์€ ์•Œ๋ฆฌ๊ธฐ๋งŒ ํ•ฉ๋‹ˆ๋‹ค.** ์ตœ์‹  ๋ฒ„์ „์ด๋ฉด ์ค„ ๋์— `v3.24.0`์„ ํšŒ์ƒ‰์œผ๋กœ ์กฐ์šฉํžˆ ํ‘œ์‹œํ•˜๊ณ , ์ƒˆ ๋ฒ„์ „์ด ์žˆ์œผ๋ฉด `โฌ† v3.24.0 โ†’ 3.25.0`์„ ์ค„ ์•ž์ชฝ์— ๋…ธ๋ž€์ƒ‰์œผ๋กœ ์˜ฌ๋ฆฝ๋‹ˆ๋‹ค. ๋นจ๊ฐ„์ƒ‰์€ ์“ฐ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋ฌด์—‡๋„ ๊ณ ์žฅ ๋‚œ ์ƒํƒœ๊ฐ€ ์•„๋‹ˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.
177
+ - **๋ฌป๋Š” ์ผ์€ ์„ธ์…˜ ์‹œ์ž‘์—์„œ ํ•ฉ๋‹ˆ๋‹ค.** ์ƒˆ ์„ธ์…˜์ด๋‚˜ `/clear` ์‹œ์ ์— SessionStart ํ›…์ด "์ƒˆ ๋ฒ„์ „์ด ์žˆ์œผ๋‹ˆ ์‚ฌ์šฉ์ž์—๊ฒŒ ์—…๊ทธ๋ ˆ์ด๋“œํ• ์ง€ ๋ฌผ์–ด๋ณด๋ผ"๋Š” ํ•œ ์ค„์„ ๋ชจ๋ธ์—๊ฒŒ ์ฃผ์ž…ํ•ฉ๋‹ˆ๋‹ค. ๋ชจ๋ธ์€ ์‚ฌ์šฉ์ž์—๊ฒŒ ํ™•์ธํ•œ ๋’ค์—๋งŒ `claude-token-saver upgrade`๋ฅผ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค. ๋ฌป์ง€ ์•Š๊ณ  ์„ค์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
178
+ - **๊ฑฐ์ ˆ์€ ๊ธฐ์–ตํ•ฉ๋‹ˆ๋‹ค.** ์‚ฌ์šฉ์ž๊ฐ€ ์›์น˜ ์•Š์œผ๋ฉด `claude-token-saver update-check --dismiss`๋กœ ๊ทธ ๋ฒ„์ „์„ ๋ฌป์ง€ ์•Š๋„๋ก ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. ๋” ์ƒˆ๋กœ์šด ๋ฒ„์ „์ด ๋ฐฐํฌ๋˜๋ฉด ๋‹ค์‹œ ๋ฌป์Šต๋‹ˆ๋‹ค. statusline ์นฉ์€ ๊ทธ๋Œ€๋กœ ๋‚จ์Šต๋‹ˆ๋‹ค. ๊ฑฐ์ ˆํ•œ ๊ฒƒ์€ ์งˆ๋ฌธ์ด์ง€, ์ƒˆ ๋ฒ„์ „์ด ์žˆ๋‹ค๋Š” ์‚ฌ์‹ค์ด ์•„๋‹ˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.
179
+
180
+ ๋ฒ„์ „ ์กฐํšŒ๋Š” 24์‹œ๊ฐ„์— ํ•œ ๋ฒˆ, ๋ถ„๋ฆฌ๋œ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ํ”„๋กœ์„ธ์Šค๊ฐ€ ์ˆ˜ํ–‰ํ•˜๊ณ  ๊ฒฐ๊ณผ๋งŒ ํŒŒ์ผ์— ๋‚จ๊น๋‹ˆ๋‹ค(`update-check.json`). ์ด ๋ฐฉ์‹์€ npm์˜ `update-notifier`๊ฐ€ ์“ฐ๋Š” ๊ฒƒ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค. ๋„คํŠธ์›Œํฌ๊ฐ€ ๋Š๊ฒจ ์žˆ์–ด๋„ ์‹คํŒจ ์‹œ๊ฐ์„ ๊ธฐ๋กํ•ด ๋‘๋ฏ€๋กœ ๋งค ๋ Œ๋”๋งˆ๋‹ค ์žฌ์‹œ๋„ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ™•์ธ ์ž์ฒด๋ฅผ ๋„๋ ค๋ฉด ํ™˜๊ฒฝ ๋ณ€์ˆ˜ `CTS_NO_UPDATE_CHECK=1` ๋˜๋Š” `NO_UPDATE_NOTIFIER`๋ฅผ ์„ค์ •ํ•˜์‹ญ์‹œ์˜ค.
181
+
167
182
  ## ๐Ÿ…ท Harness ๋ชจ๋“œ
168
183
 
169
184
  ๋‹ค์„ฏ ์›์น™(Ratchet ยท Evidence ยท PEV ยท Structured Task ยท Default Safe Path)์„ ํ•œ ์ค„ ๋ช…๋ น์œผ๋กœ `CLAUDE.md`์— ์…‹์—…ํ•˜๊ณ  statusline์ด `๐Ÿ…ท 5/5`๋กœ ์ ์ˆ˜ํ™”ํ•ฉ๋‹ˆ๋‹ค. ๊ฐ™์€ ์—๋Ÿฌ๊ฐ€ ๋ฐ˜๋ณต๋˜๋ฉด `๐Ÿ…ทโš  ratchet?` ์•Œ๋ฆผ์ด ๋– ์„œ ๋ฃฐ๋กœ ์Šน๊ฒฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
@@ -405,6 +420,11 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
405
420
 
406
421
  ## ๋ฆด๋ฆฌ์Šค ๋…ธํŠธ
407
422
 
423
+ ### v3.25.0 (2026-09-04)
424
+ - **statusline์— ์‹คํ–‰ ์ค‘์ธ ๋ฒ„์ „์„ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค.** ์ง€๊ธˆ๊นŒ์ง€๋Š” ํ‘œ ํ˜•์‹ ๋ฆฌํฌํŠธ์˜ ๊ฐ์ฃผ์—๋งŒ ๋ฒ„์ „์ด ์žˆ์—ˆ๊ธฐ ๋•Œ๋ฌธ์—, ์–ด๋–ค ๋ฒ„์ „์ด ๋„๋Š”์ง€ ํ™•์ธํ•˜๋ ค๋ฉด ์ „์ฒด ๋ฆฌํฌํŠธ๋ฅผ ์‹คํ–‰ํ•ด์•ผ ํ–ˆ์Šต๋‹ˆ๋‹ค. `--version` ํ”Œ๋ž˜๊ทธ๋„ ํ•จ๊ป˜ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค.
425
+ - **์ƒˆ ๋ฒ„์ „์ด ๋‚˜์˜ค๋ฉด ์„ธ์…˜ ์‹œ์ž‘์— ๋ฌผ์–ด๋ด…๋‹ˆ๋‹ค.** statusline์€ ๋Œ€ํ™” ์ƒ์ž๋ฅผ ๋„์šธ ์ˆ˜ ์—†์œผ๋ฏ€๋กœ, ์•Œ๋ฆผ๊ณผ ์งˆ๋ฌธ์„ ๋‚˜๋ˆ„์—ˆ์Šต๋‹ˆ๋‹ค. statusline์€ `โฌ† v3.24.0 โ†’ 3.25.0`์œผ๋กœ ์•Œ๋ฆฌ๊ธฐ๋งŒ ํ•˜๊ณ , ์‹ค์ œ ์งˆ๋ฌธ์€ SessionStart ํ›…์ด ๋ชจ๋ธ์—๊ฒŒ "์‚ฌ์šฉ์ž์—๊ฒŒ ์—…๊ทธ๋ ˆ์ด๋“œ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•˜๋ผ"๊ณ  ์ฃผ์ž…ํ•ด์„œ ์ด๋ฃจ์–ด์ง‘๋‹ˆ๋‹ค. ์Šน๋‚™ํ•˜๋ฉด `claude-token-saver upgrade`๊ฐ€ ์„ค์น˜ ๊ฒฝ๋กœ์— ๋งž๋Š” ๋ช…๋ น์„ ์‹คํ–‰ํ•˜๊ณ , ๊ฑฐ์ ˆํ•˜๋ฉด `update-check --dismiss`๊ฐ€ ๊ทธ ๋ฒ„์ „์„ ๋” ๋ฌป์ง€ ์•Š๋„๋ก ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค.
426
+ - **๋ฒ„์ „ ํ™•์ธ์ด ๋ Œ๋”๋ฅผ ๋ถ™์žก์ง€ ์•Š์Šต๋‹ˆ๋‹ค.** ์กฐํšŒ๋Š” 24์‹œ๊ฐ„์— ํ•œ ๋ฒˆ ๋ถ„๋ฆฌ๋œ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ํ”„๋กœ์„ธ์Šค๊ฐ€ ์ˆ˜ํ–‰ํ•˜๊ณ , ๋ Œ๋” ๊ฒฝ๋กœ๋Š” ์บ์‹œ ํŒŒ์ผ๋งŒ ์ฝ์Šต๋‹ˆ๋‹ค. ํ™•์ธ์— ์‹คํŒจํ•ด๋„ ์‹œ๊ฐ์„ ๊ธฐ๋กํ•˜๋ฏ€๋กœ ์˜คํ”„๋ผ์ธ์—์„œ ๋งค ๋ Œ๋”๋งˆ๋‹ค ์žฌ์‹œ๋„ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. `CTS_NO_UPDATE_CHECK=1` ๋˜๋Š” `NO_UPDATE_NOTIFIER`๋กœ ๋Œ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
427
+
408
428
  ### v3.21.0 (2026-08-22)
409
429
  - **์„ค์น˜๊ฐ€ ๋ฌด์—‡์„ ์ผœ๋Š”์ง€ ๋ณด์—ฌ ์ค€ ๋’ค ๋ฌผ์–ด๋ด…๋‹ˆ๋‹ค** โ€” ์ง€๊ธˆ๊นŒ์ง€๋Š” ๐Ÿ…ท Harness 5์›์น™๊ณผ ํ•œ๊ตญ์–ด ๋ฌธ์ฒด ์ง€์นจ์„ ์„ค์น˜๊ฐ€ ์Šค์Šค๋กœ ์ผฐ๊ณ , ์‚ฌ์šฉ์ž๋Š” ๊ฒฐ๊ณผ๋งŒ ๋ณผ ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. ์ด์ œ harness๋Š” ๋‹ค์„ฏ ์›์น™์˜ ์ œ๋ชฉ์„, ํ•œ๊ตญ์–ด ์ง€์นจ์€ ๋ฐ”๊พธ๋Š” ๋‚ด์šฉ๊ณผ ์„ธ์…˜๋‹น ๋น„์šฉ๊ณผ ์ถœ์ฒ˜๋ฅผ ๋จผ์ € ์ถœ๋ ฅํ•œ ๋‹ค์Œ ์ผค์ง€ ๋ฌป์Šต๋‹ˆ๋‹ค. ๋กœ์บ˜ ๊ฐ์ง€๋Š” ๋‹ต์ด ์•„๋‹ˆ๋ผ ์งˆ๋ฌธ์˜ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ๋‚ด๋ ค์™”์œผ๋ฏ€๋กœ, ์˜์–ด ๋กœ์บ˜์—์„œ ํ•œ๊ตญ์–ด๋กœ ์ž‘์—…ํ•˜๋Š” ๊ฒฝ์šฐ์—๋„ ๊ทธ ์ž๋ฆฌ์—์„œ ์ผค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
410
430
  - **์‚ฌ๋žŒ์ด ๋ถ™์–ด ์žˆ์ง€ ์•Š์€ ์„ค์น˜๋Š” ์˜ˆ์ „๋Œ€๋กœ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค** โ€” npm์˜ `postinstall`, CI, ํŒŒ์ดํ”„ ์ž…๋ ฅ, `CTS_NO_INPUT=1`์—์„œ๋Š” ์งˆ๋ฌธ์„ ๊ฑด๋„ˆ๋›ฐ๊ณ  ๊ธฐ์กด ๊ธฐ๋ณธ๊ฐ’์„ ๊ทธ๋Œ€๋กœ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค. ํ”„๋กฌํ”„ํŠธ๊ฐ€ ๋ฉˆ์ถฐ ์„œ๋ฉด ์„ค์น˜๊ฐ€ ๊ฑธ๋ฆฌ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. `--yes`์™€ `--no-input`์œผ๋กœ ์ง์ ‘ ๊ฐ•์ œํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค. ๋น„๋Œ€ํ™”ํ˜•์ด๋ฉด์„œ ๋กœ์บ˜์ด ํ•œ๊ตญ์–ด๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ์—๋Š” ์„ค์ •์„ ์ €์žฅํ•˜์ง€ ์•Š๊ณ  ๋ฏธ๊ฒฐ์ •์œผ๋กœ ๋‚จ๊ฒจ ๋‘๋ฏ€๋กœ, ๋‚˜์ค‘์— ํ„ฐ๋ฏธ๋„์—์„œ ์„ค์น˜ํ•˜๋ฉด ๊ทธ๋•Œ ๋ฌผ์–ด๋ด…๋‹ˆ๋‹ค.
package/bin/cli.js CHANGED
@@ -52,6 +52,7 @@ import { estimateCost } from '../src/cost.js';
52
52
  import { chipForIssues } from '../src/advice.js';
53
53
  import { debug } from '../src/debug.js';
54
54
  import { createArgs } from '../src/cli-args.js';
55
+ import { updateStatus, maybeSpawnUpdateCheck } from '../src/update-check.js';
55
56
 
56
57
  const args = process.argv.slice(2);
57
58
 
@@ -66,6 +67,22 @@ const PKG_VERSION = (() => {
66
67
 
67
68
  const { getArg, hasFlag, numArg } = createArgs(args);
68
69
 
70
+ /**
71
+ * Version/update state for the statusline chip. Reads a cache file and, when
72
+ * that cache has aged past the check interval, kicks a detached child to
73
+ * refresh it for a later render. Never awaits the network, never throws into
74
+ * the render: a registry outage must not cost the statusline its other chips.
75
+ */
76
+ function readUpdateChip() {
77
+ try {
78
+ maybeSpawnUpdateCheck(PKG_VERSION);
79
+ return updateStatus(PKG_VERSION);
80
+ } catch (e) {
81
+ debug('update-check:chip', e);
82
+ return null;
83
+ }
84
+ }
85
+
69
86
  async function main() {
70
87
  // Subcommand: last โ€” print the most recent warning + how to handle it.
71
88
  // Designed for the auto-trigger skill so the user immediately sees
@@ -166,6 +183,37 @@ async function main() {
166
183
  return (await import('../src/commands/compact-window.js')).run({ args, hasFlag });
167
184
  }
168
185
 
186
+ // `--version` / `-v` โ€” the flag every CLI is expected to answer. Until now
187
+ // the version was only visible in the table view's footer, which meant
188
+ // "which version am I on" required running a full report.
189
+ if (hasFlag('--version') || hasFlag('-v')) {
190
+ console.log(PKG_VERSION);
191
+ return;
192
+ }
193
+
194
+ // Subcommand: update-check โ€” the registry lookup behind the โฌ† statusline
195
+ // chip and the session-start upgrade offer.
196
+ // claude-token-saver update-check # print cached status
197
+ // claude-token-saver update-check --refresh # hit the registry now (detached child uses this)
198
+ // claude-token-saver update-check --dismiss # stop offering THIS version at session start
199
+ if (args[0] === 'update-check') {
200
+ return (await import('../src/commands/update-check.js')).run({
201
+ hasFlag,
202
+ version: PKG_VERSION,
203
+ });
204
+ }
205
+
206
+ // Subcommand: upgrade โ€” run the install command that matches how this copy
207
+ // got here, then confirm the new version.
208
+ // claude-token-saver upgrade # install the latest release
209
+ // claude-token-saver upgrade --print # just show the command, run nothing
210
+ if (args[0] === 'upgrade') {
211
+ return (await import('../src/commands/upgrade.js')).run({
212
+ hasFlag,
213
+ version: PKG_VERSION,
214
+ });
215
+ }
216
+
169
217
  // Hook management
170
218
  if (hasFlag('--install-hook')) {
171
219
  const { installHook } = await import('../src/hook-manager.js');
@@ -319,7 +367,7 @@ async function main() {
319
367
  } catch (e) { debug('caps-cache:persist', e); }
320
368
  }
321
369
  console.log(formatNoSession(
322
- { caps, model, windowLabel },
370
+ { caps, model, windowLabel, version: PKG_VERSION, update: readUpdateChip() },
323
371
  { color: colorOk, mode: isIcon ? 'icon' : 'text' },
324
372
  ));
325
373
  return;
@@ -471,6 +519,8 @@ async function main() {
471
519
  anomalies,
472
520
  cost,
473
521
  options: { days, windowHours, windowLabel, version: PKG_VERSION },
522
+ // Cached-only; the background refresh it may trigger lands on a later render.
523
+ update: format === 'statusline' ? readUpdateChip() : null,
474
524
  lastActivity,
475
525
  spikeReport,
476
526
  contextWindow,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.24.0",
3
+ "version": "3.25.0",
4
4
  "description": "Route the easy work your expensive Claude model keeps repeating down to haiku/sonnet โ€” post-hoc session analysis, no realtime router, no extra LLM calls.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,9 +2,23 @@
2
2
 
3
3
  */
4
4
 
5
+ import { readFileSync } from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { dirname, join } from 'node:path';
5
8
  import { readStdinJson } from '../stdin-payload.js';
6
9
  import { debug } from '../debug.js';
7
10
 
11
+ /** This package's own version โ€” the baseline the upgrade offer compares against. */
12
+ function readPackageVersion() {
13
+ try {
14
+ const pkg = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
15
+ return JSON.parse(readFileSync(pkg, 'utf8')).version || null;
16
+ } catch (e) {
17
+ debug('route-scan:pkg-version', e);
18
+ return null;
19
+ }
20
+ }
21
+
8
22
  export async function run({ args, hasFlag, numArg }) {
9
23
  const rs = await import('../route-scan.js');
10
24
  const { userLanguage } = await import('../config.js');
@@ -190,10 +204,44 @@ export async function run({ args, hasFlag, numArg }) {
190
204
  koreanBlock = koreanStyleInjection();
191
205
  } catch (e) { debug('route-scan:korean-style', e); /* style is optional */ }
192
206
 
207
+ // Upgrade offer. A statusline cannot open a dialog, so session start is
208
+ // where the *asking* happens: the model gets one line telling it a newer
209
+ // version exists and to ask before installing anything. Cached read only
210
+ // โ€” the refresh runs detached off the statusline path, never here.
211
+ let updateBlock = null;
212
+ try {
213
+ const { updateStatus, maybeSpawnUpdateCheck } = await import('../update-check.js');
214
+ const pkgVersion = readPackageVersion();
215
+ // Session start is the one moment where a stale cache matters most, so
216
+ // kick the detached refresh here too. It lands in time for the
217
+ // statusline and for the next session, not for this line.
218
+ if (pkgVersion) maybeSpawnUpdateCheck(pkgVersion);
219
+ const u = pkgVersion ? updateStatus(pkgVersion) : null;
220
+ // `dismissed` is the whole point of asking once: a user who said no to
221
+ // this version must not be asked again every `/clear`.
222
+ if (u && u.available && !u.dismissed) {
223
+ updateBlock = lang === 'ko'
224
+ ? [
225
+ `[claude-token-saver update] ์ƒˆ ๋ฒ„์ „์ด ๋‚˜์™€ ์žˆ์Šต๋‹ˆ๋‹ค: v${u.current} โ†’ ${u.latest}.`,
226
+ '์‚ฌ์šฉ์ž์—๊ฒŒ ์ง€๊ธˆ ์—…๊ทธ๋ ˆ์ด๋“œํ• ์ง€ ๋ฌผ์–ด๋ณด๊ณ , ์Šน๋‚™ํ•˜๋ฉด ์•„๋ž˜ ๋ช…๋ น์„ ์‹คํ–‰ํ•˜์‹ญ์‹œ์˜ค. ๋ฌป์ง€ ์•Š๊ณ  ์„ค์น˜ํ•˜์ง€๋Š” ๋งˆ์‹ญ์‹œ์˜ค.',
227
+ ' claude-token-saver upgrade # ์—…๊ทธ๋ ˆ์ด๋“œ ์‹คํ–‰',
228
+ ' claude-token-saver update-check --dismiss # ์‚ฌ์šฉ์ž๊ฐ€ ์›์น˜ ์•Š์œผ๋ฉด (๋‹ค์Œ ๋ฒ„์ „์ด ๋‚˜์˜ฌ ๋•Œ๊นŒ์ง€ ๋‹ค์‹œ ๋ฌป์ง€ ์•Š์Šต๋‹ˆ๋‹ค)',
229
+ ].join('\n')
230
+ : [
231
+ `[claude-token-saver update] A newer version is available: v${u.current} โ†’ ${u.latest}.`,
232
+ 'Ask the user whether to upgrade now, and run the command below only if they agree. Do not install without asking.',
233
+ ' claude-token-saver upgrade # perform the upgrade',
234
+ ' claude-token-saver update-check --dismiss # if they decline (stays quiet until a newer release)',
235
+ ].join('\n');
236
+ }
237
+ } catch (e) { debug('route-scan:update-check', e); /* the offer is optional */ }
238
+
193
239
  if (open.length === 0 && reviewRules.length === 0) {
240
+ if (updateBlock) console.log(updateBlock);
194
241
  if (koreanBlock) console.log(koreanBlock);
195
242
  return; // nothing else to inject
196
243
  }
244
+ if (updateBlock) console.log(updateBlock);
197
245
  // This text is injected straight into the model's context, so it must
198
246
  // follow the user's configured language โ€” a Korean-only briefing in an
199
247
  // English session steers the whole first response into Korean.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Subcommand: update-check โ€” everything around "is a newer version out".
3
+ *
4
+ * claude-token-saver update-check # print the cached answer
5
+ * claude-token-saver update-check --refresh # ask the registry now
6
+ * claude-token-saver update-check --refresh --quiet
7
+ * # what the detached background child runs
8
+ * claude-token-saver update-check --dismiss # stop offering this version at session start
9
+ *
10
+ * The statusline never calls the --refresh path directly; it spawns this
11
+ * command detached so the render itself stays offline and instant.
12
+ */
13
+
14
+ export async function run({ hasFlag, version }) {
15
+ const {
16
+ updateStatus,
17
+ refreshUpdateState,
18
+ dismissUpdate,
19
+ upgradeCommand,
20
+ updateCheckDisabled,
21
+ updateStatePath,
22
+ } = await import('../update-check.js');
23
+ const { userLanguage } = await import('../config.js');
24
+ const lang = userLanguage();
25
+ const quiet = hasFlag('--quiet');
26
+
27
+ if (hasFlag('--refresh')) {
28
+ const res = await refreshUpdateState(version);
29
+ if (quiet) return;
30
+ if (!res.ok) {
31
+ console.error(lang === 'ko'
32
+ ? `์ตœ์‹  ๋ฒ„์ „์„ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค: ${res.error}`
33
+ : `Could not reach the registry: ${res.error}`);
34
+ process.exit(1);
35
+ }
36
+ }
37
+
38
+ if (hasFlag('--dismiss')) {
39
+ const s = updateStatus(version);
40
+ if (s.latest) dismissUpdate(s.latest);
41
+ console.log(lang === 'ko'
42
+ ? `${s.latest || '์ตœ์‹ '} ๋ฒ„์ „ ์•ˆ๋‚ด๋ฅผ ๋•๋‹ˆ๋‹ค. ๊ทธ ๋‹ค์Œ ๋ฒ„์ „์ด ๋‚˜์˜ค๋ฉด ๋‹ค์‹œ ์•ˆ๋‚ดํ•ฉ๋‹ˆ๋‹ค.`
43
+ : `Muted the ${s.latest || 'latest'} notice. The next release after it will ask again.`);
44
+ return;
45
+ }
46
+
47
+ if (updateCheckDisabled()) {
48
+ console.log(lang === 'ko'
49
+ ? `ํ˜„์žฌ ๋ฒ„์ „ v${version} โ€” ์—…๋ฐ์ดํŠธ ํ™•์ธ์ด ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋กœ ๊บผ์ ธ ์žˆ์Šต๋‹ˆ๋‹ค (CTS_NO_UPDATE_CHECK / NO_UPDATE_NOTIFIER).`
50
+ : `v${version} โ€” update checks are disabled by env var (CTS_NO_UPDATE_CHECK / NO_UPDATE_NOTIFIER).`);
51
+ return;
52
+ }
53
+
54
+ const s = updateStatus(version);
55
+ if (s.available) {
56
+ console.log(lang === 'ko'
57
+ ? `์ƒˆ ๋ฒ„์ „์ด ์žˆ์Šต๋‹ˆ๋‹ค: v${version} โ†’ ${s.latest}`
58
+ : `Update available: v${version} โ†’ ${s.latest}`);
59
+ console.log(lang === 'ko'
60
+ ? `์—…๊ทธ๋ ˆ์ด๋“œ: ${upgradeCommand()} (๋˜๋Š” claude-token-saver upgrade)`
61
+ : `Upgrade with: ${upgradeCommand()} (or: claude-token-saver upgrade)`);
62
+ if (s.dismissed) {
63
+ console.log(lang === 'ko'
64
+ ? '์ด ๋ฒ„์ „์€ ์‚ฌ์šฉ์ž๊ฐ€ ํ•œ ๋ฒˆ ๋„˜๊ธด ์ƒํƒœ๋ผ, ์„ธ์…˜ ์‹œ์ž‘ ์‹œ์—๋Š” ๋‹ค์‹œ ๋ฌป์ง€ ์•Š์Šต๋‹ˆ๋‹ค.'
65
+ : 'This version was dismissed, so the session-start offer stays quiet for it.');
66
+ }
67
+ return;
68
+ }
69
+ console.log(lang === 'ko'
70
+ ? `์ตœ์‹  ๋ฒ„์ „์„ ์“ฐ๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค (v${version}).`
71
+ : `Up to date (v${version}).`);
72
+ if (!s.latest) {
73
+ console.log(lang === 'ko'
74
+ ? `์•„์ง ํ™•์ธ๋œ ๊ธฐ๋ก์ด ์—†์Šต๋‹ˆ๋‹ค. ์ง€๊ธˆ ํ™•์ธํ•˜๋ ค๋ฉด: claude-token-saver update-check --refresh (๊ธฐ๋ก ํŒŒ์ผ: ${updateStatePath()})`
75
+ : `No check has completed yet. Run: claude-token-saver update-check --refresh (state: ${updateStatePath()})`);
76
+ }
77
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Subcommand: upgrade โ€” install the latest release with the package manager
3
+ * that put this copy on disk.
4
+ *
5
+ * claude-token-saver upgrade # refresh the check, then install
6
+ * claude-token-saver upgrade --print # show the command, run nothing
7
+ *
8
+ * This is the command the model runs after the user says yes to the
9
+ * session-start offer, so it prints the exact command it is about to execute
10
+ * before executing it: an install that writes outside the project should never
11
+ * be a black box.
12
+ */
13
+
14
+ import { spawn } from 'node:child_process';
15
+
16
+ function runCommand(cmd) {
17
+ return new Promise((resolve) => {
18
+ // Shell form: the upgrade command is a fixed string we composed ourselves
19
+ // (no user input reaches it), and `npm install -g` needs the user's PATH
20
+ // resolution to find the same npm that installed us.
21
+ const child = spawn(cmd, { shell: true, stdio: 'inherit' });
22
+ child.on('close', (code) => resolve(code === null ? 1 : code));
23
+ child.on('error', () => resolve(1));
24
+ });
25
+ }
26
+
27
+ export async function run({ hasFlag, version }) {
28
+ const { updateStatus, refreshUpdateState, upgradeCommand } = await import('../update-check.js');
29
+ const { userLanguage } = await import('../config.js');
30
+ const lang = userLanguage();
31
+ const cmd = upgradeCommand();
32
+
33
+ if (hasFlag('--print')) {
34
+ console.log(cmd);
35
+ return;
36
+ }
37
+
38
+ // Refresh first so the "already latest" answer is trustworthy rather than up
39
+ // to 24h stale โ€” an upgrade is a deliberate, interactive action, so one
40
+ // network round trip is fine here.
41
+ await refreshUpdateState(version);
42
+ const s = updateStatus(version);
43
+ if (!s.available) {
44
+ console.log(lang === 'ko'
45
+ ? `์ด๋ฏธ ์ตœ์‹  ๋ฒ„์ „์ž…๋‹ˆ๋‹ค (v${version}). ์„ค์น˜ํ•  ๊ฒƒ์ด ์—†์Šต๋‹ˆ๋‹ค.`
46
+ : `Already on the latest version (v${version}). Nothing to install.`);
47
+ return;
48
+ }
49
+
50
+ console.log(lang === 'ko'
51
+ ? `v${version} โ†’ ${s.latest} ๋กœ ์—…๊ทธ๋ ˆ์ด๋“œํ•ฉ๋‹ˆ๋‹ค.`
52
+ : `Upgrading v${version} โ†’ ${s.latest}.`);
53
+ console.log(`$ ${cmd}`);
54
+ const code = await runCommand(cmd);
55
+ if (code !== 0) {
56
+ console.error(lang === 'ko'
57
+ ? `์—…๊ทธ๋ ˆ์ด๋“œ ๋ช…๋ น์ด ์ข…๋ฃŒ ์ฝ”๋“œ ${code}๋กœ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ์œ„ ์ถœ๋ ฅ์„ ํ™•์ธํ•˜๊ณ , ๊ถŒํ•œ ๋ฌธ์ œ๋ผ๋ฉด sudo ์—†์ด ์„ค์น˜๋˜๋Š” ์ „์—ญ ๊ฒฝ๋กœ์ธ์ง€ ์ ๊ฒ€ํ•˜์‹ญ์‹œ์˜ค.`
58
+ : `Upgrade command failed with exit code ${code}. Check the output above; if it is a permissions error, verify your global install prefix.`);
59
+ process.exit(code);
60
+ }
61
+ // The freshly installed copy is a different file on disk, so this process
62
+ // still reports the old version. Refresh the cache against the new latest so
63
+ // the statusline chip clears on the next render instead of lingering.
64
+ await refreshUpdateState(s.latest);
65
+ console.log(lang === 'ko'
66
+ ? `์„ค์น˜๊ฐ€ ๋๋‚ฌ์Šต๋‹ˆ๋‹ค. ์ƒˆ ์…ธ์—์„œ claude-token-saver --version ์œผ๋กœ ${s.latest} ์ธ์ง€ ํ™•์ธํ•˜์‹ญ์‹œ์˜ค.`
67
+ : `Done. In a fresh shell, run claude-token-saver --version to confirm ${s.latest}.`);
68
+ }
@@ -157,6 +157,33 @@ function buildKoreanSeg(c, isIcon, verbose) {
157
157
  }
158
158
  }
159
159
 
160
+ /**
161
+ * Version segment builder โ€” "which copy of this tool am I looking at", plus
162
+ * the upgrade nudge when a newer one has been published.
163
+ *
164
+ * Two states, deliberately different in weight:
165
+ * - up to date โ†’ `v3.24.0` in gray. Identity context, not news.
166
+ * - update available โ†’ `โฌ† v3.24.0 โ†’ 3.25.0` in yellow. Same tone as the
167
+ * other "you should do something eventually" chips, never red: nothing is
168
+ * broken, and a permanently-red statusline trains the eye to ignore red.
169
+ *
170
+ * A statusline cannot open a dialog, so the *asking* happens at session start
171
+ * (see route-scan --hook, which briefs the model to offer the upgrade). This
172
+ * chip is the persistent reminder between those offers, which is why it keeps
173
+ * rendering after the user declines โ€” declining hides the session-start
174
+ * question, not the fact that a new version exists.
175
+ */
176
+ function buildVersionSeg(version, update, c, isIcon, verbose) {
177
+ if (!version) return null;
178
+ if (update && update.available && update.latest) {
179
+ const body = verbose
180
+ ? `Update v${version} โ†’ ${update.latest}`
181
+ : `v${version} โ†’ ${update.latest}`;
182
+ return `${c(YELLOW)}${isIcon ? 'โฌ† ' : ''}${body}${c(RESET)}`;
183
+ }
184
+ return `${c(GRAY)}v${version}${c(RESET)}`;
185
+ }
186
+
160
187
  /**
161
188
  * Harness ๐Ÿ…ท segment builder โ€” shared by the full report and the no-session
162
189
  * fallback line. Best-effort: never throws into the statusline (corrupted
@@ -209,18 +236,21 @@ function buildCapWarnSeg(capWarn, c, isIcon) {
209
236
  * just because the user has been idle past the window โ€” so cap-warn,
210
237
  * harness, and model chips still render around the "no session data" note.
211
238
  */
212
- export function formatNoSession({ caps = null, model = null, windowLabel = '' } = {}, { color = true, mode = 'icon' } = {}) {
239
+ export function formatNoSession({ caps = null, model = null, windowLabel = '', version = '', update = null } = {}, { color = true, mode = 'icon' } = {}) {
213
240
  const c = (v) => (color ? v : '');
214
241
  const isIcon = mode === 'icon';
215
242
  const segs = [];
216
243
  const capSeg = buildCapWarnSeg(pickCapWarn(caps), c, isIcon);
217
244
  if (capSeg) segs.push(capSeg);
245
+ const versionSeg = buildVersionSeg(version, update, c, isIcon, false);
246
+ if (versionSeg && update && update.available) segs.push(versionSeg);
218
247
  const harnessSeg = buildHarnessSeg(c, isIcon);
219
248
  if (harnessSeg) segs.push(harnessSeg);
220
249
  if (typeof model === 'string' && model.length > 0) {
221
250
  segs.push(isIcon ? `${c(MAGENTA)}๐Ÿค– ${model}${c(RESET)}` : `${c(MAGENTA)}${model}${c(RESET)}`);
222
251
  }
223
252
  segs.push(`${c(GRAY)}๐Ÿง  no session data${windowLabel ? ` ยท ${windowLabel}` : ''}${c(RESET)}`);
253
+ if (versionSeg && !(update && update.available)) segs.push(versionSeg);
224
254
  return segs.join(' ยท ') + (color ? '\x1b[K' : '');
225
255
  }
226
256
 
@@ -231,7 +261,7 @@ export function formatNoSession({ caps = null, model = null, windowLabel = '' }
231
261
  * @param {boolean} [opts.verbose=false] - longer layout with labels
232
262
  * @param {boolean} [opts.timer=true] - show TTL countdown segment
233
263
  * @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses ๐Ÿง  โณ ๐Ÿ’ฐ instead of word labels.
234
- * @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, harness, korean, model, hit, ttl, saved, delegated, ctx, period, plus per-window keys (`five_hour`, `seven_day`, โ€ฆ). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
264
+ * @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, version, harness, korean, model, hit, ttl, saved, delegated, ctx, period, plus per-window keys (`five_hour`, `seven_day`, โ€ฆ). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
235
265
  * @param {boolean} [opts.singleLine=false] - force the legacy one-line layout. By default, when the delegation ledger has lifetime savings, the routing totals lead on their own first line and everything else moves to line 2 (Claude Code renders multi-line statuslines; `--single-line` is the escape hatch for terminals that only show the first line).
236
266
  */
237
267
  export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null, singleLine = false } = {}) {
@@ -446,6 +476,11 @@ export function formatReport(data, { color = true, verbose = false, timer = true
446
476
  // a missing section at a glance and know to run `harness init`.
447
477
  const harnessSeg = buildHarnessSeg(c, isIcon);
448
478
 
479
+ // Version / upgrade chip. Read from a cache written by a detached background
480
+ // check โ€” this render path never touches the network.
481
+ const versionSeg = buildVersionSeg(options.version, data.update, c, isIcon, verbose);
482
+ const updateAvailable = !!(data.update && data.update.available);
483
+
449
484
  // Korean-style chip โ€” rendered only when the session-start injection is on.
450
485
  const koreanSeg = buildKoreanSeg(c, isIcon, verbose);
451
486
 
@@ -550,6 +585,10 @@ export function formatReport(data, { color = true, verbose = false, timer = true
550
585
  const segs = [];
551
586
  if (capWarnSeg && want('cap-warn')) segs.push(capWarnSeg);
552
587
  if (spikeSeg && want('spike')) segs.push(spikeSeg);
588
+ // An available upgrade rides up front with the other "act on this" chips.
589
+ // When there is nothing to upgrade to, the same segment is pure identity and
590
+ // sits at the tail instead (pushed after `saved`, below).
591
+ if (versionSeg && updateAvailable && want('version')) segs.push(versionSeg);
553
592
  if (harnessSeg && want('harness')) segs.push(harnessSeg);
554
593
  if (koreanSeg && want('korean')) segs.push(koreanSeg);
555
594
  if (modelSeg && want('model')) segs.push(modelSeg);
@@ -569,6 +608,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
569
608
  // it sits near the tail. The period label closes the line as a quiet
570
609
  // timeframe footer.
571
610
  if (want('saved')) segs.push(saveSeg);
611
+ if (versionSeg && !updateAvailable && want('version')) segs.push(versionSeg);
572
612
  if (want('period')) segs.push(periodSeg);
573
613
  // Trailing erase-to-end-of-line so any leftover characters from a previous
574
614
  // (longer) statusline render don't bleed into ours. \x1b[K is the standard
@@ -0,0 +1,198 @@
1
+ /**
2
+ * update-check โ€” "is there a newer claude-token-saver?", answered without ever
3
+ * blocking a render.
4
+ *
5
+ * The statusline command runs every ~300ms, so a network call on that path is
6
+ * out of the question. We use the same shape sindresorhus/update-notifier
7
+ * settled on: the foreground only ever READS a cached answer, and when that
8
+ * answer is older than the check interval it spawns a detached, unref'd child
9
+ * that refreshes the cache for the *next* render. Nothing awaits the network.
10
+ *
11
+ * State lives next to the other user-data files:
12
+ * { checkedAt: <ms>, latest: "3.25.0", current: "3.24.0",
13
+ * dismissedVersion: "3.25.0"|undefined }
14
+ *
15
+ * Opt out with CTS_NO_UPDATE_CHECK=1 or NO_UPDATE_NOTIFIER (the de-facto
16
+ * standard env var โ€” anyone who set it for other CLIs meant us too).
17
+ */
18
+
19
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
20
+ import { spawn } from 'node:child_process';
21
+ import { join, dirname } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { userDataDir } from './paths.js';
24
+ import { debug } from './debug.js';
25
+
26
+ const PKG_NAME = 'claude-token-saver';
27
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h โ€” the registry is not a health endpoint
28
+ const FETCH_TIMEOUT_MS = 5000;
29
+
30
+ export function updateStatePath() {
31
+ return join(userDataDir(), 'update-check.json');
32
+ }
33
+
34
+ export function updateCheckDisabled() {
35
+ return process.env.CTS_NO_UPDATE_CHECK === '1' || !!process.env.NO_UPDATE_NOTIFIER;
36
+ }
37
+
38
+ export function readUpdateState() {
39
+ try {
40
+ const s = JSON.parse(readFileSync(updateStatePath(), 'utf8'));
41
+ return s && typeof s === 'object' ? s : {};
42
+ } catch {
43
+ return {};
44
+ }
45
+ }
46
+
47
+ function writeUpdateState(next) {
48
+ const dir = userDataDir();
49
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
50
+ writeFileSync(updateStatePath(), JSON.stringify(next, null, 2) + '\n');
51
+ }
52
+
53
+ /**
54
+ * Compare two semver-ish strings. Returns true when `a` is strictly newer than
55
+ * `b`. Pre-release tags (`3.25.0-beta.1`) are treated as older than the plain
56
+ * release, which is what we want: we never nudge anyone onto a pre-release.
57
+ */
58
+ export function isNewer(a, b) {
59
+ const parse = (v) => {
60
+ const m = String(v || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
61
+ if (!m) return null;
62
+ return { nums: [+m[1], +m[2], +m[3]], pre: m[4] || null };
63
+ };
64
+ const pa = parse(a);
65
+ const pb = parse(b);
66
+ if (!pa || !pb) return false;
67
+ for (let i = 0; i < 3; i++) {
68
+ if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] > pb.nums[i];
69
+ }
70
+ if (pa.pre && !pb.pre) return false; // 3.25.0-beta < 3.25.0
71
+ if (!pa.pre && pb.pre) return true;
72
+ return false;
73
+ }
74
+
75
+ /**
76
+ * The read-only accessor every render path uses.
77
+ *
78
+ * @returns {{current: string, latest: string|null, available: boolean, dismissed: boolean, stale: boolean}}
79
+ */
80
+ export function updateStatus(currentVersion) {
81
+ if (updateCheckDisabled()) {
82
+ return { current: currentVersion, latest: null, available: false, dismissed: false, stale: false };
83
+ }
84
+ const s = readUpdateState();
85
+ const latest = typeof s.latest === 'string' ? s.latest : null;
86
+ const available = !!latest && isNewer(latest, currentVersion);
87
+ const age = Date.now() - (Number(s.checkedAt) || 0);
88
+ return {
89
+ current: currentVersion,
90
+ latest,
91
+ available,
92
+ // A version the user already declined stays out of the statusline and out
93
+ // of the session briefing until a newer one ships โ€” otherwise "no thanks"
94
+ // means "ask me again in five minutes", forever.
95
+ dismissed: available && s.dismissedVersion === latest,
96
+ stale: age >= CHECK_INTERVAL_MS,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Fire the background refresh when the cached answer has aged out. Returns
102
+ * immediately in every case; the child is detached and unref'd so it cannot
103
+ * hold the statusline process open.
104
+ */
105
+ export function maybeSpawnUpdateCheck(currentVersion) {
106
+ if (updateCheckDisabled()) return false;
107
+ const { stale } = updateStatus(currentVersion);
108
+ if (!stale) return false;
109
+ // Stamp the attempt before spawning. Without this, an offline machine
110
+ // re-spawns a doomed child on every single statusline render โ€” several per
111
+ // second โ€” because the cache never gets a fresh timestamp.
112
+ try {
113
+ writeUpdateState({ ...readUpdateState(), checkedAt: Date.now(), current: currentVersion });
114
+ } catch (e) {
115
+ debug('update-check:stamp', e);
116
+ return false;
117
+ }
118
+ try {
119
+ spawn(process.execPath, [cliEntryPath(), 'update-check', '--refresh', '--quiet'], {
120
+ detached: true,
121
+ stdio: 'ignore',
122
+ }).unref();
123
+ return true;
124
+ } catch (e) {
125
+ debug('update-check:spawn', e);
126
+ return false;
127
+ }
128
+ }
129
+
130
+ /** Path to this package's CLI entry point (bin/cli.js). */
131
+ export function cliEntryPath() {
132
+ return join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'cli.js');
133
+ }
134
+
135
+ /**
136
+ * Actually hit the registry and persist the answer. Only the detached child
137
+ * and the explicit `update-check --refresh` command call this.
138
+ */
139
+ export async function refreshUpdateState(currentVersion) {
140
+ const controller = new AbortController();
141
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
142
+ try {
143
+ // The `latest` dist-tag endpoint returns a few hundred bytes, unlike the
144
+ // full packument which is megabytes for a package with this many releases.
145
+ const res = await fetch(`https://registry.npmjs.org/${PKG_NAME}/latest`, {
146
+ signal: controller.signal,
147
+ headers: { accept: 'application/json' },
148
+ });
149
+ if (!res.ok) throw new Error(`registry responded ${res.status}`);
150
+ const body = await res.json();
151
+ const latest = typeof body.version === 'string' ? body.version : null;
152
+ if (!latest) throw new Error('registry response carried no version');
153
+ const prev = readUpdateState();
154
+ const next = { ...prev, checkedAt: Date.now(), latest, current: currentVersion };
155
+ // A newly published version clears an older dismissal: the user declined
156
+ // 3.25.0, not "all future upgrades".
157
+ if (prev.dismissedVersion && isNewer(latest, prev.dismissedVersion)) {
158
+ delete next.dismissedVersion;
159
+ }
160
+ writeUpdateState(next);
161
+ return { ok: true, latest };
162
+ } catch (e) {
163
+ debug('update-check:refresh', e);
164
+ // Keep the timestamp fresh even on failure so an offline machine backs off
165
+ // for the full interval instead of retrying on every render.
166
+ try {
167
+ writeUpdateState({ ...readUpdateState(), checkedAt: Date.now(), current: currentVersion });
168
+ } catch (e2) {
169
+ debug('update-check:refresh-stamp', e2);
170
+ }
171
+ return { ok: false, error: String(e && e.message ? e.message : e) };
172
+ } finally {
173
+ clearTimeout(timer);
174
+ }
175
+ }
176
+
177
+ /** Record that the user said "not now" for this exact version. */
178
+ export function dismissUpdate(version) {
179
+ const s = readUpdateState();
180
+ writeUpdateState({ ...s, dismissedVersion: version });
181
+ }
182
+
183
+ /**
184
+ * How this copy was installed, and therefore what command upgrades it.
185
+ * Best-effort: the install root is the only reliable signal we have, and when
186
+ * it tells us nothing we fall back to the npm global install, which is how the
187
+ * overwhelming majority of copies got here.
188
+ */
189
+ export function upgradeCommand() {
190
+ const here = dirname(fileURLToPath(import.meta.url));
191
+ if (here.includes('/pnpm/')) return `pnpm add -g ${PKG_NAME}@latest`;
192
+ if (here.includes('/.bun/')) return `bun add -g ${PKG_NAME}@latest`;
193
+ if (here.includes('/.yarn/')) return `yarn global add ${PKG_NAME}@latest`;
194
+ return `npm install -g ${PKG_NAME}@latest`;
195
+ }
196
+
197
+ export const UPDATE_CHECK_INTERVAL_MS = CHECK_INTERVAL_MS;
198
+ export const PACKAGE_NAME = PKG_NAME;