claude-token-saver 2.15.0 โ†’ 2.16.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/bin/cli.js CHANGED
@@ -838,12 +838,31 @@ async function main() {
838
838
  const sessions = await parseAllSessions({ days, projectFilter });
839
839
 
840
840
  if (sessions.length === 0) {
841
- // Statusline must always emit a single line (no multi-line help spam every 300ms)
841
+ // Statusline must always emit a single line (no multi-line help spam every
842
+ // 300ms) โ€” but the stdin payload (rate limits, model) is still live even
843
+ // with an empty analysis window (e.g. `mode 1h` + idle), and cap-warn /
844
+ // harness are exactly the signals that must not vanish then.
842
845
  if (format === 'statusline') {
843
- const colorOk = !hasFlag('--no-color') && !process.env.NO_COLOR;
844
- const gray = colorOk ? '\x1b[90m' : '';
845
- const reset = colorOk ? '\x1b[0m' : '';
846
- console.log(`${gray}๐Ÿง  no session data ยท ${days}d${reset}`);
846
+ const { formatNoSession } = await import('../src/formatters/statusline.js');
847
+ const { statuslineDefaults } = await import('../src/config.js');
848
+ const cfg = statuslineDefaults();
849
+ const colorOk = !hasFlag('--no-color') && !process.env.NO_COLOR && cfg.color;
850
+ const isIcon = hasFlag('--icon')
851
+ ? true
852
+ : (hasFlag('--no-icon') || hasFlag('--text') ? false : cfg.icon);
853
+ const stdinJson = readStdinJson();
854
+ const caps = extractCaps(stdinJson);
855
+ const model = extractModel(stdinJson);
856
+ if (caps || model) {
857
+ try {
858
+ const { persistSnapshot } = await import('../src/caps-cache.js');
859
+ persistSnapshot({ caps, model });
860
+ } catch { /* non-critical */ }
861
+ }
862
+ console.log(formatNoSession(
863
+ { caps, model, windowLabel },
864
+ { color: colorOk, mode: isIcon ? 'icon' : 'text' },
865
+ ));
847
866
  return;
848
867
  }
849
868
  console.log('No session data found for the given period.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "2.15.0",
3
+ "version": "2.16.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": {
@@ -137,6 +137,73 @@ export function pickCapWarn(caps) {
137
137
  return candidates[0];
138
138
  }
139
139
 
140
+ /**
141
+ * Harness ๐Ÿ…ท segment builder โ€” shared by the full report and the no-session
142
+ * fallback line. Best-effort: never throws into the statusline (corrupted
143
+ * CLAUDE.md, permission issue, etc. โ†’ null).
144
+ */
145
+ function buildHarnessSeg(c, isIcon) {
146
+ try {
147
+ const harnessInfo = harnessStatusForStatusline(loadConfig());
148
+ if (!harnessInfo) return null;
149
+ const icon = isIcon ? '๐Ÿ…ท' : 'H';
150
+ if (harnessInfo.warning) {
151
+ // Warning state outranks the N/5 count โ€” a runtime issue (repeated
152
+ // error / no-evidence / racing edits) is more actionable than a
153
+ // missing ratchet section. Always red so it stands out.
154
+ return `${c(RED)}${icon}โš  ${harnessInfo.warning}${c(RESET)}`;
155
+ }
156
+ if (harnessInfo.custom) return `${c(CYAN)}${icon} custom${c(RESET)}`;
157
+ const tone = harnessInfo.configured >= harnessInfo.total ? GREEN : YELLOW;
158
+ return `${c(tone)}${icon} ${harnessInfo.configured}/${harnessInfo.total}${c(RESET)}`;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Cap-warn chip builder โ€” shared by the full report and the no-session
166
+ * fallback line. At 90%+ the user wants to know "when can I send again", so
167
+ * the wall-clock reset time rides along in the same `๐Ÿ”„ HH:MM` shape as the
168
+ * always-on usage segments.
169
+ */
170
+ function buildCapWarnSeg(capWarn, c, isIcon) {
171
+ if (!capWarn) return null;
172
+ const pct = Math.round(capWarn.usedPct);
173
+ const clock = formatResetClock(capWarn.resetsAt);
174
+ const clockTail = clock ? ` ๐Ÿ”„ ${clock}` : '';
175
+ if (isIcon) {
176
+ // Gauge keeps shape parity with the always-on usage segment โ€” the
177
+ // cap-warn is just the same gauge "filled to alarm". Visual continuity
178
+ // helps the eye understand "this is the 5H bar I was watching, just red now."
179
+ const bar = gaugeBar(pct);
180
+ return `${c(BOLD)}${c(RED)}๐Ÿšจ ${capWarn.label} ${bar} ${pct}%${clockTail}${c(RESET)}`;
181
+ }
182
+ return `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${clockTail}${c(RESET)}`;
183
+ }
184
+
185
+ /**
186
+ * Fallback line for when no session data exists in the analysis window.
187
+ * The stdin payload (rate limits, model) is still live in that case, and a
188
+ * 90%+ cap warning is exactly the kind of signal that must not disappear
189
+ * just because the user has been idle past the window โ€” so cap-warn,
190
+ * harness, and model chips still render around the "no session data" note.
191
+ */
192
+ export function formatNoSession({ caps = null, model = null, windowLabel = '' } = {}, { color = true, mode = 'icon' } = {}) {
193
+ const c = (v) => (color ? v : '');
194
+ const isIcon = mode === 'icon';
195
+ const segs = [];
196
+ const capSeg = buildCapWarnSeg(pickCapWarn(caps), c, isIcon);
197
+ if (capSeg) segs.push(capSeg);
198
+ const harnessSeg = buildHarnessSeg(c, isIcon);
199
+ if (harnessSeg) segs.push(harnessSeg);
200
+ if (typeof model === 'string' && model.length > 0) {
201
+ segs.push(isIcon ? `${c(MAGENTA)}๐Ÿค– ${model}${c(RESET)}` : `${c(MAGENTA)}${model}${c(RESET)}`);
202
+ }
203
+ segs.push(`${c(GRAY)}๐Ÿง  no session data${windowLabel ? ` ยท ${windowLabel}` : ''}${c(RESET)}`);
204
+ return segs.join(' ยท ') + (color ? '\x1b[K' : '');
205
+ }
206
+
140
207
  /**
141
208
  * @param {object} data - output of main report pipeline (summary, ttl, cost, options, lastActivity)
142
209
  * @param {object} [opts]
@@ -144,7 +211,7 @@ export function pickCapWarn(caps) {
144
211
  * @param {boolean} [opts.verbose=false] - longer layout with labels
145
212
  * @param {boolean} [opts.timer=true] - show TTL countdown segment
146
213
  * @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses ๐Ÿง  โณ ๐Ÿ’ฐ instead of word labels.
147
- * @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, model, hit, ttl, saved, ctx, period, plus per-window keys (`five_hour`, `seven_day`, โ€ฆ). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
214
+ * @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, harness, model, hit, ttl, saved, ctx, period, plus per-window keys (`five_hour`, `seven_day`, โ€ฆ). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
148
215
  */
149
216
  export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null } = {}) {
150
217
  const { summary, ttl, cost, options, lastActivity, contextWindow, spikeChip, caps, model } = data;
@@ -279,28 +346,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
279
346
  // Silent when the project hasn't opted in (no CLAUDE.md and no .claude/);
280
347
  // otherwise renders ๐Ÿ…ท 5/5 (green) / ๐Ÿ…ท N/5 (yellow) so the user can spot
281
348
  // a missing section at a glance and know to run `harness init`.
282
- let harnessSeg = null;
283
- try {
284
- const harnessInfo = harnessStatusForStatusline(loadConfig());
285
- if (harnessInfo) {
286
- const icon = isIcon ? '๐Ÿ…ท' : 'H';
287
- if (harnessInfo.warning) {
288
- // Warning state outranks the N/5 count โ€” a runtime issue (repeated
289
- // error / no-evidence / racing edits) is more actionable than a
290
- // missing ratchet section. Always red so it stands out.
291
- harnessSeg = `${c(RED)}${icon}โš  ${harnessInfo.warning}${c(RESET)}`;
292
- } else if (harnessInfo.custom) {
293
- harnessSeg = `${c(CYAN)}${icon} custom${c(RESET)}`;
294
- } else {
295
- const tone = harnessInfo.configured >= harnessInfo.total ? GREEN : YELLOW;
296
- harnessSeg = `${c(tone)}${icon} ${harnessInfo.configured}/${harnessInfo.total}${c(RESET)}`;
297
- }
298
- }
299
- } catch {
300
- // Harness check is best-effort โ€” never break the statusline if the file
301
- // read fails (corrupted CLAUDE.md, permission issue, etc.).
302
- harnessSeg = null;
303
- }
349
+ const harnessSeg = buildHarnessSeg(c, isIcon);
304
350
 
305
351
  // Model chip โ€” pulled from Claude Code's stdin payload (`model.display_name`).
306
352
  // Cheap identity context: useful when the user toggles between Sonnet/Opus
@@ -322,12 +368,14 @@ export function formatReport(data, { color = true, verbose = false, timer = true
322
368
  // Today the stdin payload exposes the 5h ("Current session") and 7-day
323
369
  // rolling ("Current week") windows; if Anthropic ships more (e.g. a
324
370
  // Sonnet-only weekly), they render automatically with derived labels.
325
- // Each renders as `{label} {pct}% ยท {countdown}`. When a window is at >=90%
326
- // the cap-warn chip already shouts about it, so we suppress the always-on
327
- // segment to avoid duplicate noise.
328
- function buildUsageSeg({ labels, info, color: tone }) {
371
+ // Each renders as `{label} {pct}% ยท {countdown}`. The window promoted to the
372
+ // cap-warn chip is suppressed here to avoid duplicate noise โ€” but ONLY that
373
+ // one. When several windows are at 90%+ the chip shows just the most
374
+ // imminent, so the others must keep their always-on segment (red) or they'd
375
+ // vanish from the statusline entirely at the worst possible moment.
376
+ function buildUsageSeg({ labels, info, color: tone, suppressed }) {
329
377
  if (!info || !Number.isFinite(info.usedPct)) return null;
330
- if (info.usedPct >= 90) return null; // cap-warn chip handles this case
378
+ if (suppressed) return null; // cap-warn chip handles this window
331
379
  const pct = Math.round(info.usedPct);
332
380
  // Show only the wall-clock reset time (e.g. `๐Ÿ”„ 21:10`). Absolute time
333
381
  // doesn't tick second-by-second so the statusline reads stable, and the
@@ -351,14 +399,20 @@ export function formatReport(data, { color = true, verbose = false, timer = true
351
399
  }
352
400
  return `${c(tone)}${labels.short} cap ${pct}%${tail}${c(RESET)}`;
353
401
  }
354
- // Color tone: green when <70%, yellow 70-89% (the segment is suppressed at
355
- // 90+% in favor of cap-warn). Lets the user spot "I'm getting close" without
356
- // waiting for the alarm chip.
402
+ // Color tone: green <70%, yellow 70-89%, red 90+% (a 90+% window only
403
+ // renders here when a *different* window won the cap-warn chip slot).
357
404
  function usageTone(info) {
358
405
  if (!info || !Number.isFinite(info.usedPct)) return GRAY;
406
+ if (info.usedPct >= 90) return RED;
359
407
  if (info.usedPct >= 70) return YELLOW;
360
408
  return GREEN;
361
409
  }
410
+ // Cap-warn chip โ€” leads everything when ANY rate-limit window is at 90%+.
411
+ // It's the most actionable signal we can show: no point optimizing cache
412
+ // hits if you're about to be rate-limited anyway. The chip body matches the
413
+ // English shape `๐Ÿšจ 5H 94%` / `๐Ÿšจ 7D 92%` so history parsers can dedupe on it.
414
+ // Computed before the usage segments so they know which window it claimed.
415
+ const capWarn = pickCapWarn(caps);
362
416
  const usageSegs = [];
363
417
  if (caps && Array.isArray(caps.windows)) {
364
418
  for (const win of caps.windows) {
@@ -367,34 +421,13 @@ export function formatReport(data, { color = true, verbose = false, timer = true
367
421
  labels,
368
422
  info: win,
369
423
  color: usageTone(win),
424
+ suppressed: !!capWarn && capWarn.key === win.key,
370
425
  });
371
426
  if (seg) usageSegs.push({ key: win.key, seg });
372
427
  }
373
428
  }
374
429
 
375
- // Cap-warn chip โ€” leads everything when ANY rate-limit window is at 90%+.
376
- // It's the most actionable signal we can show: no point optimizing cache
377
- // hits if you're about to be rate-limited anyway. The chip body matches the
378
- // English shape `๐Ÿšจ 5H 94%` / `๐Ÿšจ 7D 92%` so history parsers can dedupe on it.
379
- const capWarn = pickCapWarn(caps);
380
- let capWarnSeg = null;
381
- if (capWarn) {
382
- const pct = Math.round(capWarn.usedPct);
383
- // At 90%+ the user wants to know "when can I send again" โ€” wall-clock is
384
- // the actionable bit. Same `๐Ÿ”„ HH:MM` shape as the always-on segments so
385
- // the icon's meaning carries over to the alarm chip.
386
- const clock = formatResetClock(capWarn.resetsAt);
387
- const clockTail = clock ? ` ๐Ÿ”„ ${clock}` : '';
388
- if (isIcon) {
389
- // Gauge keeps shape parity with the always-on usage segment โ€” the
390
- // cap-warn is just the same gauge "filled to alarm". Visual continuity
391
- // helps the eye understand "this is the 5H bar I was watching, just red now."
392
- const bar = gaugeBar(pct);
393
- capWarnSeg = `${c(BOLD)}${c(RED)}๐Ÿšจ ${capWarn.label} ${bar} ${pct}%${clockTail}${c(RESET)}`;
394
- } else {
395
- capWarnSeg = `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${clockTail}${c(RESET)}`;
396
- }
397
- }
430
+ const capWarnSeg = buildCapWarnSeg(capWarn, c, isIcon);
398
431
 
399
432
  // Warning chip leads โ€” a glance at the statusline catches "something's wrong"
400
433
  // before parsing any numbers. Healthy states have no chip and look unchanged.
@@ -431,7 +464,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
431
464
  if (want('period')) segs.push(periodSeg);
432
465
  // Trailing erase-to-end-of-line so any leftover characters from a previous
433
466
  // (longer) statusline render don't bleed into ours. \x1b[K is the standard
434
- // "erase from cursor to EOL" CSI; safe on any ANSI-compatible terminal and
435
- // a no-op when stdout isn't a TTY.
436
- return segs.join(' ยท ') + '\x1b[K';
467
+ // "erase from cursor to EOL" CSI. Only emitted when color (i.e. ANSI) is
468
+ // allowed โ€” --no-color/NO_COLOR consumers expect escape-free output.
469
+ return segs.join(' ยท ') + (color ? '\x1b[K' : '');
437
470
  }
@@ -12,9 +12,11 @@
12
12
  * keywords). <30% โ†’ โš  no-evidence โ€” high chance the model is reporting
13
13
  * "done" without showing it.
14
14
  *
15
- * 3. PEV-skip โ€” many tool_use calls (5+) in the last 15 turns with no plan
16
- * signal (no TodoWrite, no "plan"/"Phase"/"๋‹จ๊ณ„" mention). Suggests the
17
- * model is racing through edits without a verify pass.
15
+ * 3. PEV-skip โ€” many *mutating* tool_use calls (Edit/Write/Bashโ€ฆ, 5+) in the
16
+ * last 15 assistant turns with no plan signal (no TodoWrite, no
17
+ * "plan"/"Phase"/"๋‹จ๊ณ„" mention). Suggests the model is racing through
18
+ * edits without a verify pass. Read-only exploration (Read/Grep/Glob)
19
+ * deliberately doesn't count โ€” reading five files is research, not racing.
18
20
  *
19
21
  * CommonJS so hook.cjs can `require()` it without a bundler step.
20
22
  */
@@ -28,10 +30,14 @@ const os = require('node:os');
28
30
  const STATE_DIR = stateDir();
29
31
  const STATE_PATH = path.join(STATE_DIR, 'harness-state.json');
30
32
 
31
- const RECENT_TURNS = 15; // PEV / evidence window
33
+ const RECENT_TURNS = 15; // PEV / evidence window (assistant turns)
32
34
  const RATCHET_TURNS = 30; // ratchet-candidate window
33
35
  const EVIDENCE_THRESHOLD = 0.3; // <30% โ†’ โš  no-evidence
34
36
  const PEV_TOOLUSE_THRESHOLD = 5;
37
+ // Tools that change state. Only these count toward PEV-skip โ€” an agentic
38
+ // session trivially racks up 5+ *read* tool calls (Read/Grep/Glob) while
39
+ // researching, which is exactly the behavior we don't want to punish.
40
+ const MUTATING_TOOL_RE = /^(edit|write|multiedit|notebookedit|bash)$/i;
35
41
 
36
42
  function stateDir() {
37
43
  if (process.platform === 'win32') {
@@ -167,39 +173,42 @@ function findRatchetCandidates(entries) {
167
173
  * the immediate next user message also count as "shown the work."
168
174
  */
169
175
  function computeEvidenceRate(entries) {
170
- const recent = entries.slice(-RECENT_TURNS * 2); // both user/assistant
171
- const assistants = [];
172
- for (let i = 0; i < recent.length; i++) {
173
- const e = recent[i];
174
- if (e && e.type === 'assistant') {
175
- const text = assistantText(e.message);
176
- let proof = looksLikeEvidence(text);
177
- // If the *next* entry is a user message with tool_result blocks, count
178
- // that as evidence for the assistant turn that triggered it.
179
- const next = recent[i + 1];
180
- if (!proof && next && next.type === 'user' && toolResultsIn(next.message).length > 0) {
181
- proof = true;
182
- }
183
- assistants.push(proof);
176
+ // Window by *assistant turns*, not raw JSONL entries โ€” one agentic turn can
177
+ // span dozens of entries, so an entry-sliced window covered only 1-2 real
178
+ // turns and made the rate jumpy.
179
+ const idxs = [];
180
+ for (let i = 0; i < entries.length; i++) {
181
+ if (entries[i] && entries[i].type === 'assistant') idxs.push(i);
182
+ }
183
+ const recentIdxs = idxs.slice(-RECENT_TURNS);
184
+ if (recentIdxs.length === 0) return null;
185
+ let proofCount = 0;
186
+ for (const i of recentIdxs) {
187
+ const text = assistantText(entries[i].message);
188
+ let proof = looksLikeEvidence(text);
189
+ // If the *next* entry is a user message with tool_result blocks, count
190
+ // that as evidence for the assistant turn that triggered it.
191
+ const next = entries[i + 1];
192
+ if (!proof && next && next.type === 'user' && toolResultsIn(next.message).length > 0) {
193
+ proof = true;
184
194
  }
195
+ if (proof) proofCount++;
185
196
  }
186
- if (assistants.length === 0) return null;
187
- const proofCount = assistants.filter(Boolean).length;
188
- return proofCount / assistants.length;
197
+ return proofCount / recentIdxs.length;
189
198
  }
190
199
 
191
200
  function computePevSkip(entries) {
192
- const recent = entries.slice(-RECENT_TURNS);
193
- let toolUseCount = 0;
201
+ const assistants = entries.filter((e) => e && e.type === 'assistant');
202
+ const recent = assistants.slice(-RECENT_TURNS);
203
+ let mutatingCount = 0;
194
204
  let planSignal = false;
195
205
  for (const e of recent) {
196
- if (!e || e.type !== 'assistant') continue;
197
206
  const text = assistantText(e.message);
198
207
  const tus = toolUsesIn(e.message);
199
- toolUseCount += tus.length;
208
+ mutatingCount += tus.filter((t) => MUTATING_TOOL_RE.test(t.name || '')).length;
200
209
  if (looksLikePlanSignal(text, tus)) planSignal = true;
201
210
  }
202
- return toolUseCount >= PEV_TOOLUSE_THRESHOLD && !planSignal;
211
+ return mutatingCount >= PEV_TOOLUSE_THRESHOLD && !planSignal;
203
212
  }
204
213
 
205
214
  function analyzeTranscript(transcriptPath, opts) {
package/src/harness.js CHANGED
@@ -101,7 +101,9 @@ function statusForFile(filePath) {
101
101
  try {
102
102
  content = readFileSync(filePath, 'utf8');
103
103
  } catch {
104
- return { configured: 0, total: HARNESS_SECTIONS.length, missing: [], hasBlock: false, hasFile: true, optOut: false, custom: false, file: filePath };
104
+ // Unreadable file (permissions, etc.) โ€” report every section missing so
105
+ // `harness check` can't print "All 5 sections present โœ…" over a 0/5.
106
+ return { configured: 0, total: HARNESS_SECTIONS.length, missing: HARNESS_SECTIONS.map((s) => s.id), hasBlock: false, hasFile: true, optOut: false, custom: false, file: filePath };
105
107
  }
106
108
  const hasBlock = content.includes(HARNESS_BLOCK_BEGIN);
107
109
  // Opt-out marker โ€” when the user intentionally customizes the harness block
@@ -334,13 +336,24 @@ export function harnessStatusForStatusline(cfg, { root } = {}) {
334
336
  if (!status.hasFile && !existsSync(join(projectRoot, '.claude'))) return null;
335
337
  if (status.optOut) return null;
336
338
  // Attach a warning derived from the analyzer state file (if any). Precedence:
337
- // ratchet? > no-evidence > PEV-skip. Only surfaces when the state's
338
- // sessionId or cwd matches this project, so unrelated sessions don't leak.
339
+ // ratchet? > no-evidence > PEV-skip. Guards, in order:
340
+ // - freshness: the hook rewrites the state on every tool use, so anything
341
+ // older than WARNING_TTL_MS is a dead session's leftovers โ€” a red ๐Ÿ…ทโš 
342
+ // must never linger for days after the triggering session ended.
343
+ // - project match: state.cwd is the *session* cwd, which may be a subdir
344
+ // of the repo, while projectRoot is the walked-up root. Normalize both
345
+ // through findProjectRoot so launching Claude Code in a subdirectory
346
+ // still surfaces (and correctly scopes) the warning. A state with no
347
+ // cwd at all is unattributable โ€” stay silent rather than leak it into
348
+ // every project.
349
+ const WARNING_TTL_MS = 30 * 60 * 1000;
339
350
  const state = readHarnessState();
340
351
  let warning = null;
341
352
  if (state) {
342
- const matches = (state.cwd && state.cwd === projectRoot) || !state.cwd;
343
- if (matches) {
353
+ const ts = state.timestamp ? Date.parse(state.timestamp) : NaN;
354
+ const fresh = Number.isFinite(ts) && Date.now() - ts <= WARNING_TTL_MS;
355
+ const matches = !!state.cwd && findProjectRoot(state.cwd) === projectRoot;
356
+ if (fresh && matches) {
344
357
  if (state.ratchetCandidate && state.ratchetCandidate.count >= 2) {
345
358
  const id = state.ratchetCandidate.id || 1;
346
359
  warning = `ratchet? #${id}`;