claude-token-saver 2.1.0 → 2.5.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 +50 -23
- package/bin/cli.js +285 -3
- package/package.json +1 -1
- package/src/advice.js +120 -0
- package/src/caps-cache.js +84 -0
- package/src/demo.js +73 -1
- package/src/format-time.js +44 -0
- package/src/formatters/statusline.js +214 -14
- package/src/formatters/table.js +36 -2
- package/src/handoff.js +161 -0
- package/src/history.js +208 -8
- package/src/installer.js +55 -30
- package/src/window-labels.js +61 -0
- package/examples/statusline-with-rz1989s.sh +0 -52
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
|
-
**
|
|
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
|
-
**단독 도구로도,
|
|
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
|
-
##
|
|
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.
|
|
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,52 @@ 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
|
+
## Model + /usage segments (new in v2.3, generic in v2.4)
|
|
246
|
+
|
|
247
|
+
Three more segments mirror the data Claude Code's `/usage` slash command shows, so you don't have to slash for it every few minutes:
|
|
248
|
+
|
|
249
|
+
| Segment | Icon mode example | Source |
|
|
250
|
+
| ----------- | --------------------- | ------------------------------- |
|
|
251
|
+
| `model` | `🤖 Opus 4.7` | stdin `model.display_name` |
|
|
252
|
+
| `five_hour` | `✦ current ███▒░░ 47% 🔄 21:10` | stdin `rate_limits.five_hour` |
|
|
253
|
+
| `seven_day` | `📅 weekly ▒░░░░░ 9% 🔄 Thu 13:00` | stdin `rate_limits.seven_day` |
|
|
254
|
+
|
|
255
|
+
The window segments stay quiet under 70% (calm emerald), warm to amber at 70–89%, and yield to the leading `🚨 5H █████▓ 94%` / `🚨 7D █████▒ 92%` cap-warn chip at 90%+ — so you never see the same window twice. The gauge is 6 cells wide using a single density family (`█▓▒░`), so the fill→empty boundary reads as one smooth gradient instead of an awkward step between fractional and shaded glyphs. Colors render as a Tailwind-inspired muted palette (emerald-400 / amber-400 / rose-400) on truecolor terminals (`COLORTERM=truecolor`), with a graceful fallback to 8-color ANSI elsewhere. Filter the layout with `--segments=` if you only want a subset:
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
claude-token-saver --statusline --icon --segments=model,five_hour,seven_day,saved
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
`5h` and `7d` are kept as aliases for the older config files. Any new `rate_limits.*` window Anthropic ships (e.g. a Sonnet-only weekly bucket) renders automatically with a derived label — no config or version bump needed. As of 2026-04-25 the stdin contract exposes only `five_hour` + `seven_day`; the third row in `/usage` ("Current week — Sonnet only") is not in the payload yet, so we mirror what's there.
|
|
262
|
+
|
|
263
|
+
## Cap-warn + handoff (new in v2.2)
|
|
264
|
+
|
|
265
|
+
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:
|
|
266
|
+
|
|
267
|
+
```
|
|
268
|
+
- 14:32:08 🚨 5H 94% cap warning (resets in 1h 38m)
|
|
269
|
+
- 16:10:21 ✓ 5H cap warning resolved
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
When you see the chip, back up the work in flight before the cap blocks you:
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
claude-token-saver handoff
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
That writes `./HANDOFF-YYYY-MM-DD-HHMM.md` in the current directory with:
|
|
279
|
+
|
|
280
|
+
- timestamp, cwd, git branch / HEAD / dirty file list
|
|
281
|
+
- the 5h/7d cap snapshot (and "resets in Hh Mm")
|
|
282
|
+
- empty fillable sections for *what I just did*, *TODO*, *where to pick up next*, *gotchas*
|
|
283
|
+
- a one-line resume prompt for a fresh Claude Code session:
|
|
284
|
+
|
|
285
|
+
```
|
|
286
|
+
Read the most recent HANDOFF-*.md in this directory and continue the work.
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
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.
|
|
290
|
+
|
|
264
291
|
## Hook Setup
|
|
265
292
|
|
|
266
293
|
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,66 @@ 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
|
+
* extractCaps treats `rate_limits` as a generic object so any future window
|
|
44
|
+
* Anthropic adds (e.g. a Sonnet-only weekly bucket) flows through without code
|
|
45
|
+
* changes — known keys get curated labels, unknowns get derived ones.
|
|
46
|
+
*/
|
|
47
|
+
function readStdinJson() {
|
|
48
|
+
if (process.stdin.isTTY) return null;
|
|
49
|
+
try {
|
|
50
|
+
const raw = readFileSync(0, 'utf8');
|
|
51
|
+
if (!raw || !raw.trim()) return null;
|
|
52
|
+
return JSON.parse(raw);
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function extractCaps(stdinJson) {
|
|
59
|
+
if (!stdinJson || !stdinJson.rate_limits || typeof stdinJson.rate_limits !== 'object') return null;
|
|
60
|
+
const windows = [];
|
|
61
|
+
for (const [key, value] of Object.entries(stdinJson.rate_limits)) {
|
|
62
|
+
if (!value || typeof value !== 'object') continue;
|
|
63
|
+
const usedPct = Number(value.used_percentage);
|
|
64
|
+
if (!Number.isFinite(usedPct)) continue;
|
|
65
|
+
const resetsAt = Number(value.resets_at);
|
|
66
|
+
windows.push({
|
|
67
|
+
key,
|
|
68
|
+
usedPct,
|
|
69
|
+
resetsAt: Number.isFinite(resetsAt) ? resetsAt : null,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return windows.length ? { windows } : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Pull the human-friendly model name out of Claude Code's stdin payload.
|
|
77
|
+
* `model.display_name` is the contract; fall back to `model.id` when it's
|
|
78
|
+
* absent. Returns null when nothing usable is in the JSON.
|
|
79
|
+
*/
|
|
80
|
+
function extractModel(stdinJson) {
|
|
81
|
+
if (!stdinJson || !stdinJson.model) return null;
|
|
82
|
+
const m = stdinJson.model;
|
|
83
|
+
if (typeof m === 'string') return m;
|
|
84
|
+
if (typeof m.display_name === 'string' && m.display_name) return m.display_name;
|
|
85
|
+
if (typeof m.id === 'string' && m.id) return m.id;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
29
89
|
import { parseAllSessions, getLastUserMessageTime } from '../src/parser.js';
|
|
30
90
|
import {
|
|
31
91
|
dailyTrend,
|
|
@@ -53,15 +113,162 @@ const PKG_VERSION = (() => {
|
|
|
53
113
|
|
|
54
114
|
function getArg(name) {
|
|
55
115
|
const idx = args.indexOf(name);
|
|
56
|
-
if (idx
|
|
57
|
-
|
|
116
|
+
if (idx !== -1) return args[idx + 1];
|
|
117
|
+
const prefix = `${name}=`;
|
|
118
|
+
const eq = args.find((a) => a.startsWith(prefix));
|
|
119
|
+
if (eq) return eq.slice(prefix.length);
|
|
120
|
+
return undefined;
|
|
58
121
|
}
|
|
59
122
|
|
|
60
123
|
function hasFlag(name) {
|
|
61
124
|
return args.includes(name);
|
|
62
125
|
}
|
|
63
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Scan recent history file contents (newest day first) and return the most
|
|
129
|
+
* recent warning event — `{ time, chip, detail, codes, isCap, capLabel, capPct }`.
|
|
130
|
+
* Returns null when no warning is found in the window.
|
|
131
|
+
*
|
|
132
|
+
* Recognized event lines (from history.js appendDayLine output):
|
|
133
|
+
* - HH:MM:SS ⚠ Cache miss — session abc1: LOW_HIT_RATE
|
|
134
|
+
* - HH:MM:SS ⚠ A → ⚠ B — detail
|
|
135
|
+
* - HH:MM:SS 🚨 5H 94% cap warning (resets in ...)
|
|
136
|
+
* - HH:MM:SS ✓ resolved (was ...) ← skip
|
|
137
|
+
* - HH:MM:SS ✓ 5H cap warning resolved ← skip
|
|
138
|
+
* - HH:MM:SS 📝 handoff written: ... ← skip
|
|
139
|
+
*/
|
|
140
|
+
function findLatestWarning(historyEntries, chipToCodes) {
|
|
141
|
+
const warnings = [];
|
|
142
|
+
for (const { date, content } of historyEntries) {
|
|
143
|
+
const lines = content.split('\n');
|
|
144
|
+
for (const line of lines) {
|
|
145
|
+
// Skip non-event lines
|
|
146
|
+
const m = line.match(/^- (\d{2}:\d{2}:\d{2})\s+(.+)$/);
|
|
147
|
+
if (!m) continue;
|
|
148
|
+
const time = m[1];
|
|
149
|
+
const rest = m[2];
|
|
150
|
+
// Skip resolutions and handoff entries
|
|
151
|
+
if (rest.startsWith('✓ ') || rest.startsWith('📝 ')) continue;
|
|
152
|
+
// Cap-warn line: `🚨 5H 94% cap warning (...)`
|
|
153
|
+
const cap = rest.match(/^🚨\s+(\S+)\s+(\d+)%\s+cap warning(?:\s*\((.+)\))?$/);
|
|
154
|
+
if (cap) {
|
|
155
|
+
warnings.push({
|
|
156
|
+
date,
|
|
157
|
+
time,
|
|
158
|
+
chip: `🚨 ${cap[1]} ${cap[2]}%`,
|
|
159
|
+
isCap: true,
|
|
160
|
+
capLabel: cap[1],
|
|
161
|
+
capPct: parseInt(cap[2], 10),
|
|
162
|
+
capReset: cap[3] || null,
|
|
163
|
+
codes: [],
|
|
164
|
+
detail: null,
|
|
165
|
+
});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// Chip line — last token after the chip is `— detail` (optional). The
|
|
169
|
+
// chip itself can be a plain `⚠ X` or a `⚠ A → ⚠ B` transition; we want
|
|
170
|
+
// the *current* chip (right side of the arrow if present).
|
|
171
|
+
const arrowMatch = rest.match(/^(.+?)\s+→\s+(.+?)(?:\s+—\s+(.+))?$/);
|
|
172
|
+
let chip;
|
|
173
|
+
let detail = null;
|
|
174
|
+
if (arrowMatch) {
|
|
175
|
+
chip = arrowMatch[2].trim();
|
|
176
|
+
detail = arrowMatch[3] || null;
|
|
177
|
+
} else {
|
|
178
|
+
const plain = rest.match(/^(\S+(?:\s+\S+)*?)(?:\s+—\s+(.+))?$/);
|
|
179
|
+
if (!plain) continue;
|
|
180
|
+
chip = plain[1].trim();
|
|
181
|
+
detail = plain[2] || null;
|
|
182
|
+
}
|
|
183
|
+
// Resolve codes: detail "session ID: A, B" → codes; else CHIP_TO_CODES.
|
|
184
|
+
const codes = [];
|
|
185
|
+
if (detail) {
|
|
186
|
+
const dm = detail.match(/^session [^:]+:\s*(.+)$/);
|
|
187
|
+
if (dm) {
|
|
188
|
+
for (const c of dm[1].split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
189
|
+
if (!codes.includes(c)) codes.push(c);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (chipToCodes[chip]) {
|
|
194
|
+
for (const c of chipToCodes[chip]) if (!codes.includes(c)) codes.push(c);
|
|
195
|
+
}
|
|
196
|
+
warnings.push({ date, time, chip, isCap: false, codes, detail });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return warnings.length ? warnings[warnings.length - 1] : null;
|
|
200
|
+
}
|
|
201
|
+
|
|
64
202
|
async function main() {
|
|
203
|
+
// Subcommand: last — print the most recent warning + how to handle it.
|
|
204
|
+
// Designed for the /token-monitor slash command and the auto-skill so the
|
|
205
|
+
// user immediately sees "what just fired and how to fix it" without having
|
|
206
|
+
// to read the whole history file.
|
|
207
|
+
// claude-token-saver last # search last 1 day
|
|
208
|
+
// claude-token-saver last --days 7 # widen the lookback
|
|
209
|
+
if (args[0] === 'last') {
|
|
210
|
+
const { readRecent, historyDir } = await import('../src/history.js');
|
|
211
|
+
const { ISSUE_MESSAGES, CHIP_TO_CODES, CAP_TIPS } = await import('../src/advice.js');
|
|
212
|
+
const days = parseFloat(getArg('--days') || '1');
|
|
213
|
+
const recent = readRecent(days);
|
|
214
|
+
const latest = findLatestWarning(recent, CHIP_TO_CODES);
|
|
215
|
+
if (!latest) {
|
|
216
|
+
console.log(`No warnings in the last ${days} day${days === 1 ? '' : 's'}.`);
|
|
217
|
+
console.log(`(History dir: ${historyDir()})`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// Header
|
|
221
|
+
console.log(`Most recent warning — ${latest.date} ${latest.time}`);
|
|
222
|
+
console.log(` ${latest.chip}${latest.detail ? ` — ${latest.detail}` : ''}`);
|
|
223
|
+
console.log('');
|
|
224
|
+
// Cap-warn path: handoff is the recommendation. Print the bilingual tip
|
|
225
|
+
// and a one-line "how to back up" pointer.
|
|
226
|
+
if (latest.isCap) {
|
|
227
|
+
if (latest.capReset) console.log(` Cap window: ${latest.capReset}`);
|
|
228
|
+
console.log('');
|
|
229
|
+
console.log('💡 ' + CAP_TIPS.en);
|
|
230
|
+
console.log('💡 ' + CAP_TIPS.ko);
|
|
231
|
+
console.log('');
|
|
232
|
+
console.log('Run:');
|
|
233
|
+
console.log(' claude-token-saver handoff');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
// Chip warning path: render full ISSUE_MESSAGES advice for each code,
|
|
237
|
+
// bilingual (English first, `└ Korean` continuation per line — matches
|
|
238
|
+
// the history.md format).
|
|
239
|
+
if (latest.codes.length === 0) {
|
|
240
|
+
console.log('(No diagnostic code attached — open the table view: `claude-token-saver --days 1`)');
|
|
241
|
+
console.log('(진단 코드 없음 — 표 뷰를 열어보세요: `claude-token-saver --days 1`)');
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
for (const code of latest.codes) {
|
|
245
|
+
const msg = ISSUE_MESSAGES[code];
|
|
246
|
+
if (!msg) {
|
|
247
|
+
console.log(`Code: ${code} (no advice registered)`);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
console.log(`▎ ${msg.title}`);
|
|
251
|
+
if (msg.titleKo && msg.titleKo !== msg.title) console.log(` └ ${msg.titleKo}`);
|
|
252
|
+
console.log(` ${msg.explain}`);
|
|
253
|
+
if (msg.explainKo && msg.explainKo !== msg.explain) console.log(` └ ${msg.explainKo}`);
|
|
254
|
+
const actions = typeof msg.actions === 'function' ? msg.actions() : msg.actions || [];
|
|
255
|
+
for (const a of actions) {
|
|
256
|
+
console.log('');
|
|
257
|
+
console.log(` ${a.label}:`);
|
|
258
|
+
if (a.labelKo && a.labelKo !== a.label) console.log(` └ ${a.labelKo}:`);
|
|
259
|
+
const cmds = a.commands || [];
|
|
260
|
+
const cmdsKo = a.commandsKo || [];
|
|
261
|
+
for (let i = 0; i < cmds.length; i++) {
|
|
262
|
+
console.log(` - ${cmds[i]}`);
|
|
263
|
+
const ko = cmdsKo[i];
|
|
264
|
+
if (ko && ko !== cmds[i]) console.log(` └ ${ko}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
console.log('');
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
65
272
|
// Subcommand: history — print recent warning transitions captured by the
|
|
66
273
|
// statusline. One markdown file per day, persisted under the platform-
|
|
67
274
|
// specific user-data dir.
|
|
@@ -94,6 +301,34 @@ async function main() {
|
|
|
94
301
|
return;
|
|
95
302
|
}
|
|
96
303
|
|
|
304
|
+
// Subcommand: handoff — write a HANDOFF-YYYY-MM-DD-HHMM.md template in cwd
|
|
305
|
+
// capturing git status + the latest cap snapshot, so a fresh Claude Code
|
|
306
|
+
// session can pick up where this one stopped. Pairs with the cap-warn chip:
|
|
307
|
+
// when statusline shows 🚨 5H 90%+, run this to back up state before the cap
|
|
308
|
+
// hits.
|
|
309
|
+
// claude-token-saver handoff # write to cwd
|
|
310
|
+
// claude-token-saver handoff --cwd PATH # custom directory
|
|
311
|
+
if (args[0] === 'handoff') {
|
|
312
|
+
const { writeHandoff } = await import('../src/handoff.js');
|
|
313
|
+
const { recordHandoff } = await import('../src/history.js');
|
|
314
|
+
const cwd = getArg('--cwd') || process.cwd();
|
|
315
|
+
// Cap data only flows in via stdin (Claude Code statusline contract).
|
|
316
|
+
// Direct CLI invocations won't have it — that's fine, the template will
|
|
317
|
+
// note the gap.
|
|
318
|
+
const stdinJson = readStdinJson();
|
|
319
|
+
const caps = extractCaps(stdinJson);
|
|
320
|
+
const { path, git } = writeHandoff({ cwd, caps });
|
|
321
|
+
try { recordHandoff(path); } catch { /* non-critical */ }
|
|
322
|
+
console.log(`Handoff written: ${path}`);
|
|
323
|
+
if (git) {
|
|
324
|
+
console.log(` git: ${git.branch}${git.head ? ` @ ${git.head}` : ''}${git.status ? ' (dirty)' : ' (clean)'}`);
|
|
325
|
+
}
|
|
326
|
+
console.log('');
|
|
327
|
+
console.log('Fill in the empty sections, then start a new Claude Code session with:');
|
|
328
|
+
console.log(' Read the most recent HANDOFF-*.md in this directory and continue the work.');
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
97
332
|
// Subcommand: install — write the Claude Code Skill and slash command so
|
|
98
333
|
// /token-monitor and the auto-trigger skill become available without any
|
|
99
334
|
// manual file editing. Cross-platform (uses node:path + node:fs).
|
|
@@ -234,11 +469,16 @@ async function main() {
|
|
|
234
469
|
: (hasFlag('--no-verbose') || hasFlag('--compact') ? false : cfg.verbose);
|
|
235
470
|
const showTimer = hasFlag('--no-timer') ? false : cfg.timer;
|
|
236
471
|
const colorOk = !hasFlag('--no-color') && !process.env.NO_COLOR && cfg.color;
|
|
472
|
+
const segmentsArg = getArg('--segments');
|
|
473
|
+
const segments = segmentsArg
|
|
474
|
+
? segmentsArg.split(',').map((s) => s.trim()).filter(Boolean)
|
|
475
|
+
: null;
|
|
237
476
|
const out = formatReport(data, {
|
|
238
477
|
color: colorOk,
|
|
239
478
|
verbose: isVerbose,
|
|
240
479
|
timer: showTimer,
|
|
241
480
|
mode: isIcon ? 'icon' : 'text',
|
|
481
|
+
segments,
|
|
242
482
|
});
|
|
243
483
|
// For `cycle` mode, prefix with the scenario label so the screen recorder
|
|
244
484
|
// shows what the viewer is looking at (only when explicitly requested).
|
|
@@ -318,6 +558,36 @@ async function main() {
|
|
|
318
558
|
const spikeReport = detectSpikes(sessions, { recentHours: 24, multiplier: 3 });
|
|
319
559
|
const contextWindow = detectContextWindow(sessions, { recentHours: 24 });
|
|
320
560
|
|
|
561
|
+
// Claude Code feeds the statusline command a JSON blob on stdin every
|
|
562
|
+
// refresh. Pull rate_limits + model out of it so we can surface cap-warn
|
|
563
|
+
// (>=90%) chips, always-on usage segments, the model chip, record cap
|
|
564
|
+
// transitions, and seed the table view's warning box. The table path falls
|
|
565
|
+
// back to the most-recent cached snapshot so the /token-monitor slash
|
|
566
|
+
// command (which doesn't pipe stdin) still has the data.
|
|
567
|
+
const stdinJson = readStdinJson();
|
|
568
|
+
let caps = extractCaps(stdinJson);
|
|
569
|
+
let model = extractModel(stdinJson);
|
|
570
|
+
if (isStatusline && (caps || model)) {
|
|
571
|
+
try {
|
|
572
|
+
const { persistSnapshot } = await import('../src/caps-cache.js');
|
|
573
|
+
persistSnapshot({ caps, model });
|
|
574
|
+
} catch {
|
|
575
|
+
// non-critical
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (!isStatusline && (!caps || !model)) {
|
|
579
|
+
try {
|
|
580
|
+
const { loadRecentSnapshot } = await import('../src/caps-cache.js');
|
|
581
|
+
const snap = loadRecentSnapshot();
|
|
582
|
+
if (snap) {
|
|
583
|
+
if (!caps && snap.caps) caps = snap.caps;
|
|
584
|
+
if (!model && snap.model) model = snap.model;
|
|
585
|
+
}
|
|
586
|
+
} catch {
|
|
587
|
+
// ignore
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
321
591
|
// For statusline: attach a single-word chip only when there's something
|
|
322
592
|
// actionable right now. 1M context is always shown; otherwise only fire
|
|
323
593
|
// if the most recent session actually appears in the spike list.
|
|
@@ -348,8 +618,13 @@ async function main() {
|
|
|
348
618
|
// Persist transitions to ~/.config/claude-token-saver/history/YYYY-MM-DD.md
|
|
349
619
|
// so /token-monitor and `claude-token-saver history` can replay them.
|
|
350
620
|
try {
|
|
351
|
-
const { recordChip } = await import('../src/history.js');
|
|
621
|
+
const { recordChip, recordCapTransition } = await import('../src/history.js');
|
|
352
622
|
recordChip(spikeChip, { detail: chipDetail });
|
|
623
|
+
// Cap-warn transitions are tracked independently per window — a session
|
|
624
|
+
// can hit 90% on the 5h window even when no spike chip is firing.
|
|
625
|
+
if (caps && Array.isArray(caps.windows)) {
|
|
626
|
+
for (const win of caps.windows) recordCapTransition(win);
|
|
627
|
+
}
|
|
353
628
|
} catch {
|
|
354
629
|
// history is non-critical — don't let it break the statusline render
|
|
355
630
|
}
|
|
@@ -387,6 +662,8 @@ async function main() {
|
|
|
387
662
|
spikeReport,
|
|
388
663
|
contextWindow,
|
|
389
664
|
spikeChip,
|
|
665
|
+
caps,
|
|
666
|
+
model,
|
|
390
667
|
};
|
|
391
668
|
|
|
392
669
|
let output;
|
|
@@ -412,11 +689,16 @@ async function main() {
|
|
|
412
689
|
const colorOk =
|
|
413
690
|
!hasFlag('--no-color') && !process.env.NO_COLOR && cfg.color;
|
|
414
691
|
|
|
692
|
+
const segmentsArg = getArg('--segments');
|
|
693
|
+
const segments = segmentsArg
|
|
694
|
+
? segmentsArg.split(',').map((s) => s.trim()).filter(Boolean)
|
|
695
|
+
: null;
|
|
415
696
|
output = formatReport(data, {
|
|
416
697
|
color: colorOk,
|
|
417
698
|
verbose: isVerbose,
|
|
418
699
|
timer: showTimer,
|
|
419
700
|
mode: isIcon ? 'icon' : 'text',
|
|
701
|
+
segments,
|
|
420
702
|
});
|
|
421
703
|
} else {
|
|
422
704
|
const { formatReport } = await import('../src/formatters/table.js');
|
package/package.json
CHANGED