claude-token-saver 2.0.3 → 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 +81 -23
- package/bin/cli.js +340 -11
- package/package.json +1 -1
- package/src/advice.js +48 -41
- package/src/caps-cache.js +51 -0
- package/src/config.js +151 -0
- package/src/demo.js +251 -0
- package/src/formatters/statusline.js +102 -23
- package/src/formatters/table.js +51 -11
- package/src/handoff.js +162 -0
- package/src/history.js +258 -0
- package/src/installer.js +150 -0
- package/src/paths.js +41 -0
- package/examples/statusline-with-rz1989s.sh +0 -52
package/README.md
CHANGED
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
|
|
10
10
|
**Save tokens on Claude Code.** Catch the session that suddenly burned 10× your usual input, figure out *why*, and get a one-line remediation you can paste.
|
|
11
11
|
|
|
12
|
+
**Why I built this.** I'm on the Max plan. On Opus 4.6 I never hit the *current-session* cap. After Opus 4.7 rolled out, I started hitting it on the same workflow — repeatedly. The official token statistics didn't match what I was actually feeling, and Claude Code's UI doesn't show prompt-cache health. This tool is what let me see *why*: low cache hit rate, 5m TTL writes that should have been 1h, 1M context auto-promoted in the background.
|
|
13
|
+
|
|
14
|
+
v2.1 (2026-04) adds the workflow that follows the diagnosis:
|
|
15
|
+
- **`claude-token-saver install`** — one command writes a Claude Code Skill (auto-activates when you mention "cache hit rate" / "1M context" / etc.) and a `/token-monitor` slash command.
|
|
16
|
+
- **`claude-token-saver history`** — every warning chip transition is auto-logged to a daily Markdown file, so you can answer "when did this start" without grepping logs.
|
|
17
|
+
- **Cross-platform paths** — Windows (`%APPDATA%`), macOS (`~/Library/Application Support`), Linux (`~/.config` / XDG) all handled.
|
|
18
|
+
|
|
12
19
|
v1.5 adds three things on top of the original `claude-cache-monitor`:
|
|
13
20
|
- **Spike diagnosis** — detect recent sessions whose input tokens exploded vs. your own baseline, and name the cause (1M context, 5m TTL churn, cache rebuild, chatty output).
|
|
14
21
|
- **1M-context detection** — Opus 4.7+ auto-enables 1M context on Max plans, silently. This tool surfaces it on the statusline as `Ctx 1M` (red) vs. `Ctx 200k` (green), with the OS-specific command to turn it off.
|
|
@@ -16,7 +23,7 @@ v1.5 adds three things on top of the original `claude-cache-monitor`:
|
|
|
16
23
|
|
|
17
24
|
The original functionality still works: cache hit rate, TTL breakdown, cost impact vs. no-cache, TTL countdown timer, and Claude Code statusline integration.
|
|
18
25
|
|
|
19
|
-
**
|
|
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).
|
|
20
27
|
|
|
21
28
|
---
|
|
22
29
|
|
|
@@ -29,7 +36,7 @@ v1.5 신규:
|
|
|
29
36
|
|
|
30
37
|
기존 기능(캐시 히트율·TTL 분포·비용 절감·TTL 카운트다운·statusline)은 그대로 유지됩니다.
|
|
31
38
|
|
|
32
|
-
**단독 도구로도,
|
|
39
|
+
**단독 도구로도, Claude Code statusline 통합으로도 동작합니다.** `npx claude-token-saver` 한 줄로 진단 리포트만 보거나, 내장 statusline에 연결해 상시 표시할 수 있습니다. 자세한 용법은 [Two Ways to Use It](#two-ways-to-use-it) 참고.
|
|
33
40
|
|
|
34
41
|
## Quick Start
|
|
35
42
|
|
|
@@ -92,15 +99,12 @@ Issue codes detected:
|
|
|
92
99
|
|
|
93
100
|
Remediation commands are chosen from `process.platform` — macOS/Linux/WSL get `~/.zshrc` snippets, Windows gets `setx` and the PowerShell equivalent.
|
|
94
101
|
|
|
95
|
-
##
|
|
96
|
-
|
|
97
|
-
`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
|
|
98
103
|
|
|
99
104
|
| Mode | What you run | When to pick this |
|
|
100
105
|
|---|---|---|
|
|
101
106
|
| **1. Standalone CLI report** | `npx claude-token-saver` | One-off diagnosis. Prints the full report (spikes + cache + cost + trend). Zero setup. |
|
|
102
|
-
| **2.
|
|
103
|
-
| **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. |
|
|
104
108
|
|
|
105
109
|
Detail for each mode below.
|
|
106
110
|
|
|
@@ -186,22 +190,6 @@ Works best in **Windows Terminal** or **PowerShell 7+** (ANSI color + emoji). Cl
|
|
|
186
190
|
|
|
187
191
|
Same as Linux — install the package in your WSL Node.js and point to the POSIX sh script.
|
|
188
192
|
|
|
189
|
-
#### Combine with rz1989s/claude-code-statusline
|
|
190
|
-
|
|
191
|
-
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.
|
|
192
|
-
|
|
193
|
-
```json
|
|
194
|
-
{
|
|
195
|
-
"statusLine": {
|
|
196
|
-
"type": "command",
|
|
197
|
-
"command": "bash ~/.claude/statusline-with-rz1989s.sh",
|
|
198
|
-
"refreshInterval": 1
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
---
|
|
204
|
-
|
|
205
193
|
Claude Code calls this every ~300ms on events, plus once per `refreshInterval` second while idle. Colors are emitted when the terminal supports them:
|
|
206
194
|
|
|
207
195
|
- **Hit rate** — 🟢 ≥85% · 🟡 70–85% · 🔴 <70%
|
|
@@ -212,6 +200,76 @@ Claude Code calls this every ~300ms on events, plus once per `refreshInterval` s
|
|
|
212
200
|
|
|
213
201
|
Statusline mode uses the last 7 days by default (override with `--days N`) and never emits multi-line errors, so your statusline stays clean even when there's no session data yet.
|
|
214
202
|
|
|
203
|
+
## Claude Code integration (`install`) — new in v2.1
|
|
204
|
+
|
|
205
|
+
One command wires up everything else this README mentions:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
claude-token-saver install
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
This writes two files under your Claude user dir:
|
|
212
|
+
- `~/.claude/skills/claude-token-saver/SKILL.md` — auto-activates whenever you mention chip wording ("⚠ 1M ON", "cache miss", etc.) or ask about token usage. Claude Code will then know to read history, drill into the table report, and explain the warning.
|
|
213
|
+
- `~/.claude/commands/token-monitor.md` — adds a `/token-monitor` slash command that runs `claude-token-saver history` + a fresh report and summarizes both for you.
|
|
214
|
+
|
|
215
|
+
Re-run with `--force` to overwrite. Install only one piece with `install --skill` or `install --command`.
|
|
216
|
+
|
|
217
|
+
## Warning history (`history`) — new in v2.1
|
|
218
|
+
|
|
219
|
+
The statusline path auto-logs every chip transition (none → ⚠, ⚠ A → ⚠ B, ⚠ → resolved) to a daily Markdown file. Read it back with:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
claude-token-saver history # last 7 days
|
|
223
|
+
claude-token-saver history --days 30 # wider window
|
|
224
|
+
claude-token-saver history --list # just list available dates
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Sample output:
|
|
228
|
+
|
|
229
|
+
```
|
|
230
|
+
# Token Monitor — 2026-04-25
|
|
231
|
+
|
|
232
|
+
## Events
|
|
233
|
+
- 09:14:02 ⚠ 1M ON — Context auto-promoted to 1M (max single-request 280k tokens)
|
|
234
|
+
- 09:42:18 ⚠ 1M ON → ⚠ Cache miss — session abc12345: LOW_HIT_RATE
|
|
235
|
+
- 10:05:47 ✓ resolved (was ⚠ Cache miss)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Storage paths (cross-platform):
|
|
239
|
+
- **Windows**: `%APPDATA%\claude-token-saver\history\YYYY-MM-DD.md`
|
|
240
|
+
- **macOS**: `~/Library/Application Support/claude-token-saver/history/YYYY-MM-DD.md`
|
|
241
|
+
- **Linux**: `$XDG_CONFIG_HOME/claude-token-saver/history/YYYY-MM-DD.md` (or `~/.config/...`)
|
|
242
|
+
|
|
243
|
+
Each day's file is plain Markdown — open it in any editor. Transitions are deduped, so the 1Hz statusline refresh doesn't spam.
|
|
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
|
+
|
|
215
273
|
## Hook Setup
|
|
216
274
|
|
|
217
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
|
|
57
|
-
|
|
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) {
|
|
@@ -62,6 +109,141 @@ function hasFlag(name) {
|
|
|
62
109
|
}
|
|
63
110
|
|
|
64
111
|
async function main() {
|
|
112
|
+
// Subcommand: history — print recent warning transitions captured by the
|
|
113
|
+
// statusline. One markdown file per day, persisted under the platform-
|
|
114
|
+
// specific user-data dir.
|
|
115
|
+
// claude-token-saver history # last 7 days
|
|
116
|
+
// claude-token-saver history --days 30 # custom window
|
|
117
|
+
// claude-token-saver history --list # just list available dates
|
|
118
|
+
if (args[0] === 'history') {
|
|
119
|
+
const { readRecent, listDates, historyDir } = await import('../src/history.js');
|
|
120
|
+
if (hasFlag('--list')) {
|
|
121
|
+
const dates = listDates();
|
|
122
|
+
if (dates.length === 0) {
|
|
123
|
+
console.log(`No history yet. Files will appear under: ${historyDir()}`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
console.log(`History (${historyDir()}):`);
|
|
127
|
+
for (const d of dates) console.log(` ${d}`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const days = parseFloat(getArg('--days') || '7');
|
|
131
|
+
const recent = readRecent(days);
|
|
132
|
+
if (recent.length === 0) {
|
|
133
|
+
console.log(`No warning history in the last ${days} day${days === 1 ? '' : 's'}.`);
|
|
134
|
+
console.log(`(Files would be written to: ${historyDir()})`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
for (const { content } of recent) {
|
|
138
|
+
console.log(content.replace(/\n+$/, ''));
|
|
139
|
+
console.log('');
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
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
|
+
|
|
172
|
+
// Subcommand: install — write the Claude Code Skill and slash command so
|
|
173
|
+
// /token-monitor and the auto-trigger skill become available without any
|
|
174
|
+
// manual file editing. Cross-platform (uses node:path + node:fs).
|
|
175
|
+
// claude-token-saver install # install both
|
|
176
|
+
// claude-token-saver install --skill # only the skill
|
|
177
|
+
// claude-token-saver install --command # only the slash command
|
|
178
|
+
// claude-token-saver install --force # overwrite existing files
|
|
179
|
+
if (args[0] === 'install') {
|
|
180
|
+
const { installSkill, installCommand, installAll } = await import('../src/installer.js');
|
|
181
|
+
const force = hasFlag('--force');
|
|
182
|
+
const onlySkill = hasFlag('--skill');
|
|
183
|
+
const onlyCommand = hasFlag('--command');
|
|
184
|
+
const print = (kind, r) => {
|
|
185
|
+
const verb = r.action === 'exists' ? 'already exists' : r.action;
|
|
186
|
+
console.log(` ${kind}: ${r.path} (${verb})`);
|
|
187
|
+
};
|
|
188
|
+
if (onlySkill && !onlyCommand) {
|
|
189
|
+
print('skill', installSkill({ force }));
|
|
190
|
+
} else if (onlyCommand && !onlySkill) {
|
|
191
|
+
print('command', installCommand({ force }));
|
|
192
|
+
} else {
|
|
193
|
+
const r = installAll({ force });
|
|
194
|
+
print('skill', r.skill);
|
|
195
|
+
print('command', r.command);
|
|
196
|
+
}
|
|
197
|
+
console.log('');
|
|
198
|
+
console.log('Open Claude Code in any directory and try:');
|
|
199
|
+
console.log(' /token-monitor');
|
|
200
|
+
console.log('Or just mention "cache hit rate" / "1M context" — the skill auto-activates.');
|
|
201
|
+
if (!force) {
|
|
202
|
+
console.log('');
|
|
203
|
+
console.log('Tip: re-run with --force to overwrite existing files.');
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Subcommand: mode — persist statusline preferences so future runs pick
|
|
209
|
+
// them up without flags or wrapper edits.
|
|
210
|
+
// claude-token-saver mode # show current config
|
|
211
|
+
// claude-token-saver mode icon verbose # set icon + verbose
|
|
212
|
+
// claude-token-saver mode reset # clear back to defaults
|
|
213
|
+
if (args[0] === 'mode') {
|
|
214
|
+
const { applyMode, loadConfig, configPath, statuslineDefaults, VALID_KEYWORDS } =
|
|
215
|
+
await import('../src/config.js');
|
|
216
|
+
const words = args.slice(1);
|
|
217
|
+
if (words.length === 0) {
|
|
218
|
+
const eff = statuslineDefaults();
|
|
219
|
+
const raw = loadConfig().statusline || {};
|
|
220
|
+
console.log('Statusline mode (effective):');
|
|
221
|
+
console.log(` icon: ${eff.icon}`);
|
|
222
|
+
console.log(` verbose: ${eff.verbose}`);
|
|
223
|
+
console.log(` timer: ${eff.timer}`);
|
|
224
|
+
console.log(` color: ${eff.color}`);
|
|
225
|
+
console.log(` window: ${eff.windowLabel} (${eff.windowHours}h)`);
|
|
226
|
+
console.log('');
|
|
227
|
+
console.log(`Stored config (${configPath()}):`);
|
|
228
|
+
console.log(` ${Object.keys(raw).length === 0 ? '(none — using defaults)' : JSON.stringify(raw)}`);
|
|
229
|
+
console.log('');
|
|
230
|
+
console.log('Change with: claude-token-saver mode <keywords...>');
|
|
231
|
+
console.log(`Keywords: ${VALID_KEYWORDS.join(', ')}`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const { applied, unknown } = applyMode(words);
|
|
235
|
+
if (unknown.length) {
|
|
236
|
+
console.error(`Unknown keyword${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`);
|
|
237
|
+
console.error(`Valid: ${VALID_KEYWORDS.join(', ')}`);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
const eff = statuslineDefaults();
|
|
241
|
+
console.log(`Updated: ${applied.join(', ')}`);
|
|
242
|
+
console.log(`Now: icon=${eff.icon} verbose=${eff.verbose} timer=${eff.timer} color=${eff.color} window=${eff.windowLabel}`);
|
|
243
|
+
console.log('Statusline picks up the change on the next refresh (~1s).');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
65
247
|
// Hook management
|
|
66
248
|
if (hasFlag('--install-hook')) {
|
|
67
249
|
const { installHook } = await import('../src/hook-manager.js');
|
|
@@ -85,10 +267,94 @@ async function main() {
|
|
|
85
267
|
// Statusline mode shortcut
|
|
86
268
|
const isStatusline = hasFlag('--statusline') || getArg('--format') === 'statusline';
|
|
87
269
|
|
|
270
|
+
// Demo mode — render synthetic warning-case data through the real
|
|
271
|
+
// formatter for screencasts/marketing GIFs. `--demo cycle` rotates through
|
|
272
|
+
// every scenario based on wall clock so a screen recorder picks them up.
|
|
273
|
+
const demoArg = getArg('--demo');
|
|
274
|
+
|
|
275
|
+
// `claude-token-saver --demo table` (no --statusline) — full table view
|
|
276
|
+
// with all six issue drill-downs at once, for marketing screencasts.
|
|
277
|
+
if (!isStatusline && demoArg === 'table') {
|
|
278
|
+
const { buildTableDemoData } = await import('../src/demo.js');
|
|
279
|
+
const { formatReport } = await import('../src/formatters/table.js');
|
|
280
|
+
const data = buildTableDemoData({ version: PKG_VERSION });
|
|
281
|
+
console.log(formatReport(data));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (isStatusline && demoArg) {
|
|
286
|
+
const { buildScenarioData, listScenarios } = await import('../src/demo.js');
|
|
287
|
+
const { statuslineDefaults } = await import('../src/config.js');
|
|
288
|
+
const cfg = statuslineDefaults();
|
|
289
|
+
const cycleSeconds = parseFloat(getArg('--demo-cycle-sec') || '3');
|
|
290
|
+
const data = buildScenarioData(demoArg, {
|
|
291
|
+
cycleSeconds,
|
|
292
|
+
windowHours: cfg.windowHours,
|
|
293
|
+
windowLabel: cfg.windowLabel,
|
|
294
|
+
days: cfg.windowHours / 24,
|
|
295
|
+
version: PKG_VERSION,
|
|
296
|
+
});
|
|
297
|
+
if (!data) {
|
|
298
|
+
const known = listScenarios().map((s) => s.name).concat(['cycle']).join(', ');
|
|
299
|
+
console.error(`Unknown demo scenario: ${demoArg}`);
|
|
300
|
+
console.error(`Valid: ${known}`);
|
|
301
|
+
process.exit(1);
|
|
302
|
+
}
|
|
303
|
+
const { formatReport } = await import('../src/formatters/statusline.js');
|
|
304
|
+
const isIcon = hasFlag('--icon')
|
|
305
|
+
? true
|
|
306
|
+
: (hasFlag('--no-icon') || hasFlag('--text') ? false : cfg.icon);
|
|
307
|
+
const isVerbose = hasFlag('--verbose')
|
|
308
|
+
? true
|
|
309
|
+
: (hasFlag('--no-verbose') || hasFlag('--compact') ? false : cfg.verbose);
|
|
310
|
+
const showTimer = hasFlag('--no-timer') ? false : cfg.timer;
|
|
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;
|
|
316
|
+
const out = formatReport(data, {
|
|
317
|
+
color: colorOk,
|
|
318
|
+
verbose: isVerbose,
|
|
319
|
+
timer: showTimer,
|
|
320
|
+
mode: isIcon ? 'icon' : 'text',
|
|
321
|
+
segments,
|
|
322
|
+
});
|
|
323
|
+
// For `cycle` mode, prefix with the scenario label so the screen recorder
|
|
324
|
+
// shows what the viewer is looking at (only when explicitly requested).
|
|
325
|
+
if (demoArg === 'cycle' && hasFlag('--demo-label')) {
|
|
326
|
+
const gray = colorOk ? '\x1b[90m' : '';
|
|
327
|
+
const reset = colorOk ? '\x1b[0m' : '';
|
|
328
|
+
console.log(`${gray}[${data._demoLabel}]${reset} ${out}`);
|
|
329
|
+
} else {
|
|
330
|
+
console.log(out);
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
88
335
|
// Report generation
|
|
89
|
-
// Statusline
|
|
90
|
-
|
|
91
|
-
|
|
336
|
+
// Statusline window comes from persisted config — hours-precise so users
|
|
337
|
+
// can pick `1h` / `6h` etc, not just whole days. Other formats default to
|
|
338
|
+
// 30 days as before.
|
|
339
|
+
let windowHours = 30 * 24;
|
|
340
|
+
let windowLabel = '30d';
|
|
341
|
+
if (isStatusline) {
|
|
342
|
+
const { statuslineDefaults } = await import('../src/config.js');
|
|
343
|
+
const d = statuslineDefaults();
|
|
344
|
+
windowHours = d.windowHours;
|
|
345
|
+
windowLabel = d.windowLabel;
|
|
346
|
+
}
|
|
347
|
+
// CLI overrides: --hours wins over --days; both win over config.
|
|
348
|
+
const hoursArg = getArg('--hours');
|
|
349
|
+
const daysArg = getArg('--days') || getArg('-d');
|
|
350
|
+
if (hoursArg !== undefined) {
|
|
351
|
+
windowHours = parseFloat(hoursArg);
|
|
352
|
+
windowLabel = `${windowHours}h`;
|
|
353
|
+
} else if (daysArg !== undefined) {
|
|
354
|
+
windowHours = parseFloat(daysArg) * 24;
|
|
355
|
+
windowLabel = `${parseFloat(daysArg)}d`;
|
|
356
|
+
}
|
|
357
|
+
const days = windowHours / 24;
|
|
92
358
|
const format = isStatusline ? 'statusline' : (getArg('--format') || getArg('-f') || 'table');
|
|
93
359
|
const projectFilter = getArg('--project') || getArg('-p');
|
|
94
360
|
|
|
@@ -132,13 +398,39 @@ async function main() {
|
|
|
132
398
|
const spikeReport = detectSpikes(sessions, { recentHours: 24, multiplier: 3 });
|
|
133
399
|
const contextWindow = detectContextWindow(sessions, { recentHours: 24 });
|
|
134
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
|
+
|
|
135
425
|
// For statusline: attach a single-word chip only when there's something
|
|
136
426
|
// actionable right now. 1M context is always shown; otherwise only fire
|
|
137
427
|
// if the most recent session actually appears in the spike list.
|
|
138
428
|
let spikeChip = null;
|
|
429
|
+
let chipDetail = null;
|
|
139
430
|
if (format === 'statusline') {
|
|
140
431
|
if (contextWindow.size === '1M') {
|
|
141
432
|
spikeChip = chipForIssues([], contextWindow);
|
|
433
|
+
chipDetail = `Context auto-promoted to 1M (max single-request ${Math.round(contextWindow.maxContext / 1000)}k tokens)`;
|
|
142
434
|
} else {
|
|
143
435
|
const recentSession = sessions
|
|
144
436
|
.slice()
|
|
@@ -150,8 +442,27 @@ async function main() {
|
|
|
150
442
|
const m = sessionMetrics(recentSession);
|
|
151
443
|
const issues = diagnoseSession(m, spikeReport.baseline);
|
|
152
444
|
spikeChip = chipForIssues(issues, contextWindow);
|
|
445
|
+
const titles = issues
|
|
446
|
+
.map((i) => i.code)
|
|
447
|
+
.slice(0, 2)
|
|
448
|
+
.join(', ');
|
|
449
|
+
chipDetail = `session ${recentSession.sessionId?.slice(0, 8) || ''}: ${titles}`;
|
|
153
450
|
}
|
|
154
451
|
}
|
|
452
|
+
// Persist transitions to ~/.config/claude-token-saver/history/YYYY-MM-DD.md
|
|
453
|
+
// so /token-monitor and `claude-token-saver history` can replay them.
|
|
454
|
+
try {
|
|
455
|
+
const { recordChip, recordCapTransition } = await import('../src/history.js');
|
|
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
|
+
}
|
|
463
|
+
} catch {
|
|
464
|
+
// history is non-critical — don't let it break the statusline render
|
|
465
|
+
}
|
|
155
466
|
}
|
|
156
467
|
|
|
157
468
|
// Last API activity feeds the statusline TTL countdown.
|
|
@@ -181,11 +492,12 @@ async function main() {
|
|
|
181
492
|
ttl,
|
|
182
493
|
anomalies,
|
|
183
494
|
cost,
|
|
184
|
-
options: { days, version: PKG_VERSION },
|
|
495
|
+
options: { days, windowHours, windowLabel, version: PKG_VERSION },
|
|
185
496
|
lastActivity,
|
|
186
497
|
spikeReport,
|
|
187
498
|
contextWindow,
|
|
188
499
|
spikeChip,
|
|
500
|
+
caps,
|
|
189
501
|
};
|
|
190
502
|
|
|
191
503
|
let output;
|
|
@@ -197,13 +509,30 @@ async function main() {
|
|
|
197
509
|
output = formatReport(data);
|
|
198
510
|
} else if (format === 'statusline') {
|
|
199
511
|
const { formatReport } = await import('../src/formatters/statusline.js');
|
|
200
|
-
const
|
|
201
|
-
const
|
|
512
|
+
const { statuslineDefaults } = await import('../src/config.js');
|
|
513
|
+
const cfg = statuslineDefaults();
|
|
514
|
+
|
|
515
|
+
// CLI flags take precedence; otherwise fall back to persisted config.
|
|
516
|
+
const isIcon = hasFlag('--icon')
|
|
517
|
+
? true
|
|
518
|
+
: (hasFlag('--no-icon') || hasFlag('--text') ? false : cfg.icon);
|
|
519
|
+
const isVerbose = hasFlag('--verbose')
|
|
520
|
+
? true
|
|
521
|
+
: (hasFlag('--no-verbose') || hasFlag('--compact') ? false : cfg.verbose);
|
|
522
|
+
const showTimer = hasFlag('--no-timer') ? false : cfg.timer;
|
|
523
|
+
const colorOk =
|
|
524
|
+
!hasFlag('--no-color') && !process.env.NO_COLOR && cfg.color;
|
|
525
|
+
|
|
526
|
+
const segmentsArg = getArg('--segments');
|
|
527
|
+
const segments = segmentsArg
|
|
528
|
+
? segmentsArg.split(',').map((s) => s.trim()).filter(Boolean)
|
|
529
|
+
: null;
|
|
202
530
|
output = formatReport(data, {
|
|
203
531
|
color: colorOk,
|
|
204
|
-
verbose:
|
|
205
|
-
timer:
|
|
206
|
-
mode,
|
|
532
|
+
verbose: isVerbose,
|
|
533
|
+
timer: showTimer,
|
|
534
|
+
mode: isIcon ? 'icon' : 'text',
|
|
535
|
+
segments,
|
|
207
536
|
});
|
|
208
537
|
} else {
|
|
209
538
|
const { formatReport } = await import('../src/formatters/table.js');
|
package/package.json
CHANGED