cc-cream 0.1.17 → 0.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/CHANGELOG.md CHANGED
@@ -4,6 +4,33 @@ All notable changes to cc-cream are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.2.0] — 2026-05-30
8
+
9
+ ### Added
10
+ - **`cc-cream-setup --check-config`** lints `~/.claude/cc-cream.json` and reports unknown keys and out-of-domain values — the fields the renderer silently falls back to defaults for. Exits non-zero when there's something to fix, so a typo'd key ("`ambre`", "`colour`") is no longer a silent no-op.
11
+ - **`CC_CREAM_DEBUG=1` opt-in diagnostics.** When the bar is unexpectedly empty or short, set it to log — to `~/.claude/cc-cream-debug.log` (override with `CC_CREAM_DEBUG_LOG`) — which on-by-default segments rendered and which were dropped, plus the resolved TTL window and stdin size. Claude Code discards status-line stderr, so the channel is a file; **stdout stays untouched** (zero tokens). Off by default: no file, no overhead.
12
+
13
+ ### Changed
14
+ - **The plugin status line no longer resolves its version with a shell glob.** The wired command was `ls … | grep -E … | sort -V | tail -1` over the plugin cache, run on every render — it depended on GNU `sort -V` (not guaranteed in the status-line subprocess, notably on macOS) and reverse-engineered Claude Code's undocumented cache layout. `${CLAUDE_PLUGIN_ROOT}` doesn't expand in the status-line command context, so the command can't discover the current version itself. Instead the command now bakes the current version's **absolute** `cc-cream.js` path, and the `SessionStart` hook — which *does* receive `${CLAUDE_PLUGIN_ROOT}` — re-pins it after a `/plugin update`. Both install modes now share one command shape (`[ -f "<entrypoint>" ] || exit 0; exec "<node>" "<entrypoint>"`); the `[ -f … ]` guard preserves the silent exit-0 when the plugin cache is deleted out from under a stale line.
15
+
16
+ ### Internal
17
+ - **Settings.json read/parse/atomic-write logic is shared** between the installer and the `SessionStart` hook via a new `src/settings.js`, instead of a copy in each.
18
+ - **Segment rendering is now pure.** The TTL anchor (including its `transcript_path` `statSync`) is resolved once in the I/O layer (`cc-cream.js`) and injected into `render()`, so `src/segments.js` no longer performs any filesystem access.
19
+ - **Config normalization is now a single schema table** (`src/config.js`) instead of an ad-hoc per-field conditional ladder. The same table powers `--check-config`, so validation rules live in one place.
20
+
21
+ ## [0.1.18] — 2026-05-29
22
+
23
+ ### Security
24
+ - **Status-line text is now stripped of terminal control characters before output.** Three stdin-derived fields — `model.display_name`, `session_name`, and `effort.level` — were written to the terminal verbatim on every render. Because `session_name` can be derived from conversation content (which may include untrusted material), an embedded ANSI/OSC escape sequence would have been interpreted by the terminal (window-title/clipboard rewrites via OSC, or cursor/erase sequences that spoof or hide output). `paint()` now passes every segment through a `sanitize()` pass that drops C0/C1 control bytes (incl. ESC, BEL, DEL) while preserving the tool's own color codes, which are added afterward. The bar is purely visual, so the strip is lossless.
25
+ - **A crafted `session_id` can no longer corrupt the session-state map.** `session_id` is used as an object key; values of `__proto__`/`constructor`/`prototype` are now rejected in `getSessionState`/`patchSessionState`, and reads use `Object.hasOwn`.
26
+
27
+ ### Fixed
28
+ - **Writes to `settings.json` (and the state file) are now atomic.** `install.js`, the `SessionStart` auto-setup hook, and `state.js` wrote via a direct `writeFileSync` over the live file; an interruption (crash, `ENOSPC`) mid-write could truncate `settings.json` and erase the user's permissions/hooks/plugins/MCP config — the very loss `readSettings` works to avoid. They now write a sibling temp file and `rename` it over the target (atomic within a filesystem; the temp shares the target's directory so the rename never crosses devices).
29
+ - **The plugin auto-update command now quotes the node binary path** (`exec "${nodePath}"`), so a node path containing spaces no longer breaks the status line.
30
+
31
+ ### Changed
32
+ - **The session-state map is capped at 50 entries**, evicting the least-recently-touched sessions. It previously gained one key per `session_id` and was never pruned, growing without bound.
33
+
7
34
  ## [0.1.17] — 2026-05-29
8
35
 
9
36
  ### Fixed
@@ -142,6 +169,7 @@ line and prints a colored ≤3-row bar — zero tokens, the model never sees it.
142
169
  - Supports **macOS and Linux**; Windows is a planned fast-follow.
143
170
  - Requires Claude Code **2.1.132+** (`effort` / `thinking` need 2.1.145+).
144
171
 
172
+ [0.1.18]: https://github.com/bart-turczynski/cc-cream/compare/v0.1.17...v0.1.18
145
173
  [0.1.17]: https://github.com/bart-turczynski/cc-cream/compare/v0.1.16...v0.1.17
146
174
  [0.1.16]: https://github.com/bart-turczynski/cc-cream/compare/v0.1.15...v0.1.16
147
175
  [0.1.15]: https://github.com/bart-turczynski/cc-cream/compare/v0.1.14...v0.1.15
package/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # cc-cream
2
2
 
3
+ [![CI](https://img.shields.io/github/actions/workflow/status/bart-turczynski/cc-cream/ci.yml?branch=main&label=CI)](https://github.com/bart-turczynski/cc-cream/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/cc-cream)](https://www.npmjs.com/package/cc-cream)
5
+ [![Socket Badge](https://socket.dev/api/badge/npm/package/cc-cream)](https://socket.dev/npm/package/cc-cream)
6
+ [![install size](https://img.shields.io/bundlephobia/minzip/cc-cream)](https://bundlephobia.com/package/cc-cream)
7
+ [![License: MIT](https://img.shields.io/npm/l/cc-cream)](https://github.com/bart-turczynski/cc-cream/blob/main/LICENSE)
8
+
3
9
  **C.R.E.A.M. — Cache Rules Everything Around Me.**
4
10
 
5
11
  A lightweight status-line tool for [Claude Code](https://claude.com/claude-code)
@@ -116,6 +122,13 @@ The installer:
116
122
  After install, Claude Code must be **trusted** for the directory (if prompted),
117
123
  and you may need to **restart** it for the bar to appear.
118
124
 
125
+ > **Pick one install method.** If you wire cc-cream via npm/manual (Options 2–3)
126
+ > and *then* install the plugin, the plugin won't take over the existing wiring —
127
+ > it points at your home copy, so `/plugin update` won't auto-update the bar. To
128
+ > switch to the auto-updating plugin, run `/cc-cream:setup` (or `cc-cream-setup
129
+ > --uninstall` first). Nothing breaks either way; it just stays on whichever
130
+ > method wired it.
131
+
119
132
  ### Uninstall
120
133
 
121
134
  Plugin users — two steps, **in this order** (Claude Code can't clean
@@ -160,6 +173,13 @@ or ask Claude to. It is strict JSON with no comments. **Every field falls back t
160
173
  its built-in default if missing or malformed** — a typo degrades one value rather
161
174
  than breaking the bar; a whole-file parse error falls back to all defaults.
162
175
 
176
+ Because unknown or out-of-range fields are silently ignored, run the doctor after
177
+ editing by hand to catch typos:
178
+
179
+ ```bash
180
+ cc-cream-setup --check-config # reports unknown keys / out-of-domain values; exits non-zero if any
181
+ ```
182
+
163
183
  ```json
164
184
  {
165
185
  "numbers": "compact",
@@ -272,6 +292,21 @@ Default: `amber: 75`, `red: 90` (absolute `used_percentage`).
272
292
  Anthropic's faster-drain window. Defaults `5`–`11`. Weekday-only (Mon–Fri) and
273
293
  the `America/Los_Angeles` timezone are hardcoded policy facts, not config.
274
294
 
295
+ ## Troubleshooting
296
+
297
+ cc-cream is built to degrade silently — if a stdin field is missing or malformed
298
+ it just hides that segment rather than crashing. When the bar is unexpectedly
299
+ empty or shorter than you expect, turn on diagnostics:
300
+
301
+ ```bash
302
+ export CC_CREAM_DEBUG=1 # then trigger a render in Claude Code
303
+ ```
304
+
305
+ Each render appends a line to `~/.claude/cc-cream-debug.log` (override the path
306
+ with `CC_CREAM_DEBUG_LOG`) listing which segments rendered, which were dropped,
307
+ the resolved TTL window, and the stdin size. It never writes to the status line
308
+ itself, so it costs zero tokens. Unset the variable to turn it off.
309
+
275
310
  ## Development
276
311
 
277
312
  See [CONTRIBUTING.md](CONTRIBUTING.md) for how to run the tests. The runtime
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-cream",
3
- "version": "0.1.17",
3
+ "version": "0.2.0",
4
4
  "description": "Claude Code cache/context/cost status-line tool",
5
5
  "directories": {
6
6
  "doc": "docs"
package/src/cc-cream.js CHANGED
@@ -3,11 +3,12 @@
3
3
  // Reads the session JSON Claude Code pipes on stdin and prints a colored
4
4
  // <=3-row bar. Hard rule: degrade, never crash.
5
5
 
6
+ import fs from 'node:fs';
6
7
  import os from 'node:os';
7
8
  import path from 'node:path';
8
9
  import process from 'node:process';
9
10
  import { loadConfig, readConfigFile } from './config.js';
10
- import { render } from './render.js';
11
+ import { buildSegments, render } from './render.js';
11
12
  import {
12
13
  getSessionState,
13
14
  nextSessionPatch,
@@ -15,7 +16,7 @@ import {
15
16
  readState,
16
17
  writeState,
17
18
  } from './state.js';
18
- import { isEntrypoint } from './utils.js';
19
+ import { isEntrypoint, isNum } from './utils.js';
19
20
 
20
21
  export { DEFAULTS } from './defaults.js';
21
22
  export { loadConfig } from './config.js';
@@ -51,8 +52,63 @@ function nowFromEnv(env) {
51
52
  return rawNow && Number.isFinite(Number(rawNow)) ? Number(rawNow) : Date.now();
52
53
  }
53
54
 
55
+ // Resolve when the cache TTL window last reset, in epoch ms (or null to hide the
56
+ // ttl segment). This is the ONLY filesystem read on the render path — kept here
57
+ // in the I/O layer so render.js and the segments stay pure. Priority: token
58
+ // growth this turn (reset is now) → the last recorded API timestamp → the
59
+ // transcript file's mtime as a last resort.
60
+ function resolveTtlAnchor(data, prevSessionState, now) {
61
+ const curTokens = data?.context_window?.total_input_tokens;
62
+ const prevTokens = prevSessionState?.total_input_tokens;
63
+ if (isNum(curTokens) && isNum(prevTokens) && curTokens > prevTokens) return now;
64
+ if (isNum(prevSessionState?.last_api_ts)) return prevSessionState.last_api_ts;
65
+ const tp = data?.transcript_path;
66
+ if (typeof tp !== 'string' || tp === '') return null;
67
+ try {
68
+ return fs.statSync(tp).mtimeMs;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ // CC_CREAM_DEBUG is opt-in diagnostics. Claude Code SILENTLY DISCARDS statusLine
75
+ // stderr (it's only surfaced under `claude --debug`, first invocation), so the
76
+ // channel is a log FILE — never stdout, which would cost tokens / corrupt the
77
+ // bar. CC_CREAM_DEBUG_LOG overrides the path (used by tests).
78
+ const debugEnabled = (env) => {
79
+ const v = env.CC_CREAM_DEBUG;
80
+ return typeof v === 'string' && v !== '' && v !== '0' && v.toLowerCase() !== 'false';
81
+ };
82
+
83
+ function writeDebug(env, lines) {
84
+ const file = env.CC_CREAM_DEBUG_LOG || path.join(os.homedir(), '.claude', 'cc-cream-debug.log');
85
+ try {
86
+ fs.appendFileSync(file, `${lines.join('\n')}\n`);
87
+ } catch {
88
+ // diagnostics must never affect the render — swallow any write failure
89
+ }
90
+ }
91
+
92
+ // Record why the bar looks the way it does: which on-by-config segments rendered
93
+ // and which were dropped (the usual reason a bar is shorter/emptier than
94
+ // expected — a missing or malformed stdin field). Recomputes the segment map
95
+ // through buildSegments() so it can never diverge from what render() drew.
96
+ function logDebug(env, { data, cfg, now, prevSessionState, sessionId, rawLen, ttlAnchorMs, out }) {
97
+ const { ttlMin, segs } = buildSegments(data, cfg, env, now, prevSessionState, ttlAnchorMs);
98
+ const onIds = Object.keys(cfg.segments).filter((id) => cfg.segments[id].on);
99
+ const visible = onIds.filter((id) => segs[id]);
100
+ const hidden = onIds.filter((id) => !segs[id]);
101
+ writeDebug(env, [
102
+ `[${new Date(now).toISOString()}] session=${sessionId ?? 'none'} stdinBytes=${rawLen} ttlMin=${ttlMin} ttlAnchor=${ttlAnchorMs ?? 'none'}`,
103
+ ` output=${out ? JSON.stringify(out) : '<empty>'}`,
104
+ ` visible=[${visible.join(',')}]`,
105
+ ` hidden(on-but-absent)=[${hidden.join(',')}]`,
106
+ ]);
107
+ }
108
+
54
109
  async function main() {
55
- const data = parseSession(await readStdin());
110
+ const raw = await readStdin();
111
+ const data = parseSession(raw);
56
112
  const cfg = loadConfig(readConfigFile());
57
113
  const now = nowFromEnv(process.env);
58
114
 
@@ -61,9 +117,14 @@ async function main() {
61
117
  const state = sessionId ? readState(stateFile) : {};
62
118
  const prevSessionState = getSessionState(state, sessionId);
63
119
 
64
- const out = render(data, cfg, process.env, now, prevSessionState);
120
+ const ttlAnchorMs = resolveTtlAnchor(data, prevSessionState, now);
121
+ const out = render(data, cfg, process.env, now, prevSessionState, ttlAnchorMs);
65
122
  if (out) process.stdout.write(`${out}\n`);
66
123
 
124
+ if (debugEnabled(process.env)) {
125
+ logDebug(process.env, { data, cfg, now, prevSessionState, sessionId, rawLen: raw.length, ttlAnchorMs, out });
126
+ }
127
+
67
128
  if (sessionId) {
68
129
  const patch = nextSessionPatch(data, prevSessionState, cfg, now);
69
130
  writeState(stateFile, patchSessionState(state, sessionId, patch));
package/src/config.js CHANGED
@@ -4,6 +4,10 @@ import path from 'node:path';
4
4
  import { DEFAULTS } from './defaults.js';
5
5
  import { clone, isNum, numOr } from './utils.js';
6
6
 
7
+ // Each normalizer is `(value, fallback) => value | fallback`: it returns the
8
+ // value when it's in-domain, else the fallback. The same table drives BOTH the
9
+ // forgiving merge (fallback = the default) and the --check-config doctor
10
+ // (fallback = a sentinel, so a returned sentinel means "rejected").
7
11
  const boolOr = (v, d) => (typeof v === 'boolean' ? v : d);
8
12
  const rowOr = (v, d) => (v === 1 || v === 2 || v === 3 ? v : d);
9
13
  const posOr = (v, d) => (isNum(v) && v > 0 ? v : d); // a ceiling of 0/neg would divide-by-zero
@@ -11,6 +15,7 @@ const basisOr = (v, d) => (v === 'window' || v === 'ceiling' ? v : d);
11
15
  const ctxDisplayOr = (v, d) => (v === 'basis' || v === 'window' ? v : d);
12
16
  const hourOr = (v, d) => (isNum(v) && v >= 0 && v <= 23 ? v : d);
13
17
  const percentageOr = (v, d) => (v === 'consumed' || v === 'remaining' ? v : d);
18
+ const numbersOr = (v, d) => (v === 'compact' || v === 'exact' ? v : d);
14
19
 
15
20
  function ttlOr(v, d) {
16
21
  if (v === 'auto') return 'auto';
@@ -19,11 +24,37 @@ function ttlOr(v, d) {
19
24
  return d;
20
25
  }
21
26
 
27
+ // Top-level config keys and their normalizers (besides `segments`, handled below).
28
+ const TOP_LEVEL = {
29
+ numbers: numbersOr,
30
+ ttl: ttlOr,
31
+ percentage: percentageOr,
32
+ };
33
+
34
+ // Per-segment field normalizers. The set of fields VALID for a given segment is
35
+ // that segment's own keys in DEFAULTS (so `ctx` accepts `ceiling`, `peak`
36
+ // accepts `start`/`end`, etc.); this table just says how each is normalized.
37
+ const SEGMENT_FIELDS = {
38
+ on: boolOr,
39
+ row: rowOr,
40
+ order: numOr,
41
+ amber: numOr,
42
+ orange: numOr,
43
+ red: numOr,
44
+ drop: posOr,
45
+ drop_recover: posOr,
46
+ basis: basisOr,
47
+ ceiling: posOr,
48
+ display: ctxDisplayOr,
49
+ start: hourOr,
50
+ end: hourOr,
51
+ };
52
+
22
53
  function mergeConfig(parsed) {
23
54
  const cfg = clone(DEFAULTS);
24
- cfg.numbers = parsed.numbers === 'compact' || parsed.numbers === 'exact' ? parsed.numbers : DEFAULTS.numbers;
25
- cfg.ttl = ttlOr(parsed.ttl, DEFAULTS.ttl);
26
- cfg.percentage = percentageOr(parsed.percentage, DEFAULTS.percentage);
55
+ for (const [key, norm] of Object.entries(TOP_LEVEL)) {
56
+ cfg[key] = norm(parsed[key], DEFAULTS[key]);
57
+ }
27
58
 
28
59
  const segs = parsed.segments;
29
60
  if (segs && typeof segs === 'object' && !Array.isArray(segs)) {
@@ -32,19 +63,10 @@ function mergeConfig(parsed) {
32
63
  const s = segs[id];
33
64
  const out = clone(def);
34
65
  if (s && typeof s === 'object' && !Array.isArray(s)) {
35
- out.on = boolOr(s.on, def.on);
36
- out.row = rowOr(s.row, def.row);
37
- out.order = numOr(s.order, def.order);
38
- if ('amber' in def) out.amber = numOr(s.amber, def.amber);
39
- if ('orange' in def) out.orange = numOr(s.orange, def.orange);
40
- if ('red' in def) out.red = numOr(s.red, def.red);
41
- if ('drop' in def) out.drop = posOr(s.drop, def.drop);
42
- if ('drop_recover' in def) out.drop_recover = posOr(s.drop_recover, def.drop_recover);
43
- if ('basis' in def) out.basis = basisOr(s.basis, def.basis);
44
- if ('ceiling' in def) out.ceiling = posOr(s.ceiling, def.ceiling);
45
- if ('display' in def) out.display = ctxDisplayOr(s.display, def.display);
46
- if ('start' in def) out.start = hourOr(s.start, def.start);
47
- if ('end' in def) out.end = hourOr(s.end, def.end);
66
+ for (const field of Object.keys(def)) {
67
+ const norm = SEGMENT_FIELDS[field];
68
+ if (norm) out[field] = norm(s[field], def[field]);
69
+ }
48
70
  }
49
71
  cfg.segments[id] = out;
50
72
  }
@@ -65,6 +87,59 @@ export function loadConfig(raw) {
65
87
  return mergeConfig(parsed);
66
88
  }
67
89
 
90
+ // Diagnose a parsed config object: report unknown keys and out-of-domain values
91
+ // using the same schema table the merge uses. Returns a list of human-readable
92
+ // problems (empty = clean). The runtime silently ignores these (per-field
93
+ // fallback); the doctor surfaces them so a typo'd key isn't a silent no-op.
94
+ export function checkConfig(parsed) {
95
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
96
+ return ['cc-cream.json must be a JSON object.'];
97
+ }
98
+ const problems = [];
99
+ const INVALID = Symbol('invalid');
100
+
101
+ for (const key of Object.keys(parsed)) {
102
+ if (key === 'segments') continue;
103
+ const norm = TOP_LEVEL[key];
104
+ if (!norm) {
105
+ problems.push(`unknown top-level key: "${key}"`);
106
+ } else if (norm(parsed[key], INVALID) === INVALID) {
107
+ problems.push(`out-of-domain value for "${key}": ${JSON.stringify(parsed[key])}`);
108
+ }
109
+ }
110
+
111
+ const segs = parsed.segments;
112
+ if (segs !== undefined) {
113
+ if (!segs || typeof segs !== 'object' || Array.isArray(segs)) {
114
+ problems.push('"segments" must be an object.');
115
+ } else {
116
+ for (const id of Object.keys(segs)) {
117
+ if (!(id in DEFAULTS.segments)) {
118
+ problems.push(`unknown segment: "${id}"`);
119
+ continue;
120
+ }
121
+ const s = segs[id];
122
+ if (!s || typeof s !== 'object' || Array.isArray(s)) {
123
+ problems.push(`segment "${id}" must be an object.`);
124
+ continue;
125
+ }
126
+ const def = DEFAULTS.segments[id];
127
+ for (const field of Object.keys(s)) {
128
+ if (!(field in def)) {
129
+ problems.push(`unknown field on "${id}": "${field}"`);
130
+ continue;
131
+ }
132
+ const norm = SEGMENT_FIELDS[field];
133
+ if (norm && norm(s[field], INVALID) === INVALID) {
134
+ problems.push(`out-of-domain value for "${id}.${field}": ${JSON.stringify(s[field])}`);
135
+ }
136
+ }
137
+ }
138
+ }
139
+ }
140
+ return problems;
141
+ }
142
+
68
143
  export function readConfigFile() {
69
144
  try {
70
145
  return fs.readFileSync(path.join(os.homedir(), '.claude', 'cc-cream.json'), 'utf8');
package/src/install.js CHANGED
@@ -14,34 +14,42 @@ import os from 'node:os';
14
14
  import path from 'node:path';
15
15
  import process from 'node:process';
16
16
  import readline from 'node:readline';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { checkConfig } from './config.js';
19
+ import { isSafeToWrite, readSettings as readSettingsFile, writeFileAtomic } from './settings.js';
17
20
  import { isEntrypoint } from './utils.js';
18
21
 
22
+ export { writeFileAtomic } from './settings.js';
23
+
19
24
  const TRUST_NOTE =
20
25
  'Claude Code must be trusted and possibly restarted for the status line to appear.';
21
26
 
22
- // The cache-glob auto-update command (docs/RELEASE_PLAN.md "Auto-update mechanism").
27
+ // The statusLine command: a missing-file guard, then exec node on an ABSOLUTE
28
+ // entrypoint. Both install modes use this one shape — only the entrypoint path
29
+ // differs (plugin cache vs the copied home runtime).
30
+ //
23
31
  // `nodePath` is the ABSOLUTE node binary, resolved once at setup time — the
24
32
  // statusLine subprocess may not inherit the user's PATH, so a bare `node` is
25
- // unsafe. The `-d .../*/` glob yields directory paths with a trailing slash, so
26
- // `src/cc-cream.js` concatenates directly. The `grep` keeps ONLY semver-named
27
- // dirs (e.g. `0.1.10/`) before `sort -V | tail -1` picks the highest — without
28
- // it, a non-numeric cache dir (a git-sha install like `c83650b6360f/`) sorts
29
- // last and pins the bar to whatever version that happens to be, defeating
30
- // auto-update. With it, `/plugin update` is applied live with no re-run of setup.
33
+ // unsafe. `entrypoint` is the absolute path to cc-cream.js.
31
34
  //
32
- // The resolved dir is captured in `$d` and GUARDED with `[ -z "$d" ] && exit 0`:
33
- // when the glob matches nothing the state left behind if a user runs
34
- // `/plugin uninstall cc-cream` WITHOUT first running `/cc-cream:uninstall`, so a
35
- // stale statusLine outlives the deleted cachethe command degrades to a silent
36
- // exit 0 instead of running a bare relative `src/cc-cream.js` that crashes with
35
+ // Why no version resolution here: `${CLAUDE_PLUGIN_ROOT}` does NOT expand in the
36
+ // statusLine command context (only in hook/MCP/command contexts verified), so
37
+ // the command can't discover the current plugin version at render time. Instead
38
+ // the SessionStart hookwhich DOES get `${CLAUDE_PLUGIN_ROOT}` bakes the
39
+ // current version's absolute path here and re-pins it after `/plugin update`.
40
+ //
41
+ // The `[ -f "<entrypoint>" ] || exit 0` guard degrades to a silent exit 0 when
42
+ // the entrypoint is gone — the state left behind if a user runs `/plugin
43
+ // uninstall cc-cream` WITHOUT first running `/cc-cream:uninstall`, so a stale
44
+ // statusLine outlives the deleted cache. Without it, node would crash with
37
45
  // MODULE_NOT_FOUND on every render. "Degrade, never crash" (CLAUDE.md). `exec`
38
46
  // replaces the shell so stdin/stdout pass straight through to the renderer.
39
- export function autoUpdateCommand(nodePath) {
40
- return `d="$(ls -1d "\${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/*/cc-cream/*/ 2>/dev/null | grep -E '/[0-9]+(\\.[0-9]+)+/$' | sort -V | tail -1)"; [ -z "$d" ] && exit 0; exec ${nodePath} "\${d}src/cc-cream.js"`;
47
+ export function statusLineCommand(nodePath, entrypoint) {
48
+ return `[ -f "${entrypoint}" ] || exit 0; exec "${nodePath}" "${entrypoint}"`;
41
49
  }
42
50
 
43
51
  // `desired` is considered already installed if it matches the planned command
44
- // verbatim (so switching strategy or node path re-plans), at refreshInterval 60.
52
+ // verbatim (so a changed version path or node path re-plans), at refreshInterval 60.
45
53
  function isInstalled(existing, command) {
46
54
  return (
47
55
  !!existing &&
@@ -90,21 +98,19 @@ export function planUninstall(settings) {
90
98
  }
91
99
 
92
100
  // Decide what to do. Returns { settings, changed, messages, needsConsent }.
93
- // `consent` is the user's yes/no when an existing statusLine must be replaced.
101
+ // `consent` is the user's yes/no when a FOREIGN statusLine must be replaced.
94
102
  //
95
- // Two command strategies:
96
- // - manual (default): `node <entrypoint>` pointing at the copied-to-home runtime.
97
- // - plugin: the cache-glob auto-update command, using the absolute `nodePath`.
98
- // Pass `{ plugin: true, nodePath }` to select it; the plugin cache IS the
99
- // install, so no files are copied to home in that mode.
100
- export function plan(settings, { entrypoint, consent, plugin = false, nodePath } = {}) {
103
+ // Both install modes produce the same command shape (statusLineCommand); only
104
+ // the entrypoint differs:
105
+ // - manual (default): the copied-to-home runtime (~/.claude/cc-cream/cc-cream.js).
106
+ // - plugin: the versioned plugin-cache entrypoint (install.js's sibling).
107
+ // Pass `{ entrypoint, nodePath }` for either.
108
+ export function plan(settings, { entrypoint, consent, nodePath } = {}) {
101
109
  const s = settings && typeof settings === 'object' ? settings : {};
102
110
  const existing = s.statusLine;
103
111
  const messages = [];
104
112
 
105
- const command = plugin
106
- ? autoUpdateCommand(nodePath)
107
- : `node ${entrypoint}`;
113
+ const command = statusLineCommand(nodePath, entrypoint);
108
114
  const desired = { type: 'command', command, refreshInterval: 60 };
109
115
  // Preserve any user padding — it shrinks the 80-col budget (PRD §7).
110
116
  if (existing && typeof existing === 'object' && existing.padding !== undefined) {
@@ -116,9 +122,11 @@ export function plan(settings, { entrypoint, consent, plugin = false, nodePath }
116
122
  return { settings: s, changed: false, messages, needsConsent: false };
117
123
  }
118
124
 
119
- // An existing (different) statusLine must be confirmed before replacing.
120
- const hasExisting = existing && typeof existing === 'object';
121
- if (hasExisting) {
125
+ // A FOREIGN statusLine must be confirmed before replacing. Replacing our OWN
126
+ // out-of-date line (e.g. re-pinning to a new version after /plugin update)
127
+ // needs no consent — it's ours to maintain.
128
+ const hasForeign = existing && typeof existing === 'object' && !isCcCreamStatusLine(existing);
129
+ if (hasForeign) {
122
130
  messages.push('An existing statusLine is configured.');
123
131
  messages.push('Replace it with cc-cream?');
124
132
  if (consent !== true) {
@@ -129,7 +137,7 @@ export function plan(settings, { entrypoint, consent, plugin = false, nodePath }
129
137
 
130
138
  messages.push('Setting the cc-cream statusLine.');
131
139
  messages.push(TRUST_NOTE);
132
- return { settings: { ...s, statusLine: desired }, changed: true, messages, needsConsent: hasExisting };
140
+ return { settings: { ...s, statusLine: desired }, changed: true, messages, needsConsent: hasForeign };
133
141
  }
134
142
 
135
143
  // ---------------------------------------------------------------------------
@@ -143,30 +151,26 @@ function destinationPath() {
143
151
  return path.join(os.homedir(), '.claude', 'cc-cream', 'cc-cream.js');
144
152
  }
145
153
 
146
- // Read settings.json safely. A MISSING or empty file -> {} (fresh start, nothing
147
- // to lose). A file that exists with content but fails to parse, or parses to a
148
- // non-object, is REFUSED: we exit rather than overwrite and erase the user's
149
- // other settings (permissions, hooks, plugins...). This guards the one path
150
- // where a blind write would be destructive.
154
+ // Read settings.json safely for the CLI. A MISSING or empty file -> {} (fresh
155
+ // start, nothing to lose); a valid object is returned as-is. Any other state
156
+ // (corrupt JSON, a non-object, or an unreadable file) is REFUSED: we exit rather
157
+ // than overwrite and erase the user's other settings (permissions, hooks,
158
+ // plugins...). This guards the one path where a blind write would be destructive.
151
159
  function readSettings(file) {
152
- if (!fs.existsSync(file)) return {};
153
- const raw = fs.readFileSync(file, 'utf8');
154
- if (raw.trim() === '') return {};
155
- let parsed;
156
- try {
157
- parsed = JSON.parse(raw);
158
- } catch {
160
+ const { state, value } = readSettingsFile(file);
161
+ if (isSafeToWrite(state)) return value;
162
+ if (state === 'corrupt') {
159
163
  console.error(`Error: ${file} is not valid JSON.`);
160
164
  console.error('Refusing to write it — that would erase your other settings.');
161
165
  console.error('Fix the JSON (or move the file aside) and re-run.');
162
- process.exit(1);
163
- }
164
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
166
+ } else if (state === 'nonobject') {
165
167
  console.error(`Error: ${file} does not contain a JSON object.`);
166
168
  console.error('Refusing to overwrite it. Move it aside and re-run if intended.');
167
- process.exit(1);
169
+ } else {
170
+ console.error(`Error: cannot read ${file}.`);
171
+ console.error('Refusing to overwrite it. Fix permissions (or move it aside) and re-run.');
168
172
  }
169
- return parsed;
173
+ process.exit(1);
170
174
  }
171
175
 
172
176
  function runtimeFiles(sourceFile) {
@@ -221,7 +225,7 @@ async function uninstall({ purge }) {
221
225
  const result = planUninstall(settings);
222
226
  for (const m of result.messages) console.log(m);
223
227
  if (result.changed) {
224
- fs.writeFileSync(file, `${JSON.stringify(result.settings, null, 2)}\n`);
228
+ writeFileAtomic(file, `${JSON.stringify(result.settings, null, 2)}\n`);
225
229
  console.log(`\nUpdated ${file}.`);
226
230
  }
227
231
 
@@ -260,8 +264,43 @@ async function uninstall({ purge }) {
260
264
  console.log('\nRestart Claude Code to drop the bar.');
261
265
  }
262
266
 
267
+ // `cc-cream-setup --check-config`: lint ~/.claude/cc-cream.json against the
268
+ // config schema, reporting unknown keys and out-of-domain values (which the
269
+ // renderer silently ignores). Exits non-zero when problems are found.
270
+ function checkConfigCli() {
271
+ const file = path.join(os.homedir(), '.claude', 'cc-cream.json');
272
+ if (!fs.existsSync(file)) {
273
+ console.log(`No config at ${file} — cc-cream uses its defaults. Nothing to check.`);
274
+ return;
275
+ }
276
+ const raw = fs.readFileSync(file, 'utf8');
277
+ if (raw.trim() === '') {
278
+ console.log(`${file} is empty — cc-cream uses its defaults. Nothing to check.`);
279
+ return;
280
+ }
281
+ let parsed;
282
+ try {
283
+ parsed = JSON.parse(raw);
284
+ } catch {
285
+ console.error(`Error: ${file} is not valid JSON — the whole file is ignored.`);
286
+ process.exit(1);
287
+ }
288
+ const problems = checkConfig(parsed);
289
+ if (problems.length === 0) {
290
+ console.log(`${file}: OK — config looks good.`);
291
+ return;
292
+ }
293
+ console.error(`${file}: ${problems.length} problem(s) — each falls back to the default:`);
294
+ for (const p of problems) console.error(` - ${p}`);
295
+ process.exit(1);
296
+ }
297
+
263
298
  async function main() {
264
299
  const args = process.argv.slice(2);
300
+ if (args.includes('--check-config')) {
301
+ checkConfigCli();
302
+ return;
303
+ }
265
304
  if (args.includes('--uninstall')) {
266
305
  await uninstall({ purge: args.includes('--purge') });
267
306
  return;
@@ -274,18 +313,21 @@ async function main() {
274
313
  const file = settingsPath();
275
314
  const settings = readSettings(file);
276
315
 
277
- // planOpts holds whatever the chosen strategy needs to build its command.
316
+ // planOpts holds the entrypoint + node path the command bakes in.
278
317
  let planOpts;
279
318
  if (plugin) {
280
- // Plugin mode: the plugin cache IS the install — do NOT copy to home. The
281
- // command self-resolves the latest cached version on every render.
282
- planOpts = { plugin: true, nodePath: resolveNodePath() };
319
+ // Plugin mode: the plugin cache IS the install — do NOT copy to home. Point
320
+ // the statusLine at this install.js's sibling cc-cream.js, i.e. the current
321
+ // version's absolute path in the plugin cache. The SessionStart hook re-pins
322
+ // it after a /plugin update (the command can't self-resolve the version).
323
+ const entrypoint = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'cc-cream.js');
324
+ planOpts = { entrypoint, nodePath: resolveNodePath() };
283
325
  } else {
284
326
  // Manual / GitHub mode: copy the runtime into ~/.claude/cc-cream and point
285
- // the statusLine at that copied entrypoint.
327
+ // the statusLine at that copied (stable) entrypoint.
286
328
  const sourceFile = positional[0]
287
329
  ? path.resolve(positional[0])
288
- : path.resolve(path.dirname(new URL(import.meta.url).pathname), 'cc-cream.js');
330
+ : path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'cc-cream.js');
289
331
 
290
332
  if (!fs.existsSync(sourceFile)) {
291
333
  console.error(`Error: cc-cream.js not found at ${sourceFile}`);
@@ -297,7 +339,7 @@ async function main() {
297
339
  if (copyRuntimeFiles(sourceFile, destDir)) {
298
340
  console.log(`Copied cc-cream runtime files to ${destDir}`);
299
341
  }
300
- planOpts = { entrypoint: dest };
342
+ planOpts = { entrypoint: dest, nodePath: resolveNodePath() };
301
343
  }
302
344
 
303
345
  let result = plan(settings, planOpts);
@@ -323,7 +365,7 @@ async function main() {
323
365
  for (const m of result.messages) console.log(m);
324
366
  if (result.changed) {
325
367
  fs.mkdirSync(path.dirname(file), { recursive: true });
326
- fs.writeFileSync(file, `${JSON.stringify(result.settings, null, 2)}\n`);
368
+ writeFileAtomic(file, `${JSON.stringify(result.settings, null, 2)}\n`);
327
369
  console.log(`\nWrote ${file}.`);
328
370
  }
329
371
  }
package/src/render.js CHANGED
@@ -3,12 +3,21 @@ import { renderSegments } from './segments.js';
3
3
  import { resolveTtl } from './ttl.js';
4
4
  import { paint } from './utils.js';
5
5
 
6
- // Assemble enabled+visible segments into up to three rows.
7
- export function render(data, cfg, env, now, prevSessionState = null) {
6
+ // Build the raw segment map (id {text,color}|null) plus the resolved ttlMin.
7
+ // Shared by render() and the CC_CREAM_DEBUG diagnostics so the segment logic runs
8
+ // through exactly one path. `ttlAnchorMs` is injected by the I/O layer.
9
+ export function buildSegments(data, cfg, env, now, prevSessionState = null, ttlAnchorMs = null) {
8
10
  const ttlMin = resolveTtl({ rateLimits: data?.rate_limits, config: cfg, env });
9
11
  // CC_CREAM_TZ is an internal test/diagnostic seam, not a documented config key.
10
12
  const tz = env?.CC_CREAM_TZ || 'America/Los_Angeles';
11
- const segs = renderSegments(data, cfg, ttlMin, now, prevSessionState, tz);
13
+ return { ttlMin, segs: renderSegments(data, cfg, ttlMin, now, prevSessionState, tz, ttlAnchorMs) };
14
+ }
15
+
16
+ // Assemble enabled+visible segments into up to three rows. `ttlAnchorMs` (the
17
+ // resolved cache-window reset time) is computed by the I/O layer and injected so
18
+ // render and the segments stay free of filesystem access.
19
+ export function render(data, cfg, env, now, prevSessionState = null, ttlAnchorMs = null) {
20
+ const { segs } = buildSegments(data, cfg, env, now, prevSessionState, ttlAnchorMs);
12
21
 
13
22
  const visible = (id, row) => cfg.segments[id]?.on && segs[id] && cfg.segments[id].row === row;
14
23
  const byOrder = (a, b) => cfg.segments[a].order - cfg.segments[b].order;
package/src/segments.js CHANGED
@@ -1,4 +1,3 @@
1
- import fs from 'node:fs';
2
1
  import { band, countdown, flipPct, fmtNum, isNum, isPeak, numOr, pad2 } from './utils.js';
3
2
  import { hasWindow } from './ttl.js';
4
3
 
@@ -56,25 +55,12 @@ function segCache(data, cfg, prevCachePct, recovering) {
56
55
  return { text: `cache:${pct}%`, color };
57
56
  }
58
57
 
59
- function segTtl(data, cfg, ttlMin, now, prevSessionState) {
60
- const prevTokens = prevSessionState?.total_input_tokens;
61
- const curTokens = data?.context_window?.total_input_tokens;
62
- const tokensGrew = isNum(curTokens) && isNum(prevTokens) && curTokens > prevTokens;
63
-
64
- let anchorMs;
65
- if (tokensGrew) {
66
- anchorMs = now;
67
- } else if (isNum(prevSessionState?.last_api_ts)) {
68
- anchorMs = prevSessionState.last_api_ts;
69
- } else {
70
- const tp = data?.transcript_path;
71
- if (typeof tp !== 'string' || tp === '') return null;
72
- try {
73
- anchorMs = fs.statSync(tp).mtimeMs;
74
- } catch {
75
- return null;
76
- }
77
- }
58
+ // Pure: the TTL anchor (when the cache window last reset) is resolved upstream in
59
+ // the I/O layer — see resolveTtlAnchor() in cc-cream.js — and injected as
60
+ // `anchorMs`, so this segment does no filesystem access. A null anchor (no token
61
+ // growth, no last_api_ts, no readable transcript) hides the segment.
62
+ function segTtl(cfg, ttlMin, now, anchorMs) {
63
+ if (!isNum(anchorMs)) return null;
78
64
  const elapsedMin = Math.floor(Math.max(0, now - anchorMs) / 60000);
79
65
  const remainingMin = Math.max(0, ttlMin - elapsedMin);
80
66
  const text = `ttl:${pad2(Math.floor(remainingMin / 60))}:${pad2(remainingMin % 60)}`;
@@ -158,7 +144,7 @@ function segBurn(fiveHour, prev, now) {
158
144
  return { text: h >= 1 ? `~${h}h${pad2(m)}m` : `~${minEta}m`, color: null };
159
145
  }
160
146
 
161
- export function renderSegments(data, cfg, ttlMin, now, prevSessionState = null, tz = 'America/Los_Angeles') {
147
+ export function renderSegments(data, cfg, ttlMin, now, prevSessionState = null, tz = 'America/Los_Angeles', ttlAnchorMs = null) {
162
148
  return {
163
149
  model: segModel(data),
164
150
  ctx: segCtx(data, cfg),
@@ -168,7 +154,7 @@ export function renderSegments(data, cfg, ttlMin, now, prevSessionState = null,
168
154
  prevSessionState && isNum(prevSessionState.cache_pct) ? prevSessionState.cache_pct : undefined,
169
155
  prevSessionState?.recovering === true,
170
156
  ),
171
- ttl: segTtl(data, cfg, ttlMin, now, prevSessionState),
157
+ ttl: segTtl(cfg, ttlMin, now, ttlAnchorMs),
172
158
  cost: segCost(data),
173
159
  '5h': segRate(data?.rate_limits?.five_hour, '5h', cfg, '5h', now),
174
160
  '7d': segRate(data?.rate_limits?.seven_day, '7d', cfg, '7d', now),
@@ -0,0 +1,64 @@
1
+ // Shared settings.json I/O for the installer (src/install.js) and the
2
+ // SessionStart hook (hooks/auto-setup.js). Both must read settings.json without
3
+ // ever destroying it, and write it atomically. Previously each carried its own
4
+ // copy of this safety-critical parsing; they live here once so they can't drift.
5
+ //
6
+ // `readSettings` does NOT decide policy — it classifies the file and lets each
7
+ // caller choose what to do (the installer refuses + exits on a corrupt file; the
8
+ // hook stays silent and leaves it alone). That split is the whole reason this is
9
+ // a classifier returning `{ state, value }` rather than a function that throws.
10
+
11
+ import fs from 'node:fs';
12
+ import process from 'node:process';
13
+
14
+ // Read and classify settings.json. Returns `{ state, value }`:
15
+ // missing — file absent → value {}
16
+ // empty — present but whitespace → value {}
17
+ // object — valid JSON object → value <parsed>
18
+ // nonobject — valid JSON, not an object → value null
19
+ // corrupt — invalid JSON → value null
20
+ // unreadable— read failed (perms, …) → value null
21
+ // The destructive-write guard lives in the caller: a non-{missing,empty,object}
22
+ // state means "we cannot safely overwrite this — it holds the user's other
23
+ // config (permissions, hooks, plugins, MCP)".
24
+ export function readSettings(file) {
25
+ let raw;
26
+ try {
27
+ raw = fs.readFileSync(file, 'utf8');
28
+ } catch (err) {
29
+ return err.code === 'ENOENT' ? { state: 'missing', value: {} } : { state: 'unreadable', value: null };
30
+ }
31
+ if (raw.trim() === '') return { state: 'empty', value: {} };
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(raw);
35
+ } catch {
36
+ return { state: 'corrupt', value: null };
37
+ }
38
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
39
+ return { state: 'nonobject', value: null };
40
+ }
41
+ return { state: 'object', value: parsed };
42
+ }
43
+
44
+ // True when `state` is safe to start from and overwrite (the file holds nothing
45
+ // to lose, or a well-formed object we can extend).
46
+ export function isSafeToWrite(state) {
47
+ return state === 'missing' || state === 'empty' || state === 'object';
48
+ }
49
+
50
+ // Write `contents` to `file` atomically: write a sibling temp file, then rename
51
+ // over the target (rename is atomic within a filesystem). settings.json holds
52
+ // the user's permissions/hooks/plugins/MCP config — a direct writeFileSync that
53
+ // is interrupted (crash, ENOSPC) could truncate it and erase all of that. The
54
+ // temp file shares the target's directory so the rename never crosses devices.
55
+ export function writeFileAtomic(file, contents) {
56
+ const tmp = `${file}.tmp-${process.pid}`;
57
+ fs.writeFileSync(tmp, contents);
58
+ try {
59
+ fs.renameSync(tmp, file);
60
+ } catch (err) {
61
+ try { fs.rmSync(tmp, { force: true }); } catch {}
62
+ throw err;
63
+ }
64
+ }
package/src/state.js CHANGED
@@ -1,6 +1,17 @@
1
1
  import fs from 'node:fs';
2
+ import process from 'node:process';
2
3
  import { isNum, numOr } from './utils.js';
3
4
 
5
+ // Cap on retained per-session entries. The state file gains one key per
6
+ // session_id and is never otherwise pruned, so without a cap it grows without
7
+ // bound. We keep the most-recently-touched sessions (by `ts`) and drop the rest.
8
+ const MAX_SESSIONS = 50;
9
+
10
+ // session_id is used as an object key. A value of __proto__/constructor/prototype
11
+ // would mutate the object's prototype instead of storing data; reject those so a
12
+ // crafted or poisoned id can't corrupt the session map.
13
+ const isUnsafeKey = (k) => k === '__proto__' || k === 'constructor' || k === 'prototype';
14
+
4
15
  export function readState(stateFilePath) {
5
16
  try {
6
17
  const raw = fs.readFileSync(stateFilePath, 'utf8');
@@ -13,25 +24,45 @@ export function readState(stateFilePath) {
13
24
  }
14
25
 
15
26
  export function writeState(stateFilePath, state) {
27
+ // Atomic write: a direct writeFileSync interrupted mid-write (crash, ENOSPC)
28
+ // would truncate the state file. Write a sibling temp file, then rename over
29
+ // the target (atomic within a filesystem; the temp shares the target's dir so
30
+ // the rename never crosses devices). State is regenerable, so any failure
31
+ // degrades silently — a stateless render is fine.
32
+ const tmp = `${stateFilePath}.tmp-${process.pid}`;
16
33
  try {
17
- fs.writeFileSync(stateFilePath, JSON.stringify(state));
34
+ fs.writeFileSync(tmp, JSON.stringify(state));
35
+ fs.renameSync(tmp, stateFilePath);
18
36
  } catch {
19
- // degrade silently stateless render is fine
37
+ try { fs.rmSync(tmp, { force: true }); } catch {}
20
38
  }
21
39
  }
22
40
 
23
41
  export function getSessionState(state, sessionId) {
24
- if (!sessionId || typeof sessionId !== 'string') return null;
42
+ if (!sessionId || typeof sessionId !== 'string' || isUnsafeKey(sessionId)) return null;
25
43
  const sessions = state?.sessions;
26
44
  if (!sessions || typeof sessions !== 'object') return null;
27
- return sessions[sessionId] ?? null;
45
+ return Object.hasOwn(sessions, sessionId) ? sessions[sessionId] : null;
46
+ }
47
+
48
+ // Keep at most MAX_SESSIONS entries, evicting the lowest `ts` (oldest touched)
49
+ // first. Sessions without a numeric ts sort oldest.
50
+ function prune(sessions) {
51
+ const keys = Object.keys(sessions);
52
+ if (keys.length <= MAX_SESSIONS) return sessions;
53
+ const keep = keys
54
+ .sort((a, b) => numOr(sessions[b]?.ts, 0) - numOr(sessions[a]?.ts, 0))
55
+ .slice(0, MAX_SESSIONS);
56
+ const out = {};
57
+ for (const k of keep) out[k] = sessions[k];
58
+ return out;
28
59
  }
29
60
 
30
61
  export function patchSessionState(state, sessionId, patch) {
31
- if (!sessionId || typeof sessionId !== 'string') return state;
62
+ if (!sessionId || typeof sessionId !== 'string' || isUnsafeKey(sessionId)) return state;
32
63
  const sessions = { ...(state?.sessions ?? {}) };
33
64
  sessions[sessionId] = { ...(sessions[sessionId] ?? {}), ...patch };
34
- return { ...state, sessions };
65
+ return { ...state, sessions: prune(sessions) };
35
66
  }
36
67
 
37
68
  export function nextSessionPatch(data, prevSessionState, cfg, now) {
package/src/utils.js CHANGED
@@ -32,8 +32,19 @@ export function fmtNum(n, mode) {
32
32
  return String(n);
33
33
  }
34
34
 
35
+ // Strip C0/C1 control characters (incl. ESC, BEL, DEL) from any text bound for
36
+ // the terminal. stdin fields like session_name, model.display_name, and
37
+ // effort.level are echoed into the status line verbatim; without this, escape
38
+ // sequences smuggled into them would be interpreted by the terminal (title/OSC
39
+ // rewrites, clipboard writes, cursor moves that spoof or hide output). The bar
40
+ // is purely visual, so dropping control bytes is lossless. The tool's own ANSI
41
+ // color codes are added AFTER sanitizing, so they survive.
42
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control bytes is the intent
43
+ const sanitize = (text) => String(text).replace(/[\x00-\x1f\x7f-\x9f]/g, '');
44
+
35
45
  export function paint(text, color) {
36
- return color && ANSI[color] ? `${ANSI[color]}${text}\x1b[0m` : text;
46
+ const clean = sanitize(text);
47
+ return color && ANSI[color] ? `${ANSI[color]}${clean}\x1b[0m` : clean;
37
48
  }
38
49
 
39
50
  // 3-arg form: band(value, amber, red) — used by ttl / rate limits.