claude-token-saver 2.1.0 → 2.2.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.md CHANGED
@@ -23,7 +23,7 @@ v1.5 adds three things on top of the original `claude-cache-monitor`:
23
23
 
24
24
  The original functionality still works: cache hit rate, TTL breakdown, cost impact vs. no-cache, TTL countdown timer, and Claude Code statusline integration.
25
25
 
26
- **Runs standalone or as a plugin under another statusline.** Use `npx claude-token-saver` as a one-shot report, wire it into Claude Code's native statusline as the sole segment, or drop it in as an appended segment under a richer statusline like [rz1989s/claude-code-statusline](https://github.com/rz1989s/claude-code-statusline). See [Three Ways to Use It](#three-ways-to-use-it).
26
+ **Run it standalone or wire it into Claude Code's statusline.** Use `npx claude-token-saver` as a one-shot report, or wire it into Claude Code's native statusline for an always-on chip. See [Two Ways to Use It](#two-ways-to-use-it).
27
27
 
28
28
  ---
29
29
 
@@ -36,7 +36,7 @@ v1.5 신규:
36
36
 
37
37
  기존 기능(캐시 히트율·TTL 분포·비용 절감·TTL 카운트다운·statusline)은 그대로 유지됩니다.
38
38
 
39
- **단독 도구로도, 다른 statusline 플러그인으로도 동작합니다.** `npx claude-token-saver` 한 줄로 진단 리포트만 보는 것도 가능하고, Claude Code 내장 statusline에 직접 연결해 상시 표시할 수도 있고, [rz1989s/claude-code-statusline](https://github.com/rz1989s/claude-code-statusline) 같은 기존 statusline 아래에 세그먼트로 덧붙일 수도 있습니다. 자세한 용법은 [Three Ways to Use It](#three-ways-to-use-it) 참고.
39
+ **단독 도구로도, Claude Code statusline 통합으로도 동작합니다.** `npx claude-token-saver` 한 줄로 진단 리포트만 보거나, 내장 statusline에 연결해 상시 표시할 있습니다. 자세한 용법은 [Two Ways to Use It](#two-ways-to-use-it) 참고.
40
40
 
41
41
  ## Quick Start
42
42
 
@@ -99,15 +99,12 @@ Issue codes detected:
99
99
 
100
100
  Remediation commands are chosen from `process.platform` — macOS/Linux/WSL get `~/.zshrc` snippets, Windows gets `setx` and the PowerShell equivalent.
101
101
 
102
- ## Three Ways to Use It
103
-
104
- `claude-token-saver` is primarily a **standalone tool**; the plugin mode is just a convenience for users who already run another statusline.
102
+ ## Two Ways to Use It
105
103
 
106
104
  | Mode | What you run | When to pick this |
107
105
  |---|---|---|
108
106
  | **1. Standalone CLI report** | `npx claude-token-saver` | One-off diagnosis. Prints the full report (spikes + cache + cost + trend). Zero setup. |
109
- | **2. Standalone Claude Code statusline** | `claude-token-saver --statusline` wired via `~/.claude/settings.json` | You want the chip (hit rate · TTL countdown · Ctx 200k/1M · spike) visible all the time. The default story. |
110
- | **3. Plugin under another statusline** | `examples/statusline-with-rz1989s.sh` appends our segment to rz1989s or any wrapper script | You already have a rich statusline (repo info, cost, MCP, prayer times, themes) and want to bolt the token-saver segment on the end. |
107
+ | **2. Claude Code statusline** | `claude-token-saver --statusline` wired via `~/.claude/settings.json` | You want the chip (hit rate · TTL countdown · Ctx 200k/1M · spike) visible all the time. |
111
108
 
112
109
  Detail for each mode below.
113
110
 
@@ -193,22 +190,6 @@ Works best in **Windows Terminal** or **PowerShell 7+** (ANSI color + emoji). Cl
193
190
 
194
191
  Same as Linux — install the package in your WSL Node.js and point to the POSIX sh script.
195
192
 
196
- #### Combine with rz1989s/claude-code-statusline
197
-
198
- If you already use [rz1989s/claude-code-statusline](https://github.com/rz1989s/claude-code-statusline) for its rich layout (repo, cost, MCP, prayer times, themes), drop in [`examples/statusline-with-rz1989s.sh`](examples/statusline-with-rz1989s.sh) to append our cache segment at the end — no conflict, no feature overlap.
199
-
200
- ```json
201
- {
202
- "statusLine": {
203
- "type": "command",
204
- "command": "bash ~/.claude/statusline-with-rz1989s.sh",
205
- "refreshInterval": 1
206
- }
207
- }
208
- ```
209
-
210
- ---
211
-
212
193
  Claude Code calls this every ~300ms on events, plus once per `refreshInterval` second while idle. Colors are emitted when the terminal supports them:
213
194
 
214
195
  - **Hit rate** — 🟢 ≥85% · 🟡 70–85% · 🔴 <70%
@@ -261,6 +242,34 @@ Storage paths (cross-platform):
261
242
 
262
243
  Each day's file is plain Markdown — open it in any editor. Transitions are deduped, so the 1Hz statusline refresh doesn't spam.
263
244
 
245
+ ## Cap-warn + handoff (new in v2.2)
246
+
247
+ Claude Code's statusline payload now includes rate-limit usage (`rate_limits.five_hour.used_percentage`, `rate_limits.seven_day.used_percentage`). claude-token-saver leads the statusline with a `🚨 5H 94%` (or `🚨 7D 92%`) chip the moment either window crosses **90%**, and writes the transition into history:
248
+
249
+ ```
250
+ - 14:32:08 🚨 5H 94% cap warning (resets in 1h 38m)
251
+ - 16:10:21 ✓ 5H cap warning resolved
252
+ ```
253
+
254
+ When you see the chip, back up the work in flight before the cap blocks you:
255
+
256
+ ```bash
257
+ claude-token-saver handoff
258
+ ```
259
+
260
+ That writes `./HANDOFF-YYYY-MM-DD-HHMM.md` in the current directory with:
261
+
262
+ - timestamp, cwd, git branch / HEAD / dirty file list
263
+ - the 5h/7d cap snapshot (and "resets in Hh Mm")
264
+ - empty fillable sections for *what I just did*, *TODO*, *where to pick up next*, *gotchas*
265
+ - a one-line resume prompt for a fresh Claude Code session:
266
+
267
+ ```
268
+ Read the most recent HANDOFF-*.md in this directory and continue the work.
269
+ ```
270
+
271
+ The handoff write is also recorded in history (`📝 handoff written: …`), so `/token-monitor` and `claude-token-saver history` show both the cap-warn and the backup event next to each other.
272
+
264
273
  ## Hook Setup
265
274
 
266
275
  Automatically logs cache stats on every tool call and alerts when hit rate drops below a threshold.
package/bin/cli.js CHANGED
@@ -26,6 +26,50 @@ import { readFileSync } from 'node:fs';
26
26
  import { fileURLToPath } from 'node:url';
27
27
  import { dirname, join } from 'node:path';
28
28
 
29
+ /**
30
+ * Read the JSON blob Claude Code feeds the statusline command on stdin.
31
+ * Returns null when stdin is a TTY or empty (e.g. user invokes `--statusline`
32
+ * by hand) so callers can fall back to flag/env config.
33
+ *
34
+ * The blob shape (subset we consume):
35
+ * {
36
+ * "transcript_path": "...",
37
+ * "rate_limits": {
38
+ * "five_hour": { "used_percentage": 94, "resets_at": 1777099200 },
39
+ * "seven_day": { "used_percentage": 7, "resets_at": 1777521600 }
40
+ * }
41
+ * }
42
+ */
43
+ function readStdinJson() {
44
+ if (process.stdin.isTTY) return null;
45
+ try {
46
+ const raw = readFileSync(0, 'utf8');
47
+ if (!raw || !raw.trim()) return null;
48
+ return JSON.parse(raw);
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function extractCaps(stdinJson) {
55
+ if (!stdinJson || !stdinJson.rate_limits) return null;
56
+ const rl = stdinJson.rate_limits;
57
+ const pick = (obj) => {
58
+ if (!obj || typeof obj !== 'object') return null;
59
+ const used = Number(obj.used_percentage);
60
+ if (!Number.isFinite(used)) return null;
61
+ const resetsAt = Number(obj.resets_at);
62
+ return {
63
+ usedPct: used,
64
+ resetsAt: Number.isFinite(resetsAt) ? resetsAt : null,
65
+ };
66
+ };
67
+ return {
68
+ fiveHour: pick(rl.five_hour),
69
+ sevenDay: pick(rl.seven_day),
70
+ };
71
+ }
72
+
29
73
  import { parseAllSessions, getLastUserMessageTime } from '../src/parser.js';
30
74
  import {
31
75
  dailyTrend,
@@ -53,8 +97,11 @@ const PKG_VERSION = (() => {
53
97
 
54
98
  function getArg(name) {
55
99
  const idx = args.indexOf(name);
56
- if (idx === -1) return undefined;
57
- return args[idx + 1];
100
+ if (idx !== -1) return args[idx + 1];
101
+ const prefix = `${name}=`;
102
+ const eq = args.find((a) => a.startsWith(prefix));
103
+ if (eq) return eq.slice(prefix.length);
104
+ return undefined;
58
105
  }
59
106
 
60
107
  function hasFlag(name) {
@@ -94,6 +141,34 @@ async function main() {
94
141
  return;
95
142
  }
96
143
 
144
+ // Subcommand: handoff — write a HANDOFF-YYYY-MM-DD-HHMM.md template in cwd
145
+ // capturing git status + the latest cap snapshot, so a fresh Claude Code
146
+ // session can pick up where this one stopped. Pairs with the cap-warn chip:
147
+ // when statusline shows 🚨 5H 90%+, run this to back up state before the cap
148
+ // hits.
149
+ // claude-token-saver handoff # write to cwd
150
+ // claude-token-saver handoff --cwd PATH # custom directory
151
+ if (args[0] === 'handoff') {
152
+ const { writeHandoff } = await import('../src/handoff.js');
153
+ const { recordHandoff } = await import('../src/history.js');
154
+ const cwd = getArg('--cwd') || process.cwd();
155
+ // Cap data only flows in via stdin (Claude Code statusline contract).
156
+ // Direct CLI invocations won't have it — that's fine, the template will
157
+ // note the gap.
158
+ const stdinJson = readStdinJson();
159
+ const caps = extractCaps(stdinJson);
160
+ const { path, git } = writeHandoff({ cwd, caps });
161
+ try { recordHandoff(path); } catch { /* non-critical */ }
162
+ console.log(`Handoff written: ${path}`);
163
+ if (git) {
164
+ console.log(` git: ${git.branch}${git.head ? ` @ ${git.head}` : ''}${git.status ? ' (dirty)' : ' (clean)'}`);
165
+ }
166
+ console.log('');
167
+ console.log('Fill in the empty sections, then start a new Claude Code session with:');
168
+ console.log(' Read the most recent HANDOFF-*.md in this directory and continue the work.');
169
+ return;
170
+ }
171
+
97
172
  // Subcommand: install — write the Claude Code Skill and slash command so
98
173
  // /token-monitor and the auto-trigger skill become available without any
99
174
  // manual file editing. Cross-platform (uses node:path + node:fs).
@@ -234,11 +309,16 @@ async function main() {
234
309
  : (hasFlag('--no-verbose') || hasFlag('--compact') ? false : cfg.verbose);
235
310
  const showTimer = hasFlag('--no-timer') ? false : cfg.timer;
236
311
  const colorOk = !hasFlag('--no-color') && !process.env.NO_COLOR && cfg.color;
312
+ const segmentsArg = getArg('--segments');
313
+ const segments = segmentsArg
314
+ ? segmentsArg.split(',').map((s) => s.trim()).filter(Boolean)
315
+ : null;
237
316
  const out = formatReport(data, {
238
317
  color: colorOk,
239
318
  verbose: isVerbose,
240
319
  timer: showTimer,
241
320
  mode: isIcon ? 'icon' : 'text',
321
+ segments,
242
322
  });
243
323
  // For `cycle` mode, prefix with the scenario label so the screen recorder
244
324
  // shows what the viewer is looking at (only when explicitly requested).
@@ -318,6 +398,30 @@ async function main() {
318
398
  const spikeReport = detectSpikes(sessions, { recentHours: 24, multiplier: 3 });
319
399
  const contextWindow = detectContextWindow(sessions, { recentHours: 24 });
320
400
 
401
+ // Claude Code feeds the statusline command a JSON blob on stdin every
402
+ // refresh. Pull rate_limits out of it so we can surface cap-warn (>=90%)
403
+ // chips, record cap transitions, and seed the table view's warning box.
404
+ // The table path falls back to the most-recent cached snapshot so the
405
+ // /token-monitor slash command (which doesn't pipe stdin) still warns.
406
+ const stdinJson = readStdinJson();
407
+ let caps = extractCaps(stdinJson);
408
+ if (isStatusline && caps) {
409
+ try {
410
+ const { persistCaps } = await import('../src/caps-cache.js');
411
+ persistCaps(caps);
412
+ } catch {
413
+ // non-critical
414
+ }
415
+ }
416
+ if (!isStatusline && !caps) {
417
+ try {
418
+ const { loadRecentCaps } = await import('../src/caps-cache.js');
419
+ caps = loadRecentCaps();
420
+ } catch {
421
+ // ignore
422
+ }
423
+ }
424
+
321
425
  // For statusline: attach a single-word chip only when there's something
322
426
  // actionable right now. 1M context is always shown; otherwise only fire
323
427
  // if the most recent session actually appears in the spike list.
@@ -348,8 +452,14 @@ async function main() {
348
452
  // Persist transitions to ~/.config/claude-token-saver/history/YYYY-MM-DD.md
349
453
  // so /token-monitor and `claude-token-saver history` can replay them.
350
454
  try {
351
- const { recordChip } = await import('../src/history.js');
455
+ const { recordChip, recordCapTransition } = await import('../src/history.js');
352
456
  recordChip(spikeChip, { detail: chipDetail });
457
+ // Cap-warn transitions are tracked independently per window — a session
458
+ // can hit 90% on the 5h window even when no spike chip is firing.
459
+ if (caps) {
460
+ recordCapTransition('five_hour', caps.fiveHour);
461
+ recordCapTransition('seven_day', caps.sevenDay);
462
+ }
353
463
  } catch {
354
464
  // history is non-critical — don't let it break the statusline render
355
465
  }
@@ -387,6 +497,7 @@ async function main() {
387
497
  spikeReport,
388
498
  contextWindow,
389
499
  spikeChip,
500
+ caps,
390
501
  };
391
502
 
392
503
  let output;
@@ -412,11 +523,16 @@ async function main() {
412
523
  const colorOk =
413
524
  !hasFlag('--no-color') && !process.env.NO_COLOR && cfg.color;
414
525
 
526
+ const segmentsArg = getArg('--segments');
527
+ const segments = segmentsArg
528
+ ? segmentsArg.split(',').map((s) => s.trim()).filter(Boolean)
529
+ : null;
415
530
  output = formatReport(data, {
416
531
  color: colorOk,
417
532
  verbose: isVerbose,
418
533
  timer: showTimer,
419
534
  mode: isIcon ? 'icon' : 'text',
535
+ segments,
420
536
  });
421
537
  } else {
422
538
  const { formatReport } = await import('../src/formatters/table.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Save tokens on Claude Code — spike diagnosis, 1M-context detection, TTL countdown, statusline. (formerly claude-cache-monitor)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Caps cache — the rate-limit numbers only flow through stdin from Claude
3
+ * Code's statusline contract, but we want the table view (e.g. invoked by
4
+ * `/token-monitor`) to surface the same cap-warn box. So whenever the
5
+ * statusline path sees caps it writes them here, and the table path reads
6
+ * them back if its own stdin was empty.
7
+ *
8
+ * Stale data is worse than missing data — if the saved snapshot is older
9
+ * than `maxAgeMs` the loader returns null and the table view stays quiet.
10
+ */
11
+
12
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { userDataDir } from './paths.js';
15
+
16
+ const CACHE_PATH = join(userDataDir(), 'last-caps.json');
17
+
18
+ function ensureDir() {
19
+ const dir = userDataDir();
20
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
21
+ }
22
+
23
+ export function persistCaps(caps) {
24
+ if (!caps) return;
25
+ try {
26
+ ensureDir();
27
+ const payload = { capturedAt: Date.now(), caps };
28
+ writeFileSync(CACHE_PATH, JSON.stringify(payload) + '\n');
29
+ } catch {
30
+ // best-effort cache, never blocks the statusline
31
+ }
32
+ }
33
+
34
+ /**
35
+ * @param {object} [opts]
36
+ * @param {number} [opts.maxAgeMs=5*60*1000] - drop snapshots older than this.
37
+ * @returns {object|null}
38
+ */
39
+ export function loadRecentCaps({ maxAgeMs = 5 * 60 * 1000 } = {}) {
40
+ try {
41
+ if (!existsSync(CACHE_PATH)) return null;
42
+ const raw = readFileSync(CACHE_PATH, 'utf8');
43
+ const data = JSON.parse(raw);
44
+ if (!data || !data.caps) return null;
45
+ if (typeof data.capturedAt !== 'number') return null;
46
+ if (Date.now() - data.capturedAt > maxAgeMs) return null;
47
+ return data.caps;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
@@ -49,6 +49,39 @@ function formatTimer(remainingSec) {
49
49
  return `${m}:${String(s).padStart(2, '0')}`;
50
50
  }
51
51
 
52
+ /**
53
+ * Pick the cap-warn chip ({ kind, usedPct, resetsAt, label }) that should
54
+ * surface, or null if neither window is at 90%+. When both windows are warning,
55
+ * the one that resets sooner wins (it's the more imminent block).
56
+ */
57
+ export function pickCapWarn(caps) {
58
+ if (!caps) return null;
59
+ const candidates = [];
60
+ if (caps.fiveHour && caps.fiveHour.usedPct >= 90) {
61
+ candidates.push({
62
+ kind: 'five_hour',
63
+ label: '5H',
64
+ usedPct: caps.fiveHour.usedPct,
65
+ resetsAt: caps.fiveHour.resetsAt,
66
+ });
67
+ }
68
+ if (caps.sevenDay && caps.sevenDay.usedPct >= 90) {
69
+ candidates.push({
70
+ kind: 'seven_day',
71
+ label: '7D',
72
+ usedPct: caps.sevenDay.usedPct,
73
+ resetsAt: caps.sevenDay.resetsAt,
74
+ });
75
+ }
76
+ if (candidates.length === 0) return null;
77
+ candidates.sort((a, b) => {
78
+ const ar = Number.isFinite(a.resetsAt) ? a.resetsAt : Infinity;
79
+ const br = Number.isFinite(b.resetsAt) ? b.resetsAt : Infinity;
80
+ return ar - br;
81
+ });
82
+ return candidates[0];
83
+ }
84
+
52
85
  /**
53
86
  * @param {object} data - output of main report pipeline (summary, ttl, cost, options, lastActivity)
54
87
  * @param {object} [opts]
@@ -56,9 +89,10 @@ function formatTimer(remainingSec) {
56
89
  * @param {boolean} [opts.verbose=false] - longer layout with labels
57
90
  * @param {boolean} [opts.timer=true] - show TTL countdown segment
58
91
  * @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses 🧠 ⏳ 💰 instead of word labels.
92
+ * @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, hit, ttl, saved, ctx, period. Null/undefined = all.
59
93
  */
60
- export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text' } = {}) {
61
- const { summary, ttl, cost, options, lastActivity, contextWindow, spikeChip } = data;
94
+ export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null } = {}) {
95
+ const { summary, ttl, cost, options, lastActivity, contextWindow, spikeChip, caps } = data;
62
96
  const { hitRate } = summary;
63
97
 
64
98
  // Hit rate → color signal
@@ -170,12 +204,40 @@ export function formatReport(data, { color = true, verbose = false, timer = true
170
204
  // Spike chip — one word only, keeps the statusline single-line.
171
205
  const spikeSeg = spikeChip ? `${c(RED)}${spikeChip}${c(RESET)}` : null;
172
206
 
207
+ // Cap-warn chip — leads everything when ANY rate-limit window is at 90%+.
208
+ // It's the most actionable signal we can show: no point optimizing cache
209
+ // hits if you're about to be rate-limited anyway. The chip body matches the
210
+ // English shape `🚨 5H 94%` / `🚨 7D 92%` so history parsers can dedupe on it.
211
+ const capWarn = pickCapWarn(caps);
212
+ let capWarnSeg = null;
213
+ if (capWarn) {
214
+ const pct = Math.round(capWarn.usedPct);
215
+ if (isIcon && verbose) {
216
+ capWarnSeg = `${c(BOLD)}${c(RED)}🚨 ${capWarn.label} cap ${pct}%${c(RESET)}`;
217
+ } else if (isIcon) {
218
+ capWarnSeg = `${c(BOLD)}${c(RED)}🚨 ${capWarn.label} ${pct}%${c(RESET)}`;
219
+ } else if (verbose) {
220
+ capWarnSeg = `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${c(RESET)}`;
221
+ } else {
222
+ capWarnSeg = `${c(BOLD)}${c(RED)}${capWarn.label} ${pct}%${c(RESET)}`;
223
+ }
224
+ }
225
+
173
226
  // Warning chip leads — a glance at the statusline catches "something's wrong"
174
227
  // before parsing any numbers. Healthy states have no chip and look unchanged.
228
+ // Cap-warn outranks spike: an imminent rate-limit block is more urgent than
229
+ // a single spiking session.
230
+ const allow = segments && segments.length
231
+ ? new Set(segments.map((s) => s.toLowerCase()))
232
+ : null;
233
+ const want = (name) => !allow || allow.has(name);
175
234
  const segs = [];
176
- if (spikeSeg) segs.push(spikeSeg);
177
- segs.push(hitSeg, ttlSeg, saveSeg);
178
- if (ctxSeg) segs.push(ctxSeg);
179
- segs.push(periodSeg);
235
+ if (capWarnSeg && want('cap-warn')) segs.push(capWarnSeg);
236
+ if (spikeSeg && want('spike')) segs.push(spikeSeg);
237
+ if (want('hit')) segs.push(hitSeg);
238
+ if (want('ttl')) segs.push(ttlSeg);
239
+ if (want('saved')) segs.push(saveSeg);
240
+ if (ctxSeg && want('ctx')) segs.push(ctxSeg);
241
+ if (want('period')) segs.push(periodSeg);
180
242
  return segs.join(' · ');
181
243
  }
@@ -123,7 +123,42 @@ function renderSpikeSection(spikes, contextWindow) {
123
123
  return lines;
124
124
  }
125
125
 
126
- export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow }) {
126
+ function formatResetIn(resetsAt, now = new Date()) {
127
+ if (!Number.isFinite(resetsAt)) return null;
128
+ const remaining = Math.max(0, resetsAt - Math.floor(now.getTime() / 1000));
129
+ if (remaining <= 0) return '0m';
130
+ const h = Math.floor(remaining / 3600);
131
+ const m = Math.floor((remaining % 3600) / 60);
132
+ if (h > 0) return `${h}h ${m}m`;
133
+ return `${m}m`;
134
+ }
135
+
136
+ function renderCapWarnSection(caps) {
137
+ const warning = [];
138
+ if (caps.fiveHour && caps.fiveHour.usedPct >= 90) {
139
+ warning.push({ label: '5-hour window', info: caps.fiveHour });
140
+ }
141
+ if (caps.sevenDay && caps.sevenDay.usedPct >= 90) {
142
+ warning.push({ label: '7-day window', info: caps.sevenDay });
143
+ }
144
+ if (warning.length === 0) return [];
145
+ const lines = [];
146
+ lines.push(' 🚨 Rate-limit cap is closing in');
147
+ lines.push(` ${'─'.repeat(50)}`);
148
+ for (const { label, info } of warning) {
149
+ const reset = formatResetIn(info.resetsAt);
150
+ const tail = reset ? `, resets in ${reset}` : '';
151
+ lines.push(` • ${label}: ${Math.round(info.usedPct)}% used${tail}`);
152
+ }
153
+ lines.push('');
154
+ lines.push(' Back up work before the cap hits:');
155
+ lines.push(' claude-token-saver handoff');
156
+ lines.push(' (writes a HANDOFF-*.md so a fresh session can pick up.)');
157
+ lines.push('');
158
+ return lines;
159
+ }
160
+
161
+ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow, caps }) {
127
162
  const lines = [];
128
163
 
129
164
  // Header
@@ -133,7 +168,12 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
133
168
  lines.push(` ${'═'.repeat(50)}`);
134
169
  lines.push('');
135
170
 
136
- // Spike section goes FIRST — it's what the user acts on.
171
+ // Cap warning leads — it's the most time-sensitive signal we can show.
172
+ if (caps) {
173
+ lines.push(...renderCapWarnSection(caps));
174
+ }
175
+
176
+ // Spike section goes next — it's what the user acts on.
137
177
  if (spikeReport && spikeReport.spikes.length > 0) {
138
178
  lines.push(...renderSpikeSection(spikeReport.spikes, contextWindow));
139
179
  }
package/src/handoff.js ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Handoff template — captures enough state at session-cap time that a fresh
3
+ * Claude Code session can pick up the work without a long prelude.
4
+ *
5
+ * Output: `./HANDOFF-YYYY-MM-DD-HHMM.md` in the caller's cwd. We never
6
+ * overwrite — if the path is taken we add a `-N` suffix.
7
+ *
8
+ * What goes in:
9
+ * - Header: timestamp, cwd, git branch / HEAD / dirty file list
10
+ * - Cap snapshot: 5h/7d % and resets-in (when known)
11
+ * - Empty fillable sections the user pastes context into
12
+ * - A one-line resume prompt for the next session
13
+ */
14
+
15
+ import { writeFileSync, existsSync } from 'node:fs';
16
+ import { execSync } from 'node:child_process';
17
+ import { join, resolve } from 'node:path';
18
+
19
+ function pad(n) {
20
+ return String(n).padStart(2, '0');
21
+ }
22
+
23
+ function ymd(d = new Date()) {
24
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
25
+ }
26
+
27
+ function hhmm(d = new Date()) {
28
+ return `${pad(d.getHours())}${pad(d.getMinutes())}`;
29
+ }
30
+
31
+ function safeGit(cmd, cwd) {
32
+ try {
33
+ return execSync(`git ${cmd}`, {
34
+ cwd,
35
+ encoding: 'utf8',
36
+ stdio: ['ignore', 'pipe', 'ignore'],
37
+ }).trim();
38
+ } catch {
39
+ return '';
40
+ }
41
+ }
42
+
43
+ function gitSnapshot(cwd) {
44
+ // `rev-parse --git-dir` succeeds in any repo, including a freshly-init'd one
45
+ // with no commits yet (where `rev-parse HEAD` would fail). We use it as the
46
+ // "is this a repo?" probe.
47
+ const gitDir = safeGit('rev-parse --git-dir', cwd);
48
+ if (!gitDir) return null;
49
+ const branch = safeGit('rev-parse --abbrev-ref HEAD', cwd) || '(no commits)';
50
+ const head = safeGit('rev-parse --short HEAD', cwd);
51
+ const status = safeGit('status --short', cwd);
52
+ return { branch, head, status };
53
+ }
54
+
55
+ function formatResetIn(resetsAt, now = new Date()) {
56
+ if (!Number.isFinite(resetsAt)) return null;
57
+ const remaining = Math.max(0, resetsAt - Math.floor(now.getTime() / 1000));
58
+ if (remaining <= 0) return '0m';
59
+ const h = Math.floor(remaining / 3600);
60
+ const m = Math.floor((remaining % 3600) / 60);
61
+ if (h > 0) return `${h}h ${m}m`;
62
+ return `${m}m`;
63
+ }
64
+
65
+ function pickPath(cwd, now) {
66
+ const stem = `HANDOFF-${ymd(now)}-${hhmm(now)}`;
67
+ const direct = join(cwd, `${stem}.md`);
68
+ if (!existsSync(direct)) return direct;
69
+ for (let i = 2; i < 100; i++) {
70
+ const candidate = join(cwd, `${stem}-${i}.md`);
71
+ if (!existsSync(candidate)) return candidate;
72
+ }
73
+ return join(cwd, `${stem}-${Date.now()}.md`);
74
+ }
75
+
76
+ function renderTemplate({ now, cwd, git, caps }) {
77
+ const lines = [];
78
+ lines.push(`# Handoff — ${ymd(now)} ${pad(now.getHours())}:${pad(now.getMinutes())}`);
79
+ lines.push('');
80
+ lines.push(`Generated by \`claude-token-saver handoff\`.`);
81
+ lines.push('');
82
+ lines.push('## Context');
83
+ lines.push('');
84
+ lines.push(`- cwd: \`${cwd}\``);
85
+ if (git) {
86
+ lines.push(`- git branch: \`${git.branch}\`${git.head ? ` @ \`${git.head}\`` : ''}`);
87
+ if (git.status) {
88
+ lines.push('- dirty files:');
89
+ lines.push(' ```');
90
+ for (const line of git.status.split('\n')) lines.push(` ${line}`);
91
+ lines.push(' ```');
92
+ } else {
93
+ lines.push('- working tree: clean');
94
+ }
95
+ } else {
96
+ lines.push('- git: (not a repo)');
97
+ }
98
+ lines.push('');
99
+
100
+ lines.push('## Cap snapshot');
101
+ lines.push('');
102
+ if (caps) {
103
+ const fmtRow = (label, info) => {
104
+ if (!info) return `- ${label}: (unknown — stdin had no rate-limit info)`;
105
+ const reset = formatResetIn(info.resetsAt, now);
106
+ const tail = reset ? `, resets in ${reset}` : '';
107
+ return `- ${label}: ${Math.round(info.usedPct)}%${tail}`;
108
+ };
109
+ lines.push(fmtRow('5-hour window', caps.fiveHour));
110
+ lines.push(fmtRow('7-day window', caps.sevenDay));
111
+ } else {
112
+ lines.push('- (no cap data — run `handoff` from a Claude Code session for live numbers)');
113
+ }
114
+ lines.push('');
115
+
116
+ lines.push('## What I just did');
117
+ lines.push('');
118
+ lines.push('- _(fill in: 1–3 bullets describing the most recent work)_');
119
+ lines.push('');
120
+
121
+ lines.push('## What\'s left (TODO)');
122
+ lines.push('');
123
+ lines.push('- [ ] _(fill in)_');
124
+ lines.push('');
125
+
126
+ lines.push('## Where to pick up next');
127
+ lines.push('');
128
+ lines.push('- _(file paths, function names, the exact next step)_');
129
+ lines.push('');
130
+
131
+ lines.push('## Watch out for');
132
+ lines.push('');
133
+ lines.push('- _(non-obvious gotchas, half-finished refactors, failing tests)_');
134
+ lines.push('');
135
+
136
+ lines.push('## Resume prompt for the next Claude Code session');
137
+ lines.push('');
138
+ lines.push('```');
139
+ lines.push('Read the most recent HANDOFF-*.md in this directory and continue the work.');
140
+ lines.push('```');
141
+ lines.push('');
142
+
143
+ return lines.join('\n') + '\n';
144
+ }
145
+
146
+ /**
147
+ * Write a handoff file in the given cwd.
148
+ *
149
+ * @param {object} [opts]
150
+ * @param {string} [opts.cwd=process.cwd()]
151
+ * @param {object|null} [opts.caps] - { fiveHour, sevenDay } from extractCaps
152
+ * @param {Date} [opts.now=new Date()]
153
+ * @returns {{ path: string, git: { branch: string, head: string, status: string } | null }}
154
+ */
155
+ export function writeHandoff({ cwd = process.cwd(), caps = null, now = new Date() } = {}) {
156
+ const absCwd = resolve(cwd);
157
+ const git = gitSnapshot(absCwd);
158
+ const path = pickPath(absCwd, now);
159
+ const body = renderTemplate({ now, cwd: absCwd, git, caps });
160
+ writeFileSync(path, body);
161
+ return { path, git };
162
+ }
package/src/history.js CHANGED
@@ -3,6 +3,11 @@
3
3
  * (none → warning, warning A → warning B, warning → none). One markdown file
4
4
  * per calendar day so users can pinpoint "when did this start" easily.
5
5
  *
6
+ * Each event is written bilingually: the canonical English line first, the
7
+ * Korean translation as an indented "└" continuation right below. The chip
8
+ * text itself stays as-is (its symbol+English is part of the UX surface), but
9
+ * the diagnostic detail and resolved-status verbs are translated.
10
+ *
6
11
  * Storage path is platform-aware (see paths.userDataDir):
7
12
  * Windows: %APPDATA%\claude-token-saver\history\YYYY-MM-DD.md
8
13
  * macOS: ~/Library/Application Support/claude-token-saver/history/YYYY-MM-DD.md
@@ -53,14 +58,70 @@ function saveState(state) {
53
58
  writeFileSync(STATE_PATH, JSON.stringify(state) + '\n');
54
59
  }
55
60
 
56
- function appendDayLine(line, date = new Date()) {
61
+ /**
62
+ * Map a chip's English label to its Korean equivalent.
63
+ * Returns the input unchanged if no mapping is registered (forward-compat
64
+ * with chips added in advice.js after this map was last updated).
65
+ */
66
+ function chipKo(chip) {
67
+ if (!chip) return chip;
68
+ const map = {
69
+ '⚠ 1M ON': '⚠ 1M 컨텍스트 활성',
70
+ '⚠ Cache miss': '⚠ 캐시 미스',
71
+ '⚠ Rebuild churn': '⚠ 캐시 재빌드 빈발',
72
+ '⚠ Input spike': '⚠ 입력 급증',
73
+ '⚠ Output heavy': '⚠ 출력 과다',
74
+ '⚠ Call surge': '⚠ 호출 급증',
75
+ '⚠ 5m TTL': '⚠ 5분 TTL',
76
+ '⏳ Cache expires': '⏳ 캐시 만료 임박',
77
+ '💰 Cache saved': '💰 캐시 절약',
78
+ '🧠 Cache hit': '🧠 캐시 적중',
79
+ };
80
+ return map[chip] || chip;
81
+ }
82
+
83
+ /**
84
+ * Translate the diagnostic detail string to Korean. The detail is constructed
85
+ * in cli.js and follows two stable shapes:
86
+ * "Context auto-promoted to 1M (max single-request {N}k tokens)"
87
+ * "session {ID}: CODE_A, CODE_B"
88
+ * Anything else falls through unchanged.
89
+ */
90
+ function detailKo(detail) {
91
+ if (!detail) return detail;
92
+ const m1 = detail.match(/^Context auto-promoted to 1M \(max single-request (\d+)k tokens\)$/);
93
+ if (m1) return `1M 컨텍스트 자동 활성 (단일 요청 최대 ${m1[1]}k 토큰)`;
94
+ const m2 = detail.match(/^session ([^:]+): (.+)$/);
95
+ if (m2) {
96
+ const codeKo = {
97
+ LOW_HIT_RATE: '캐시 적중률 낮음',
98
+ FREQUENT_CACHE_REBUILD: '캐시 재빌드 빈발',
99
+ OUTPUT_HEAVY: '출력 과다',
100
+ INPUT_SPIKE: '입력 급증',
101
+ CALL_SURGE: '호출 급증',
102
+ TTL_5M: '5분 TTL',
103
+ };
104
+ const codes = m2[2]
105
+ .split(',')
106
+ .map((c) => c.trim())
107
+ .map((c) => codeKo[c] || c)
108
+ .join(', ');
109
+ return `세션 ${m2[1]}: ${codes}`;
110
+ }
111
+ return detail;
112
+ }
113
+
114
+ function appendDayLine(en, ko, date = new Date()) {
57
115
  ensureDir(HISTORY_DIR);
58
116
  const path = join(HISTORY_DIR, `${ymd(date)}.md`);
117
+ const block = ko && ko !== en ? `${en}\n └ ${ko}\n` : `${en}\n`;
59
118
  if (!existsSync(path)) {
60
- writeFileSync(path, `# Token Monitor — ${ymd(date)}\n\n## Events\n${line}\n`);
119
+ const header = `# Token Monitor / 토큰 모니터 — ${ymd(date)}\n\n## Events / 이벤트\n`;
120
+ writeFileSync(path, header + block);
61
121
  } else {
62
122
  const existing = readFileSync(path, 'utf8');
63
- writeFileSync(path, existing.endsWith('\n') ? existing + line + '\n' : existing + '\n' + line + '\n');
123
+ const sep = existing.endsWith('\n') ? '' : '\n';
124
+ writeFileSync(path, existing + sep + block);
64
125
  }
65
126
  }
66
127
 
@@ -77,16 +138,23 @@ export function recordChip(chip, contextHints = {}) {
77
138
 
78
139
  if (current === last) return false;
79
140
 
80
- let line;
141
+ const detail = contextHints.detail || null;
142
+ const detailEn = detail ? ` — ${detail}` : '';
143
+ const detailKr = detail ? ` — ${detailKo(detail)}` : '';
144
+
145
+ let en, ko;
81
146
  if (current && !last) {
82
- line = `- ${hms(now)} ${current}` + (contextHints.detail ? ` — ${contextHints.detail}` : '');
147
+ en = `- ${hms(now)} ${current}${detailEn}`;
148
+ ko = `${chipKo(current)}${detailKr}`;
83
149
  } else if (current && last) {
84
- line = `- ${hms(now)} ${last} → ${current}` + (contextHints.detail ? ` — ${contextHints.detail}` : '');
150
+ en = `- ${hms(now)} ${last} → ${current}${detailEn}`;
151
+ ko = `${chipKo(last)} → ${chipKo(current)}${detailKr}`;
85
152
  } else {
86
153
  // current === null, last was something — warning resolved
87
- line = `- ${hms(now)} ✓ resolved (was ${last})`;
154
+ en = `- ${hms(now)} ✓ resolved (was ${last})`;
155
+ ko = `✓ 해소됨 (이전: ${chipKo(last)})`;
88
156
  }
89
- appendDayLine(line, now);
157
+ appendDayLine(en, ko, now);
90
158
  saveState({ chip: current, ts: now.toISOString() });
91
159
  return true;
92
160
  }
@@ -110,6 +178,73 @@ export function readRecent(days = 7) {
110
178
  return out;
111
179
  }
112
180
 
181
+ /**
182
+ * Format "resets in Hh Mm" / "Mm" given a Unix-epoch resets_at value.
183
+ * Returns null when the input isn't a finite number.
184
+ */
185
+ function formatResetIn(resetsAt, now = new Date()) {
186
+ if (!Number.isFinite(resetsAt)) return null;
187
+ const remainingSec = Math.max(0, resetsAt - Math.floor(now.getTime() / 1000));
188
+ if (remainingSec <= 0) return '0m';
189
+ const h = Math.floor(remainingSec / 3600);
190
+ const m = Math.floor((remainingSec % 3600) / 60);
191
+ if (h > 0) return `${h}h ${m}m`;
192
+ return `${m}m`;
193
+ }
194
+
195
+ /**
196
+ * Record entering or exiting the cap-warn (>=90%) zone for a rate-limit
197
+ * window. Each window (`five_hour`, `seven_day`) has its own dedup slot, so
198
+ * the daily file gets two transitions max per window per warning episode.
199
+ *
200
+ * @param {'five_hour'|'seven_day'} kind
201
+ * @param {{ usedPct: number, resetsAt: number|null } | null} info
202
+ * @returns {boolean} true when a line was appended
203
+ */
204
+ export function recordCapTransition(kind, info) {
205
+ const state = loadState();
206
+ const slotKey = `cap_${kind}`;
207
+ const wasWarn = !!state[slotKey];
208
+ const isWarn = !!(info && Number.isFinite(info.usedPct) && info.usedPct >= 90);
209
+ if (wasWarn === isWarn) return false;
210
+
211
+ const now = new Date();
212
+ const labelEn = kind === 'five_hour' ? '5H' : '7D';
213
+ const labelKo = kind === 'five_hour' ? '5시간 윈도' : '7일 윈도';
214
+ let en;
215
+ let ko;
216
+ if (isWarn) {
217
+ const pct = Math.round(info.usedPct);
218
+ const reset = formatResetIn(info.resetsAt, now);
219
+ const tail = reset ? ` (resets in ${reset})` : '';
220
+ const tailKo = reset ? ` (리셋까지 ${reset})` : '';
221
+ en = `- ${hms(now)} 🚨 ${labelEn} ${pct}% cap warning${tail}`;
222
+ ko = `🚨 ${labelKo} ${pct}% 캡 경고${tailKo}`;
223
+ } else {
224
+ en = `- ${hms(now)} ✓ ${labelEn} cap warning resolved`;
225
+ ko = `✓ ${labelKo} 캡 경고 해소`;
226
+ }
227
+ appendDayLine(en, ko, now);
228
+ state[slotKey] = isWarn;
229
+ saveState(state);
230
+ return true;
231
+ }
232
+
233
+ /**
234
+ * Record a handoff write — invoked by the `handoff` subcommand so
235
+ * `claude-token-saver history` shows when work was backed up to a HANDOFF file.
236
+ *
237
+ * @param {string} filePath
238
+ * @returns {boolean}
239
+ */
240
+ export function recordHandoff(filePath) {
241
+ const now = new Date();
242
+ const en = `- ${hms(now)} 📝 handoff written: ${filePath}`;
243
+ const ko = `📝 핸드오프 백업 작성: ${filePath}`;
244
+ appendDayLine(en, ko, now);
245
+ return true;
246
+ }
247
+
113
248
  /**
114
249
  * List all available history file dates (sorted newest first).
115
250
  */
package/src/installer.js CHANGED
@@ -15,7 +15,7 @@ import { claudeUserDir } from './paths.js';
15
15
 
16
16
  const SKILL_BODY = `---
17
17
  name: claude-token-saver
18
- description: Use when the user mentions Claude Code token usage, prompt cache hit rate, TTL/expiry, the 1M context window, cache misses, output spikes, or anything in the statusline produced by claude-token-saver (chips like "⚠ 1M ON", "⚠ Input spike", "⚠ Cache miss", "⚠ 5m TTL", "⚠ Rebuild churn", "⚠ Output heavy", "⚠ Call surge", "⏳ Cache expires", "💰 Cache saved", "🧠 Cache hit"). Also use when they ask to view token-usage history or want to understand a warning they just saw.
18
+ description: Use when the user mentions Claude Code token usage, prompt cache hit rate, TTL/expiry, the 1M context window, cache misses, output spikes, rate-limit caps (5h/7d), or anything in the statusline produced by claude-token-saver (chips like "🚨 5H 94%", "🚨 7D 92%", "⚠ 1M ON", "⚠ Input spike", "⚠ Cache miss", "⚠ 5m TTL", "⚠ Rebuild churn", "⚠ Output heavy", "⚠ Call surge", "⏳ Cache expires", "💰 Cache saved", "🧠 Cache hit"). Also use when they ask to view token-usage history, want to understand a warning they just saw, or want to back up work before a session cap with \`claude-token-saver handoff\`.
19
19
  ---
20
20
 
21
21
  # claude-token-saver — Claude Code Token Monitor
@@ -26,11 +26,14 @@ countdown, savings, and (when relevant) a leading warning chip.
26
26
 
27
27
  ## When this skill should activate
28
28
 
29
- - The user references any chip wording: \`⚠ 1M ON\`, \`⚠ Input spike\`,
30
- \`⚠ Cache miss\`, \`⚠ 5m TTL\`, \`⚠ Rebuild churn\`, \`⚠ Output heavy\`,
31
- \`⚠ Call surge\`.
29
+ - The user references any chip wording: \`🚨 5H NN%\`, \`🚨 7D NN%\`,
30
+ \`⚠ 1M ON\`, \`⚠ Input spike\`, \`⚠ Cache miss\`, \`⚠ 5m TTL\`,
31
+ \`⚠ Rebuild churn\`, \`⚠ Output heavy\`, \`⚠ Call surge\`.
32
32
  - The user asks "why is my cache hit rate low", "what does this warning mean",
33
33
  "when did this start happening", or similar.
34
+ - The user is approaching a rate-limit cap and wants to back up the current
35
+ work so a fresh session can continue (point them at
36
+ \`claude-token-saver handoff\`).
34
37
  - The user wants to see the token-usage history file or asks for a summary
35
38
  of recent warnings.
36
39
 
@@ -48,6 +51,8 @@ countdown, savings, and (when relevant) a leading warning chip.
48
51
 
49
52
  | Chip | Likely cause |
50
53
  | ------------------ | ----------------------------------------------------- |
54
+ | \`🚨 5H NN%\` | 5-hour rate-limit window at NN% (>=90%). Cap is imminent. |
55
+ | \`🚨 7D NN%\` | 7-day rate-limit window at NN% (>=90%). Pace yourself. |
51
56
  | \`⚠ 1M ON\` | Auto-promoted to 1M context (Opus 4.7+ Max default). |
52
57
  | \`⚠ Input spike\` | One request consumed >250k or >3× the recent p95. |
53
58
  | \`⚠ Cache miss\` | Cache hit rate dropped below ~70%. |
@@ -56,10 +61,12 @@ countdown, savings, and (when relevant) a leading warning chip.
56
61
  | \`⚠ Output heavy\` | Output ratio dominates input — inspect long generations. |
57
62
  | \`⚠ Call surge\` | Request count is well above baseline. |
58
63
 
59
- 5. **Suggest the next action.** For 1M ON, mention
60
- \`CLAUDE_CODE_DISABLE_1M_CONTEXT=1\`. For 5m TTL, point at the Max plan's
61
- 1h bucket. For input spike, suggest splitting the conversation or
62
- compacting context.
64
+ 5. **Suggest the next action.** For \`🚨 5H/7D\` chips, recommend running
65
+ \`claude-token-saver handoff\` to back up the current work to a
66
+ \`HANDOFF-*.md\` file before the cap hits, then continue in a fresh
67
+ session. For 1M ON, mention \`CLAUDE_CODE_DISABLE_1M_CONTEXT=1\`. For
68
+ 5m TTL, point at the Max plan's 1h bucket. For input spike, suggest
69
+ splitting the conversation or compacting context.
63
70
 
64
71
  ## Useful commands
65
72
 
@@ -67,6 +74,8 @@ countdown, savings, and (when relevant) a leading warning chip.
67
74
  - \`claude-token-saver --days 7\` — wider window.
68
75
  - \`claude-token-saver history\` — recent warning transitions per day.
69
76
  - \`claude-token-saver history --days 30\` — longer history.
77
+ - \`claude-token-saver handoff\` — write a HANDOFF-*.md template in cwd
78
+ capturing git status + cap snapshot, so a fresh session can resume cleanly.
70
79
  - \`claude-token-saver mode\` — show statusline preferences.
71
80
  - \`claude-token-saver mode icon verbose 1d\` — change preferences.
72
81
 
@@ -90,18 +99,23 @@ quick read of their Claude Code token usage and any active warnings.
90
99
  Steps:
91
100
 
92
101
  1. Run \`claude-token-saver history --days 7\` and capture the output. This
93
- prints recent warning transitions (timestamps + chip + short detail).
102
+ prints recent warning transitions (timestamps + chip + short detail),
103
+ including any \`🚨 5H NN%\` / \`🚨 7D NN%\` cap-warn entries and any
104
+ \`📝 handoff written: ...\` events.
94
105
  2. Run \`claude-token-saver --days 1\` and capture the output. This prints the
95
106
  full table view: TTL breakdown, cost impact, daily trend, and any active
96
- spikes with recommended actions.
107
+ spikes with recommended actions. When a rate-limit cap is at >=90% the
108
+ table leads with a "🚨 Rate-limit cap is closing in" section.
97
109
  3. Summarize for the user:
98
110
  - **Active warnings** — list the most recent unresolved chip(s) with the
99
- time they appeared.
111
+ time they appeared. Cap-warn (\`🚨 5H/7D NN%\`) outranks everything else.
100
112
  - **Today's pattern** — when warnings cluster in time, mention it.
101
- - **Recommended action** — pick the highest-leverage suggestion from the
102
- table report's "Recommended actions" section.
113
+ - **Recommended action** — for cap-warn, point at \`claude-token-saver
114
+ handoff\` so the user can back up state before the cap blocks them.
115
+ Otherwise pick the highest-leverage suggestion from the table report's
116
+ "Recommended actions" section.
103
117
  4. If the history is empty, say so plainly — no warnings means the cache has
104
- been healthy in the configured window.
118
+ been healthy and no caps were close in the configured window.
105
119
 
106
120
  Keep the summary to ~10 lines. The user can re-run the underlying commands
107
121
  themselves for the full output.
@@ -1,52 +0,0 @@
1
- #!/bin/sh
2
- # Combine rz1989s/claude-code-statusline (rich layout: repo, cost, MCP, prayer times)
3
- # with claude-token-saver (cache hit rate, TTL countdown, 1M-context detection,
4
- # spike diagnosis). The two projects don't overlap — rz1989s runs first, our
5
- # cache chip is appended as the final segment.
6
- #
7
- # Install:
8
- # 1) Follow rz1989s install instructions so bash ~/.claude/statusline.sh works:
9
- # https://github.com/rz1989s/claude-code-statusline
10
- # 2) npm install -g claude-token-saver (or rely on npx — fallback below)
11
- # 3) Save this file as: ~/.claude/statusline-with-rz1989s.sh
12
- # chmod +x ~/.claude/statusline-with-rz1989s.sh
13
- # 4) In ~/.claude/settings.json:
14
- # {
15
- # "statusLine": {
16
- # "type": "command",
17
- # "command": "bash ~/.claude/statusline-with-rz1989s.sh",
18
- # "refreshInterval": 1
19
- # }
20
- # }
21
- #
22
- # refreshInterval: 1 keeps our TTL countdown ticking while you're idle.
23
- # Drop to 2 or 5 for lower local CPU if your rz1989s config does heavy work.
24
-
25
- # Claude Code sends the session JSON on stdin. Both tools want to read it,
26
- # so we buffer it and tee to each.
27
- input=$(cat)
28
-
29
- # --- 1) rz1989s layout (if installed) ---
30
- RZ_STATUSLINE="${CLAUDE_RZ_STATUSLINE:-$HOME/.claude/statusline.sh}"
31
- if [ -f "$RZ_STATUSLINE" ]; then
32
- printf '%s' "$input" | bash "$RZ_STATUSLINE"
33
- # Separator between the two tools. Dim pipe.
34
- printf ' \033[90m|\033[00m '
35
- fi
36
-
37
- # --- 2) claude-token-saver ---
38
- # Pass --exclude-session so the current session's tool calls don't reset the
39
- # TTL countdown. The path comes from the session JSON if present.
40
- session_path=$(printf '%s' "$input" | sed -n 's/.*"path"[[:space:]]*:[[:space:]]*"\([^"]*\.jsonl\)".*/\1/p' | head -n1)
41
- exclude_flag=""
42
- if [ -n "$session_path" ]; then
43
- exclude_flag="--exclude-session $session_path"
44
- fi
45
-
46
- if command -v claude-token-saver >/dev/null 2>&1; then
47
- # shellcheck disable=SC2086
48
- claude-token-saver --statusline --icon $exclude_flag 2>/dev/null || true
49
- else
50
- # shellcheck disable=SC2086
51
- npx --yes claude-token-saver@latest --statusline --icon $exclude_flag 2>/dev/null || true
52
- fi