cc-cream 0.1.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 ADDED
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ All notable changes to cc-cream are documented here. Format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.0] — 2026-05-29
8
+
9
+ Initial public release. cc-cream reads the JSON Claude Code pipes to its status
10
+ line and prints a colored ≤3-row bar — zero tokens, the model never sees it.
11
+
12
+ ### Added
13
+ - **Status-line engine** — fourteen configurable segments across up to three
14
+ rows: `ctx`, `cache`, `write`, `ttl`, `effort`, `thinking`, `api_ratio`,
15
+ `cost` (row 1); `5h`, `7d`, `burn`, `peak` (row 2, hidden for API users);
16
+ `model`, `session_name` (row 3). Node built-ins only, no runtime dependencies.
17
+ - **Per-session state** keyed by `session_id` for burn-rate projection, cost
18
+ delta on `/clear`, and cache-drop detection.
19
+ - **Configuration** via `~/.claude/cc-cream.json` — every display decision
20
+ (on/off, row, order, thresholds, colors) is data-driven, with per-field and
21
+ whole-file fallback. Degrades gracefully: malformed input never crashes.
22
+ - **Distribution as a Claude Code plugin** — `.claude-plugin/plugin.json` and a
23
+ self-hosted `marketplace.json`. Install with
24
+ `/plugin marketplace add bart-turczynski/cc-cream` then `/plugin install cc-cream`.
25
+ - **`/cc-cream:setup` command** that wires the status line into `settings.json`
26
+ with a self-resolving cache-glob command, so `/plugin update` applies new
27
+ versions automatically with no network calls and no re-run of setup.
28
+ - **npm distribution** — `cc-cream` bin with a node shebang; installable via
29
+ `npx -y cc-cream@latest`.
30
+ - **Consent-based installer** (`src/install.js`) for the manual / GitHub path:
31
+ copies the runtime into `~/.claude/cc-cream` and writes one `statusLine` block,
32
+ preserving any existing configuration and asking before replacing it.
33
+ - **Docs & trust** — user-facing README, `SECURITY.md`, `CONTRIBUTING.md`, and a
34
+ prominent disclosure: no network calls, no telemetry, no runtime dependencies.
35
+
36
+ ### Notes
37
+ - Supports **macOS and Linux**; Windows is a planned fast-follow.
38
+ - Requires Claude Code **2.1.132+** (`effort` / `thinking` need 2.1.145+).
39
+
40
+ [0.1.0]: https://github.com/bart-turczynski/cc-cream/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bart Turczynski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,238 @@
1
+ # cc-cream
2
+
3
+ **C.R.E.A.M. — Cache Rules Everything Around Me.**
4
+
5
+ A lightweight status-line tool for [Claude Code](https://claude.com/claude-code)
6
+ that turns the JSON Claude Code pipes to its status line into a glanceable,
7
+ colored ≤3-row bar. The model **never sees the output** — it costs **zero tokens**.
8
+
9
+ ## What it shows
10
+
11
+ ```
12
+ ctx:3% [30k] | cache:55% | ~$0.10
13
+ 5h:22% ↺ 0m | 7d:36% ↺ Sat 21:00
14
+ Opus 4.7 (1M context) | example session
15
+ ```
16
+
17
+ > Rendered from a live Claude Code subscriber session (fixtures/subscriber.golden.json).
18
+ > A proper screenshot or asciinema recording will appear here after public release.
19
+
20
+ - **Row 1** — this session: context-window occupancy, cache hit rate, session cost
21
+ - **Row 2** — rate-limit budget windows: 5h and 7d usage + reset countdown (subscribers only; API users get one row)
22
+ - **Row 3** — identity: model name and optional session name
23
+
24
+ The bar helps you avoid rate limits, keep the cache warm, and catch context fill
25
+ before the model degrades — with cache economics as the organizing story.
26
+
27
+ ## Trust and data posture
28
+
29
+ - **No network calls.** The engine is a pure stdin → stdout transformer. It never
30
+ opens a socket, fetches a URL, or calls home.
31
+ - **No telemetry.** Nothing is collected, reported, or logged anywhere.
32
+ - **No runtime dependencies.** Node built-ins only. `npm install` is for dev tools
33
+ (Cucumber, Biome) — nothing that runs at render time.
34
+ - **Zero tokens.** Claude Code reads the bar output to display it; the model never
35
+ receives it.
36
+
37
+ The only I/O is reading the JSON blob Claude Code pipes on stdin and writing one
38
+ session-state file to `~/.claude/cc-cream-state.json` (keyed by session ID,
39
+ contains cost and rate-limit samples used for the burn projection).
40
+
41
+ ## Requirements
42
+
43
+ - **Node.js** — already present; Claude Code is a Node app.
44
+ - **Claude Code ≥ 2.1.132** (released 2026-05-06). The cache figure requires
45
+ `context_window.current_usage`, which landed in that release.
46
+ - The `effort` and `thinking` segments additionally require **Claude Code ≥ 2.1.145**;
47
+ they stay hidden below that version.
48
+
49
+ ## Platform support
50
+
51
+ v1 supports **macOS and Linux**. Windows support is a planned fast-follow; the
52
+ engine is pure Node and the only blocker is the statusLine shell-command wiring.
53
+
54
+ ## Install
55
+
56
+ ### Option 1 — Claude Code plugin (community catalog / self-hosted marketplace)
57
+
58
+ If cc-cream is listed in the community catalog:
59
+ ```bash
60
+ /plugin install cc-cream
61
+ ```
62
+
63
+ To use the self-hosted marketplace directly:
64
+ ```bash
65
+ /plugin marketplace add bart-turczynski/cc-cream
66
+ /plugin install cc-cream
67
+ ```
68
+
69
+ Then wire it into your settings in one step:
70
+ ```
71
+ /cc-cream:setup
72
+ ```
73
+
74
+ The `/cc-cream:setup` command runs the consent installer, which writes the
75
+ `statusLine` block to `~/.claude/settings.json`. Updates are automatic: when
76
+ `/plugin update` drops a new version into the cache, the next render picks it
77
+ up without any further action.
78
+
79
+ ### Option 2 — npm / npx
80
+
81
+ ```bash
82
+ npx -y cc-cream@latest
83
+ ```
84
+
85
+ Or install globally:
86
+ ```bash
87
+ npm install -g cc-cream
88
+ ```
89
+
90
+ Then run the consent installer to wire it into Claude Code:
91
+ ```bash
92
+ node $(npm root -g)/cc-cream/src/install.js
93
+ ```
94
+
95
+ ### Option 3 — Manual GitHub clone
96
+
97
+ Download and install by cloning the repository, then run the consent installer:
98
+ ```bash
99
+ git clone https://github.com/bart-turczynski/cc-cream.git
100
+ node cc-cream/src/install.js
101
+ ```
102
+
103
+ The installer:
104
+ - Detects an existing `statusLine` and **asks before replacing it**
105
+ - **Preserves any `padding`** you have set
106
+ - Is **idempotent** — re-running when cc-cream is already installed changes nothing
107
+ - States the trust and restart requirement
108
+
109
+ After install, Claude Code must be **trusted** for the directory (if prompted),
110
+ and you may need to **restart** it for the bar to appear.
111
+
112
+ ## Configuration
113
+
114
+ Every display decision is read from `~/.claude/cc-cream.json`. Edit it by hand
115
+ or ask Claude to. It is strict JSON with no comments. **Every field falls back to
116
+ its built-in default if missing or malformed** — a typo degrades one value rather
117
+ than breaking the bar; a whole-file parse error falls back to all defaults.
118
+
119
+ ```json
120
+ {
121
+ "numbers": "compact",
122
+ "ttl": "auto",
123
+ "percentage": "consumed",
124
+ "segments": {
125
+ "ctx": { "on": true, "row": 1, "order": 2, "amber": 30, "orange": 40, "red": 50, "basis": "window", "ceiling": 200000, "display": "basis" },
126
+ "cache": { "on": true, "row": 1, "order": 3 },
127
+ "write": { "on": false, "row": 1, "order": 3.5 },
128
+ "ttl": { "on": true, "row": 1, "order": 4, "amber": 50, "red": 80 },
129
+ "cost": { "on": true, "row": 1, "order": 5 },
130
+ "effort": { "on": false, "row": 1, "order": 6 },
131
+ "thinking": { "on": false, "row": 1, "order": 7 },
132
+ "api_ratio": { "on": false, "row": 1, "order": 8 },
133
+ "5h": { "on": true, "row": 2, "order": 1, "amber": 75, "red": 90 },
134
+ "burn": { "on": true, "row": 2, "order": 1.5 },
135
+ "7d": { "on": true, "row": 2, "order": 2, "amber": 75, "red": 90 },
136
+ "peak": { "on": true, "row": 2, "order": 3, "start": 5, "end": 11 },
137
+ "model": { "on": true, "row": 3, "order": 0.5 },
138
+ "session_name": { "on": false, "row": 3, "order": 1 }
139
+ }
140
+ }
141
+ ```
142
+
143
+ ### Global keys
144
+
145
+ - `numbers`: `compact` (e.g. `38k`) or `exact` (`38000`) for token magnitudes.
146
+ - `ttl`: cache time-to-live used to color the `ttl` segment — `auto` (recommended),
147
+ `60`, or `5` minutes. `auto` infers from rate-limit data when available.
148
+ - `percentage`: `consumed` (default) counts up — `ctx:19%` means 19% of the window
149
+ is used, `5h:67%` means 67% of the 5h budget is gone. `remaining` flips the
150
+ budget/occupancy segments to count down (`ctx:81%`, `5h:33%`). Only `ctx`, `5h`
151
+ and `7d` flip; `cache%` (a hit-rate, not a budget) and `ttl` (a countdown) are
152
+ unaffected. **Thresholds are always expressed in consumed terms regardless of
153
+ this setting.**
154
+
155
+ ### Per-segment keys
156
+
157
+ Every segment accepts:
158
+ - `on` (boolean) — whether to show the segment
159
+ - `row` (1, 2, or 3) — which row to place it on
160
+ - `order` (any number) — lower = further left within the row
161
+
162
+ Colored segments additionally accept threshold keys. Thresholds mark the
163
+ **lower bound** where that color begins.
164
+
165
+ ### Segment catalog
166
+
167
+ | Segment | Default | Example | Meaning | Color |
168
+ |---|---|---|---|---|
169
+ | `ctx` | on, row 1 | `ctx:19% [38k]` | context-window occupancy + input-token magnitude | `<30` green · `30–40` amber · `40–50` orange · `≥50` red |
170
+ | `cache` | on, row 1 | `cache:95%` | last-turn cache hit rate (reads / total tokens) | neutral |
171
+ | `write` | **off**, row 1 | `write:4%` | last-turn cache creation rate (new writes / total tokens) | neutral |
172
+ | `ttl` | on, row 1 | `ttl:00:52` | time remaining before cache expires (counts down to 00:00) | `<50%` green · `50–80%` amber · `≥80%` red |
173
+ | `cost` | on, row 1 | `~$4.50` | session cost incl. subagents; `~` = CC's estimate | neutral; hidden when zero |
174
+ | `effort` | **off**, row 1 | `effort:high` | reasoning effort level (requires CC ≥ 2.1.145) | neutral |
175
+ | `thinking` | **off**, row 1 | `think:on` | thinking mode indicator (requires CC ≥ 2.1.145) | neutral |
176
+ | `api_ratio` | **off**, row 1 | `∿ api:74%` | fraction of wall time spent on API calls | neutral |
177
+ | `5h` | on, row 2 | `5h:23% ↺ 2h14m` | 5-hour rate-limit window + reset countdown | `≥75` amber · `≥90` red |
178
+ | `burn` | on, row 2 | `~38m` | estimated minutes until 5h cap at current pace | neutral; hidden when ETA > 5h or no prior sample |
179
+ | `7d` | on, row 2 | `7d:41% ↺ 4d` | weekly rate-limit window + reset countdown | same as 5h |
180
+ | `peak` | on, row 2 | `peak` | weekday Pacific-time window where 5h drains faster | amber; hidden outside window |
181
+ | `model` | on, row 3 | `Sonnet 4.6` | current model name | none |
182
+ | `session_name` | **off**, row 3 | `My project session` | conversation name from CC | none |
183
+
184
+ Any segment hides cleanly when its source field is absent — API users have no
185
+ `rate_limits`; `current_usage` is null right after `/compact`; etc.
186
+
187
+ Row 2 is hidden entirely for API users (no `rate_limits` in stdin).
188
+ Row 3 suppresses itself when all its segments are hidden.
189
+
190
+ ### Row 1 layout
191
+
192
+ Row 1 has two zones separated by ` | `:
193
+
194
+ ```
195
+ [ctx · cache · write · ttl · effort · thinking · api_ratio] | [cost]
196
+ ```
197
+
198
+ Segments within zone 1 are also separated by ` | `. Segments moved off their
199
+ default row via config must land in a zone to appear on row 1.
200
+
201
+ ### `ctx`-specific keys
202
+
203
+ - `basis`: `window` (default) colors based on `used_percentage` of the real context
204
+ window. `ceiling` colors based on `total_input_tokens / ceiling`, so the warning
205
+ fires at the same absolute token count on any window size. On a 1M-context model
206
+ the window basis stays green well past where quality degrades — set `ceiling` if
207
+ you want an early warning that doesn't scale with the window.
208
+ - `ceiling`: token count the `ceiling` basis measures against. Default `200000`.
209
+ - `display`: with `basis: "ceiling"`, `basis` (default) shows the % toward the
210
+ ceiling so number and color agree; `window` pins it to CC's window figure but
211
+ still colors by the ceiling. No effect under `basis: "window"`.
212
+
213
+ ### `ctx` thresholds
214
+
215
+ Default: `amber: 30`, `orange: 40`, `red: 50` (percent consumed).
216
+
217
+ ### `ttl` thresholds
218
+
219
+ Default: `amber: 50`, `red: 80` (percent of the resolved TTL consumed).
220
+
221
+ ### `5h` / `7d` thresholds
222
+
223
+ Default: `amber: 75`, `red: 90` (absolute `used_percentage`).
224
+
225
+ ### `peak`-specific keys
226
+
227
+ - `start` / `end`: hours in Pacific time (0–23, exclusive end) bounding
228
+ Anthropic's faster-drain window. Defaults `5`–`11`. Weekday-only (Mon–Fri) and
229
+ the `America/Los_Angeles` timezone are hardcoded policy facts, not config.
230
+
231
+ ## Development
232
+
233
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for how to run the tests. The runtime
234
+ uses only Node built-ins — no runtime dependencies.
235
+
236
+ ## License
237
+
238
+ MIT — see [LICENSE](LICENSE).
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "cc-cream",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code cache/context/cost status-line tool",
5
+ "directories": {
6
+ "doc": "docs"
7
+ },
8
+ "scripts": {
9
+ "lint": "biome lint src/",
10
+ "knip": "knip",
11
+ "validate": "command -v claude >/dev/null 2>&1 && claude plugin validate . || echo 'cc-cream: claude CLI not found — skipping plugin validation'",
12
+ "pretest": "npm run lint && npm run knip && npm run validate",
13
+ "test": "cucumber-js",
14
+ "test:manual": "cucumber-js --profile manual",
15
+ "coverage": "c8 cucumber-js",
16
+ "watch": "cucumber-js --watch",
17
+ "prepare": "simple-git-hooks",
18
+ "prepublishOnly": "npm test"
19
+ },
20
+ "simple-git-hooks": {
21
+ "pre-push": "npm run coverage"
22
+ },
23
+ "bin": {
24
+ "cc-cream": "src/cc-cream.js"
25
+ },
26
+ "files": [
27
+ "src/",
28
+ "LICENSE",
29
+ "README.md",
30
+ "CHANGELOG.md"
31
+ ],
32
+ "keywords": [
33
+ "claude-code",
34
+ "status-line",
35
+ "cache",
36
+ "context",
37
+ "cost",
38
+ "developer-tools"
39
+ ],
40
+ "author": "Bart Turczynski <support@spoonkeyworks.com>",
41
+ "license": "MIT",
42
+ "type": "module",
43
+ "engines": {
44
+ "node": ">=18"
45
+ },
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/bart-turczynski/cc-cream.git"
49
+ },
50
+ "bugs": {
51
+ "url": "https://github.com/bart-turczynski/cc-cream/issues"
52
+ },
53
+ "homepage": "https://github.com/bart-turczynski/cc-cream#readme",
54
+ "devDependencies": {
55
+ "@biomejs/biome": "^2.4.15",
56
+ "@cucumber/cucumber": "^12.9.0",
57
+ "c8": "^11.0.0",
58
+ "knip": "^6.14.2",
59
+ "simple-git-hooks": "^2.13.1"
60
+ }
61
+ }
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ // cc-cream — Claude Code status-line engine.
3
+ // Reads the session JSON Claude Code pipes on stdin and prints a colored
4
+ // <=3-row bar. Hard rule: degrade, never crash.
5
+
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import process from 'node:process';
9
+ import { pathToFileURL } from 'node:url';
10
+ import { loadConfig, readConfigFile } from './config.js';
11
+ import { render } from './render.js';
12
+ import {
13
+ getSessionState,
14
+ nextSessionPatch,
15
+ patchSessionState,
16
+ readState,
17
+ writeState,
18
+ } from './state.js';
19
+
20
+ export { DEFAULTS } from './defaults.js';
21
+ export { loadConfig } from './config.js';
22
+ export { render } from './render.js';
23
+ export { resolveTtl } from './ttl.js';
24
+ export { countdown, isPeak } from './utils.js';
25
+ export {
26
+ getSessionState,
27
+ nextSessionPatch,
28
+ patchSessionState,
29
+ readState,
30
+ writeState,
31
+ } from './state.js';
32
+
33
+ async function readStdin() {
34
+ const chunks = [];
35
+ for await (const c of process.stdin) chunks.push(c);
36
+ return Buffer.concat(chunks).toString('utf8');
37
+ }
38
+
39
+ function parseSession(raw) {
40
+ try {
41
+ const parsed = JSON.parse(raw);
42
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
43
+ } catch {
44
+ // malformed/empty stdin -> render with no data -> empty bar
45
+ }
46
+ return {};
47
+ }
48
+
49
+ function nowFromEnv(env) {
50
+ const rawNow = env.CC_CREAM_NOW;
51
+ return rawNow && Number.isFinite(Number(rawNow)) ? Number(rawNow) : Date.now();
52
+ }
53
+
54
+ async function main() {
55
+ const data = parseSession(await readStdin());
56
+ const cfg = loadConfig(readConfigFile());
57
+ const now = nowFromEnv(process.env);
58
+
59
+ const sessionId = typeof data.session_id === 'string' && data.session_id ? data.session_id : null;
60
+ const stateFile = path.join(os.homedir(), '.claude', 'cc-cream-state.json');
61
+ const state = sessionId ? readState(stateFile) : {};
62
+ const prevSessionState = getSessionState(state, sessionId);
63
+
64
+ const out = render(data, cfg, process.env, now, prevSessionState);
65
+ if (out) process.stdout.write(`${out}\n`);
66
+
67
+ if (sessionId) {
68
+ const patch = nextSessionPatch(data, prevSessionState, cfg, now);
69
+ writeState(stateFile, patchSessionState(state, sessionId, patch));
70
+ }
71
+
72
+ process.exit(0);
73
+ }
74
+
75
+ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
76
+ main();
77
+ }
package/src/config.js ADDED
@@ -0,0 +1,74 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { DEFAULTS } from './defaults.js';
5
+ import { clone, isNum, numOr } from './utils.js';
6
+
7
+ const boolOr = (v, d) => (typeof v === 'boolean' ? v : d);
8
+ const rowOr = (v, d) => (v === 1 || v === 2 || v === 3 ? v : d);
9
+ const posOr = (v, d) => (isNum(v) && v > 0 ? v : d); // a ceiling of 0/neg would divide-by-zero
10
+ const basisOr = (v, d) => (v === 'window' || v === 'ceiling' ? v : d);
11
+ const ctxDisplayOr = (v, d) => (v === 'basis' || v === 'window' ? v : d);
12
+ const hourOr = (v, d) => (isNum(v) && v >= 0 && v <= 23 ? v : d);
13
+ const percentageOr = (v, d) => (v === 'consumed' || v === 'remaining' ? v : d);
14
+
15
+ function ttlOr(v, d) {
16
+ if (v === 'auto') return 'auto';
17
+ if (v === 60 || v === '60') return 60;
18
+ if (v === 5 || v === '5') return 5;
19
+ return d;
20
+ }
21
+
22
+ function mergeConfig(parsed) {
23
+ 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);
27
+
28
+ const segs = parsed.segments;
29
+ if (segs && typeof segs === 'object' && !Array.isArray(segs)) {
30
+ for (const id of Object.keys(DEFAULTS.segments)) {
31
+ const def = DEFAULTS.segments[id];
32
+ const s = segs[id];
33
+ const out = clone(def);
34
+ 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);
48
+ }
49
+ cfg.segments[id] = out;
50
+ }
51
+ }
52
+ return cfg;
53
+ }
54
+
55
+ // raw === null/undefined (no file) -> all defaults. Parse error -> all defaults.
56
+ export function loadConfig(raw) {
57
+ if (raw == null) return clone(DEFAULTS);
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(raw);
61
+ } catch {
62
+ return clone(DEFAULTS);
63
+ }
64
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return clone(DEFAULTS);
65
+ return mergeConfig(parsed);
66
+ }
67
+
68
+ export function readConfigFile() {
69
+ try {
70
+ return fs.readFileSync(path.join(os.homedir(), '.claude', 'cc-cream.json'), 'utf8');
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
@@ -0,0 +1,44 @@
1
+ // Built-in defaults (PRD §6, minus the dropped `width` key — §14.2).
2
+ // Each colored segment names the LOWER BOUND where a color begins; the color
3
+ // function tests `red` first, then `orange` (ctx only), then `amber`, else green.
4
+ export const DEFAULTS = {
5
+ numbers: 'compact', // 'compact' | 'exact'
6
+ ttl: 'auto', // 'auto' | 60 | 5
7
+ percentage: 'consumed', // 'consumed' | 'remaining' — display-only flip of ctx/5h/7d (PRDv2 §3)
8
+ segments: {
9
+ model: { on: true, row: 3, order: 0.5 },
10
+ // `basis` picks the fullness reference the color (and, by default, the
11
+ // shown %) measures against: 'window' = used_percentage of the real window
12
+ // (no-regression default); 'ceiling' = total_input_tokens / `ceiling`, so
13
+ // the warning fires at a fixed absolute size on any window. `display`
14
+ // governs the shown %: 'basis' tracks the coloring basis (number and color
15
+ // agree), 'window' pins it to CC's window figure regardless (PRD §4.4).
16
+ ctx: { on: true, row: 1, order: 2, amber: 30, orange: 40, red: 50, basis: 'window', ceiling: 200000, display: 'basis' },
17
+ cache: { on: true, row: 1, order: 3, drop: 20, drop_recover: 80 },
18
+ ttl: { on: true, row: 1, order: 4, amber: 50, red: 80 },
19
+ cost: { on: true, row: 1, order: 5 },
20
+ '5h': { on: true, row: 2, order: 1, amber: 75, red: 90 },
21
+ '7d': { on: true, row: 2, order: 2, amber: 75, red: 90 },
22
+ // peak: amber "peak" word during Anthropic's faster-drain window (PRDv2 §2).
23
+ // start/end are Pacific-time hours (0–23, exclusive end); weekday (Mon–Fri)
24
+ // and the America/Los_Angeles timezone are hardcoded policy facts, not config.
25
+ peak: { on: true, row: 2, order: 3, start: 5, end: 11 },
26
+ burn: { on: true, row: 2, order: 1.5 },
27
+ effort: { on: false, row: 1, order: 6 },
28
+ thinking: { on: false, row: 1, order: 7 },
29
+ api_ratio: { on: false, row: 1, order: 8 },
30
+ session_name: { on: false, row: 3, order: 1 },
31
+ write: { on: false, row: 1, order: 3.5 },
32
+ },
33
+ };
34
+
35
+ // Row 1 renders as visual zones separated by " | " (PRD §4.3).
36
+ // Empty zones drop out, so a model-only bar is just the name with no separators.
37
+ export const ROW1_ZONES = [['ctx', 'cache', 'write', 'ttl', 'effort', 'thinking', 'api_ratio'], ['cost']];
38
+
39
+ export const ANSI = {
40
+ red: '\x1b[31m',
41
+ green: '\x1b[32m',
42
+ amber: '\x1b[33m',
43
+ orange: '\x1b[38;5;208m',
44
+ };
package/src/install.js ADDED
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+ // cc-cream consent-based installer (PRD §7, §14.1). It copies the runtime
3
+ // modules into ~/.claude/cc-cream and writes one `statusLine` block into the
4
+ // user's settings.json after showing the change. It detects and confirms before
5
+ // replacing an existing line, preserves any user `padding`, and surfaces the
6
+ // trust/restart requirement.
7
+ //
8
+ // The pure `plan()` function does all the decision-making (no I/O) so it is
9
+ // testable; the CLI wrapper at the bottom handles reading/prompting/writing.
10
+
11
+ import { execFileSync } from 'node:child_process';
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import process from 'node:process';
16
+ import readline from 'node:readline';
17
+ import { pathToFileURL } from 'node:url';
18
+
19
+ const TRUST_NOTE =
20
+ 'Claude Code must be trusted and possibly restarted for the status line to appear.';
21
+
22
+ // The cache-glob auto-update command (docs/RELEASE_PLAN.md "Auto-update mechanism").
23
+ // `nodePath` is the ABSOLUTE node binary, resolved once at setup time — the
24
+ // 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. `sort -V | tail -1` picks the highest
27
+ // installed version, so `/plugin update` is applied live with no re-run of setup.
28
+ export function autoUpdateCommand(nodePath) {
29
+ return `${nodePath} "$(ls -1d "\${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/*/cc-cream/*/ 2>/dev/null | sort -V | tail -1)src/cc-cream.js"`;
30
+ }
31
+
32
+ // `desired` is considered already installed if it matches the planned command
33
+ // verbatim (so switching strategy or node path re-plans), at refreshInterval 60.
34
+ function isInstalled(existing, command) {
35
+ return (
36
+ !!existing &&
37
+ typeof existing === 'object' &&
38
+ existing.type === 'command' &&
39
+ typeof existing.command === 'string' &&
40
+ existing.command === command &&
41
+ existing.refreshInterval === 60
42
+ );
43
+ }
44
+
45
+ // Decide what to do. Returns { settings, changed, messages, needsConsent }.
46
+ // `consent` is the user's yes/no when an existing statusLine must be replaced.
47
+ //
48
+ // Two command strategies:
49
+ // - manual (default): `node <entrypoint>` pointing at the copied-to-home runtime.
50
+ // - plugin: the cache-glob auto-update command, using the absolute `nodePath`.
51
+ // Pass `{ plugin: true, nodePath }` to select it; the plugin cache IS the
52
+ // install, so no files are copied to home in that mode.
53
+ export function plan(settings, { entrypoint, consent, plugin = false, nodePath } = {}) {
54
+ const s = settings && typeof settings === 'object' ? settings : {};
55
+ const existing = s.statusLine;
56
+ const messages = [];
57
+
58
+ const command = plugin
59
+ ? autoUpdateCommand(nodePath)
60
+ : `node ${entrypoint}`;
61
+ const desired = { type: 'command', command, refreshInterval: 60 };
62
+ // Preserve any user padding — it shrinks the 80-col budget (PRD §7).
63
+ if (existing && typeof existing === 'object' && existing.padding !== undefined) {
64
+ desired.padding = existing.padding;
65
+ }
66
+
67
+ if (isInstalled(existing, command)) {
68
+ messages.push('cc-cream is already installed — no changes needed.');
69
+ return { settings: s, changed: false, messages, needsConsent: false };
70
+ }
71
+
72
+ // An existing (different) statusLine must be confirmed before replacing.
73
+ const hasExisting = existing && typeof existing === 'object';
74
+ if (hasExisting) {
75
+ messages.push(`An existing statusLine is configured:\n ${JSON.stringify(existing)}`);
76
+ messages.push('Replace it with cc-cream?');
77
+ if (consent !== true) {
78
+ messages.push('Declined — your existing statusLine is unchanged.');
79
+ return { settings: s, changed: false, messages, needsConsent: true };
80
+ }
81
+ }
82
+
83
+ messages.push(`Will set statusLine to:\n ${JSON.stringify(desired)}`);
84
+ messages.push(TRUST_NOTE);
85
+ return { settings: { ...s, statusLine: desired }, changed: true, messages, needsConsent: hasExisting };
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // CLI wrapper.
90
+ // ---------------------------------------------------------------------------
91
+ function settingsPath() {
92
+ return path.join(os.homedir(), '.claude', 'settings.json');
93
+ }
94
+
95
+ function destinationPath() {
96
+ return path.join(os.homedir(), '.claude', 'cc-cream', 'cc-cream.js');
97
+ }
98
+
99
+ function runtimeFiles(sourceFile) {
100
+ const sourceDir = path.dirname(sourceFile);
101
+ return fs.readdirSync(sourceDir)
102
+ .filter((name) => name.endsWith('.js') && name !== 'install.js')
103
+ .map((name) => path.join(sourceDir, name));
104
+ }
105
+
106
+ function copyRuntimeFiles(sourceFile, destDir) {
107
+ let copied = false;
108
+ fs.mkdirSync(destDir, { recursive: true });
109
+ for (const file of runtimeFiles(sourceFile)) {
110
+ const dest = path.join(destDir, path.basename(file));
111
+ const needsCopy = !fs.existsSync(dest) || fs.statSync(file).mtime > fs.statSync(dest).mtime;
112
+ if (needsCopy) {
113
+ fs.copyFileSync(file, dest);
114
+ copied = true;
115
+ }
116
+ }
117
+ return copied;
118
+ }
119
+
120
+ // Resolve the absolute node binary to bake into the statusLine command. The
121
+ // status line runs as a detached subprocess that may not inherit the user's
122
+ // PATH, so a bare `node` is unsafe. We prefer the shell's `command -v node`
123
+ // (the path the user's interactive shell would pick), falling back to
124
+ // process.execPath (the node currently running setup) if that fails.
125
+ function resolveNodePath() {
126
+ try {
127
+ const found = execFileSync('command', ['-v', 'node'], {
128
+ shell: true,
129
+ encoding: 'utf8',
130
+ }).trim();
131
+ if (found) return found;
132
+ } catch {
133
+ // fall through
134
+ }
135
+ return process.execPath;
136
+ }
137
+
138
+ function ask(question) {
139
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
140
+ return new Promise((resolve) => rl.question(`${question} [y/N] `, (a) => {
141
+ rl.close();
142
+ resolve(/^y(es)?$/i.test(a.trim()));
143
+ }));
144
+ }
145
+
146
+ async function main() {
147
+ const args = process.argv.slice(2);
148
+ const plugin = args.includes('--plugin');
149
+ // First non-flag arg is an optional local source path (manual mode only).
150
+ const positional = args.filter((a) => !a.startsWith('--'));
151
+
152
+ const file = settingsPath();
153
+ let settings = {};
154
+ try {
155
+ settings = JSON.parse(fs.readFileSync(file, 'utf8')) || {};
156
+ } catch {
157
+ settings = {}; // missing or malformed -> start fresh, don't clobber blindly below
158
+ }
159
+
160
+ // planOpts holds whatever the chosen strategy needs to build its command.
161
+ let planOpts;
162
+ if (plugin) {
163
+ // Plugin mode: the plugin cache IS the install — do NOT copy to home. The
164
+ // command self-resolves the latest cached version on every render.
165
+ planOpts = { plugin: true, nodePath: resolveNodePath() };
166
+ } else {
167
+ // Manual / GitHub mode: copy the runtime into ~/.claude/cc-cream and point
168
+ // the statusLine at that copied entrypoint.
169
+ const sourceFile = positional[0]
170
+ ? path.resolve(positional[0])
171
+ : path.resolve(path.dirname(new URL(import.meta.url).pathname), 'cc-cream.js');
172
+
173
+ if (!fs.existsSync(sourceFile)) {
174
+ console.error(`Error: cc-cream.js not found at ${sourceFile}`);
175
+ process.exit(1);
176
+ }
177
+
178
+ const dest = destinationPath();
179
+ const destDir = path.dirname(dest);
180
+ if (copyRuntimeFiles(sourceFile, destDir)) {
181
+ console.log(`Copied cc-cream runtime files to ${destDir}`);
182
+ }
183
+ planOpts = { entrypoint: dest };
184
+ }
185
+
186
+ let result = plan(settings, planOpts);
187
+ // If a replace needs consent, ask now and re-plan with the answer.
188
+ if (!result.changed && result.needsConsent) {
189
+ for (const m of result.messages) console.log(m);
190
+ const yes = await ask('Replace it with cc-cream?');
191
+ result = plan(settings, { ...planOpts, consent: yes });
192
+ }
193
+
194
+ for (const m of result.messages) console.log(m);
195
+ if (result.changed) {
196
+ fs.mkdirSync(path.dirname(file), { recursive: true });
197
+ fs.writeFileSync(file, `${JSON.stringify(result.settings, null, 2)}\n`);
198
+ console.log(`\nWrote ${file}.`);
199
+ }
200
+ }
201
+
202
+ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
203
+ main();
204
+ }
package/src/render.js ADDED
@@ -0,0 +1,34 @@
1
+ import { ROW1_ZONES } from './defaults.js';
2
+ import { renderSegments } from './segments.js';
3
+ import { resolveTtl } from './ttl.js';
4
+ import { paint } from './utils.js';
5
+
6
+ // Assemble enabled+visible segments into up to three rows.
7
+ export function render(data, cfg, env, now, prevSessionState = null) {
8
+ const ttlMin = resolveTtl({ rateLimits: data?.rate_limits, config: cfg, env });
9
+ // CC_CREAM_TZ is an internal test/diagnostic seam, not a documented config key.
10
+ const tz = env?.CC_CREAM_TZ || 'America/Los_Angeles';
11
+ const segs = renderSegments(data, cfg, ttlMin, now, prevSessionState, tz);
12
+
13
+ const visible = (id, row) => cfg.segments[id]?.on && segs[id] && cfg.segments[id].row === row;
14
+ const byOrder = (a, b) => cfg.segments[a].order - cfg.segments[b].order;
15
+ const draw = (id) => paint(segs[id].text, segs[id].color);
16
+
17
+ const row1 = ROW1_ZONES.map((zone) => zone.filter((id) => visible(id, 1)).sort(byOrder).map(draw).join(' | '))
18
+ .filter((z) => z.length > 0)
19
+ .join(' | ');
20
+
21
+ const row2 = Object.keys(cfg.segments)
22
+ .filter((id) => visible(id, 2))
23
+ .sort(byOrder)
24
+ .map(draw)
25
+ .join(' | ');
26
+
27
+ const row3 = Object.keys(cfg.segments)
28
+ .filter((id) => visible(id, 3))
29
+ .sort(byOrder)
30
+ .map(draw)
31
+ .join(' | ');
32
+
33
+ return [row1, row2, row3].filter((r) => r.length > 0).join('\n');
34
+ }
@@ -0,0 +1,173 @@
1
+ import fs from 'node:fs';
2
+ import { band, countdown, flipPct, fmtNum, isNum, isPeak, numOr, pad2 } from './utils.js';
3
+ import { hasWindow } from './ttl.js';
4
+
5
+ function magnitudeTokens(cw) {
6
+ // PRD §4.1 assumes `total_input_tokens`; fall back to the input-only sum of
7
+ // current_usage so the magnitude survives a rename.
8
+ if (isNum(cw.total_input_tokens)) return cw.total_input_tokens;
9
+ const u = cw.current_usage;
10
+ if (u && typeof u === 'object') {
11
+ const sum = numOr(u.cache_read_input_tokens, 0) + numOr(u.cache_creation_input_tokens, 0) + numOr(u.input_tokens, 0);
12
+ if (sum > 0) return sum;
13
+ }
14
+ return undefined;
15
+ }
16
+
17
+ function segModel(data) {
18
+ const v = data?.model?.display_name;
19
+ if (typeof v !== 'string' || v === '') return null;
20
+ return { text: v, color: null };
21
+ }
22
+
23
+ function segCtx(data, cfg) {
24
+ const cw = data?.context_window;
25
+ if (!cw || typeof cw !== 'object') return null;
26
+ const winPct = cw.used_percentage;
27
+ if (!isNum(winPct)) return null;
28
+ const s = cfg.segments.ctx;
29
+ const mag = magnitudeTokens(cw);
30
+
31
+ let colorPct = winPct;
32
+ let ceilingPct;
33
+ if (s.basis === 'ceiling' && isNum(mag) && s.ceiling > 0) {
34
+ ceilingPct = (mag / s.ceiling) * 100;
35
+ colorPct = ceilingPct;
36
+ }
37
+
38
+ const shownPct = ceilingPct != null && s.display !== 'window' ? ceilingPct : winPct;
39
+
40
+ let text = `ctx:${flipPct(Math.round(shownPct), cfg)}%`;
41
+ if (isNum(mag)) text += ` [${fmtNum(mag, cfg.numbers)}]`;
42
+ return { text, color: band(colorPct, s.amber, s.orange, s.red) };
43
+ }
44
+
45
+ function segCache(data, cfg, prevCachePct, recovering) {
46
+ const u = data?.context_window?.current_usage;
47
+ if (!u || typeof u !== 'object') return null;
48
+ const read = numOr(u.cache_read_input_tokens, 0);
49
+ const denom = read + numOr(u.cache_creation_input_tokens, 0) + numOr(u.input_tokens, 0);
50
+ if (denom <= 0) return null;
51
+ const pct = Math.round((read / denom) * 100);
52
+ const s = cfg.segments.cache;
53
+ const freshDrop = isNum(prevCachePct) && (prevCachePct - pct) >= s.drop;
54
+ const stillRecovering = recovering === true && pct < s.drop_recover;
55
+ const color = (freshDrop || stillRecovering) ? 'red' : null;
56
+ return { text: `cache:${pct}%`, color };
57
+ }
58
+
59
+ function segTtl(data, cfg, ttlMin, now) {
60
+ const tp = data?.transcript_path;
61
+ if (typeof tp !== 'string' || tp === '') return null;
62
+ let mtimeMs;
63
+ try {
64
+ mtimeMs = fs.statSync(tp).mtimeMs;
65
+ } catch {
66
+ return null;
67
+ }
68
+ const elapsedMin = Math.floor(Math.max(0, now - mtimeMs) / 60000);
69
+ const remainingMin = Math.max(0, ttlMin - elapsedMin);
70
+ const text = `ttl:${pad2(Math.floor(remainingMin / 60))}:${pad2(remainingMin % 60)}`;
71
+ const s = cfg.segments.ttl;
72
+ const pctTtl = ttlMin > 0 ? (elapsedMin / ttlMin) * 100 : 0;
73
+ return { text, color: band(pctTtl, s.amber, s.red) };
74
+ }
75
+
76
+ function segCost(data) {
77
+ const c = data?.cost?.total_cost_usd;
78
+ if (!isNum(c) || c <= 0) return null;
79
+ return { text: `~$${c.toFixed(2)}`, color: null };
80
+ }
81
+
82
+ function segRate(window, label, cfg, segId, now) {
83
+ if (!window || typeof window !== 'object') return null;
84
+ const pct = window.used_percentage;
85
+ if (!isNum(pct)) return null;
86
+ const s = cfg.segments[segId];
87
+ const color = pct >= s.red ? 'red' : pct >= s.amber ? 'amber' : null;
88
+ const cd = countdown(window.resets_at, now);
89
+ const head = `${label}:${flipPct(Math.round(pct), cfg)}%`;
90
+ const text = cd ? `${head} ↺ ${cd}` : head;
91
+ return { text, color };
92
+ }
93
+
94
+ function segEffort(data) {
95
+ const lvl = data?.effort?.level;
96
+ if (typeof lvl !== 'string' || lvl === '') return null;
97
+ return { text: `effort:${lvl}`, color: null };
98
+ }
99
+
100
+ function segThinking(data) {
101
+ const t = data?.thinking?.enabled;
102
+ if (typeof t !== 'boolean') return null;
103
+ return { text: `think:${t ? 'on' : 'off'}`, color: null };
104
+ }
105
+
106
+ function segApiRatio(data) {
107
+ const api = data?.cost?.total_api_duration_ms;
108
+ const total = data?.cost?.total_duration_ms;
109
+ if (!isNum(api) || !isNum(total) || total <= 0) return null;
110
+ const pct = Math.round(Math.min(100, (api / total) * 100));
111
+ return { text: `∿ api:${pct}%`, color: null };
112
+ }
113
+
114
+ function segSessionName(data) {
115
+ const name = data?.session_name;
116
+ if (typeof name !== 'string' || name === '') return null;
117
+ return { text: name, color: null };
118
+ }
119
+
120
+ function segCacheWrite(data) {
121
+ const u = data?.context_window?.current_usage;
122
+ if (!u || typeof u !== 'object') return null;
123
+ const creation = numOr(u.cache_creation_input_tokens, 0);
124
+ const denom = creation + numOr(u.cache_read_input_tokens, 0) + numOr(u.input_tokens, 0);
125
+ if (denom <= 0) return null;
126
+ return { text: `write:${Math.round((creation / denom) * 100)}%`, color: null };
127
+ }
128
+
129
+ function segPeak(data, cfg, now, tz) {
130
+ // peak rides the account-budget row, so it shows only when that row has windows.
131
+ if (!hasWindow(data?.rate_limits)) return null;
132
+ if (!isPeak(now, cfg, tz)) return null;
133
+ return { text: 'peak', color: 'amber' };
134
+ }
135
+
136
+ function segBurn(fiveHour, prev, now) {
137
+ if (!fiveHour || !isNum(fiveHour.used_percentage)) return null;
138
+ if (!prev || !isNum(prev.five_hour_pct) || !isNum(prev.ts)) return null;
139
+ const deltaPct = fiveHour.used_percentage - prev.five_hour_pct;
140
+ const deltaMs = now - prev.ts;
141
+ if (deltaPct <= 0 || deltaMs <= 0) return null;
142
+ const remaining = 100 - fiveHour.used_percentage;
143
+ if (remaining <= 0) return null;
144
+ const minEta = Math.ceil((remaining / deltaPct) * deltaMs / 60000);
145
+ if (!Number.isFinite(minEta) || minEta >= 300) return null;
146
+ const h = Math.floor(minEta / 60);
147
+ const m = minEta % 60;
148
+ return { text: h >= 1 ? `~${h}h${pad2(m)}m` : `~${minEta}m`, color: null };
149
+ }
150
+
151
+ export function renderSegments(data, cfg, ttlMin, now, prevSessionState = null, tz = 'America/Los_Angeles') {
152
+ return {
153
+ model: segModel(data),
154
+ ctx: segCtx(data, cfg),
155
+ cache: segCache(
156
+ data,
157
+ cfg,
158
+ prevSessionState && isNum(prevSessionState.cache_pct) ? prevSessionState.cache_pct : undefined,
159
+ prevSessionState?.recovering === true,
160
+ ),
161
+ ttl: segTtl(data, cfg, ttlMin, now),
162
+ cost: segCost(data),
163
+ '5h': segRate(data?.rate_limits?.five_hour, '5h', cfg, '5h', now),
164
+ '7d': segRate(data?.rate_limits?.seven_day, '7d', cfg, '7d', now),
165
+ peak: segPeak(data, cfg, now, tz),
166
+ burn: segBurn(data?.rate_limits?.five_hour, prevSessionState, now),
167
+ effort: segEffort(data),
168
+ thinking: segThinking(data),
169
+ api_ratio: segApiRatio(data),
170
+ session_name: segSessionName(data),
171
+ write: segCacheWrite(data),
172
+ };
173
+ }
package/src/state.js ADDED
@@ -0,0 +1,57 @@
1
+ import fs from 'node:fs';
2
+ import { isNum, numOr } from './utils.js';
3
+
4
+ export function readState(stateFilePath) {
5
+ try {
6
+ const raw = fs.readFileSync(stateFilePath, 'utf8');
7
+ const parsed = JSON.parse(raw);
8
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
9
+ return {};
10
+ } catch {
11
+ return {};
12
+ }
13
+ }
14
+
15
+ export function writeState(stateFilePath, state) {
16
+ try {
17
+ fs.writeFileSync(stateFilePath, JSON.stringify(state));
18
+ } catch {
19
+ // degrade silently — stateless render is fine
20
+ }
21
+ }
22
+
23
+ export function getSessionState(state, sessionId) {
24
+ if (!sessionId || typeof sessionId !== 'string') return null;
25
+ const sessions = state?.sessions;
26
+ if (!sessions || typeof sessions !== 'object') return null;
27
+ return sessions[sessionId] ?? null;
28
+ }
29
+
30
+ export function patchSessionState(state, sessionId, patch) {
31
+ if (!sessionId || typeof sessionId !== 'string') return state;
32
+ const sessions = { ...(state?.sessions ?? {}) };
33
+ sessions[sessionId] = { ...(sessions[sessionId] ?? {}), ...patch };
34
+ return { ...state, sessions };
35
+ }
36
+
37
+ export function nextSessionPatch(data, prevSessionState, cfg, now) {
38
+ const patch = { ts: now };
39
+ const cost = data?.cost?.total_cost_usd;
40
+ if (isNum(cost)) patch.cost = cost;
41
+ const cu = data?.context_window?.current_usage;
42
+ if (cu && typeof cu === 'object') {
43
+ const read = numOr(cu.cache_read_input_tokens, 0);
44
+ const denom = read + numOr(cu.cache_creation_input_tokens, 0) + numOr(cu.input_tokens, 0);
45
+ if (denom > 0) {
46
+ const currentCachePct = Math.round((read / denom) * 100);
47
+ patch.cache_pct = currentCachePct;
48
+ const prevCachePct = prevSessionState && isNum(prevSessionState.cache_pct) ? prevSessionState.cache_pct : undefined;
49
+ const wasRecovering = prevSessionState?.recovering === true;
50
+ const freshDrop = isNum(prevCachePct) && (prevCachePct - currentCachePct) >= cfg.segments.cache.drop;
51
+ patch.recovering = freshDrop || (wasRecovering && currentCachePct < cfg.segments.cache.drop_recover);
52
+ }
53
+ }
54
+ const fh = data?.rate_limits?.five_hour;
55
+ if (fh && isNum(fh.used_percentage)) patch.five_hour_pct = fh.used_percentage;
56
+ return patch;
57
+ }
package/src/ttl.js ADDED
@@ -0,0 +1,26 @@
1
+ import { numOr } from './utils.js';
2
+
3
+ const envOn = (v) => typeof v === 'string' && v !== '' && v !== '0' && v.toLowerCase() !== 'false';
4
+
5
+ export function hasWindow(rl) {
6
+ return !!(rl && typeof rl === 'object' && (rl.five_hour || rl.seven_day));
7
+ }
8
+
9
+ function overCap(rl) {
10
+ return [rl.five_hour, rl.seven_day].some((w) => w && numOr(w.used_percentage, 0) >= 100);
11
+ }
12
+
13
+ export function resolveTtl({ rateLimits, config, env }) {
14
+ const e = env || {};
15
+ // FORCE override wins over everything (PRD §10).
16
+ if (envOn(e.FORCE_PROMPT_CACHING_5M)) return 5;
17
+ // Explicit config pin.
18
+ const pin = config ? config.ttl : 'auto';
19
+ if (pin === 5) return 5;
20
+ if (pin === 60) return 60;
21
+ // auto resolution.
22
+ if (hasWindow(rateLimits)) {
23
+ return overCap(rateLimits) ? 5 : 60; // subscriber
24
+ }
25
+ return envOn(e.ENABLE_PROMPT_CACHING_1H) ? 60 : 5; // API user
26
+ }
package/src/utils.js ADDED
@@ -0,0 +1,80 @@
1
+ import { ANSI } from './defaults.js';
2
+
3
+ export const clone = (o) => JSON.parse(JSON.stringify(o));
4
+ export const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
5
+ export const numOr = (v, d) => (isNum(v) ? v : d);
6
+ export const pad2 = (n) => String(n).padStart(2, '0');
7
+
8
+ export function fmtNum(n, mode) {
9
+ if (mode === 'exact') return String(n);
10
+ if (n >= 1_000_000) return `${Math.round(n / 1_000_000)}M`;
11
+ if (n >= 1000) return `${Math.round(n / 1000)}k`;
12
+ return String(n);
13
+ }
14
+
15
+ export function paint(text, color) {
16
+ return color && ANSI[color] ? `${ANSI[color]}${text}\x1b[0m` : text;
17
+ }
18
+
19
+ // 3-arg form: band(value, amber, red) — used by ttl / rate limits.
20
+ // 4-arg form: band(value, amber, orange, red) — used by ctx.
21
+ // Orange is skipped when it falls at or below amber.
22
+ export function band(value, amber, orangeOrRed, red) {
23
+ if (red === undefined) { red = orangeOrRed; orangeOrRed = undefined; }
24
+ if (value >= red) return 'red';
25
+ if (orangeOrRed !== undefined && orangeOrRed > amber && value >= orangeOrRed) return 'orange';
26
+ if (value >= amber) return 'amber';
27
+ return 'green';
28
+ }
29
+
30
+ // Normalize a resets_at value to epoch ms. Claude Code sends a Unix timestamp
31
+ // in seconds; also tolerate ms and ISO.
32
+ function toEpochMs(v) {
33
+ if (typeof v === 'number' && Number.isFinite(v)) return v < 1e11 ? v * 1000 : v;
34
+ if (typeof v === 'string') {
35
+ if (/^\d+$/.test(v)) return toEpochMs(Number(v));
36
+ const t = Date.parse(v);
37
+ return Number.isNaN(t) ? NaN : t;
38
+ }
39
+ return NaN;
40
+ }
41
+
42
+ // Display-only percentage flip (PRDv2 §3).
43
+ export const flipPct = (consumedShown, cfg) => (
44
+ cfg.percentage === 'remaining' ? 100 - consumedShown : consumedShown
45
+ );
46
+
47
+ // True during Anthropic's faster-drain "peak" window (PRDv2 §2): a weekday
48
+ // (Mon–Fri) within [start, end) Pacific-time hours.
49
+ export function isPeak(now, cfg, tz = 'America/Los_Angeles') {
50
+ const s = cfg?.segments?.peak ?? {};
51
+ const start = isNum(s.start) ? s.start : 5;
52
+ const end = isNum(s.end) ? s.end : 11;
53
+ try {
54
+ const parts = new Intl.DateTimeFormat('en-US', {
55
+ timeZone: tz, hour: 'numeric', hour12: false, weekday: 'short',
56
+ }).formatToParts(new Date(now));
57
+ const p = Object.fromEntries(parts.map((x) => [x.type, x.value]));
58
+ const hour = Number(p.hour) % 24; // some ICU builds emit "24" for midnight
59
+ return !['Sat', 'Sun'].includes(p.weekday) && hour >= start && hour < end;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ // resets_at - now, on the §4.4 format ladder: >=1d -> "Fri 23:45", >=1h -> HhMMm, else MMm.
66
+ export function countdown(resetsAt, now) {
67
+ const t = toEpochMs(resetsAt);
68
+ if (Number.isNaN(t)) return '';
69
+ const totalMin = Math.max(0, Math.floor((t - now) / 60000));
70
+ const days = Math.floor(totalMin / 1440);
71
+ if (days >= 1) {
72
+ const d = new Date(t);
73
+ const weekday = d.toLocaleDateString(undefined, { weekday: 'short' });
74
+ const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false });
75
+ return `${weekday} ${time}`;
76
+ }
77
+ const hours = Math.floor(totalMin / 60);
78
+ if (hours >= 1) return `${hours}h${pad2(totalMin % 60)}m`;
79
+ return `${totalMin}m`;
80
+ }