claude-token-counter 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Edward Honour
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,294 @@
1
+ # claude-token-counter
2
+
3
+ A live terminal dashboard that tracks your **Claude Code** token usage and cost in
4
+ near real time — plus span markers to measure a unit of work and rolling log
5
+ files. Zero dependencies, one file, just Node.
6
+
7
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
+ ![node: >=20](https://img.shields.io/badge/node-%3E%3D20-339933.svg)
9
+ ![dependencies: none](https://img.shields.io/badge/dependencies-0-brightgreen.svg)
10
+
11
+ ```text
12
+ Claude Code Token Tracker 14:32:08
13
+ ────────────────────────────────────────────────────────────────────────────────
14
+ ▶ SPAN auth system since 14:02:11 (29m 57s)
15
+ in 4,210 out 18,940 cache-r 1,402,883 cache-w 233,015
16
+ tokens 1,659,048 requests 11 cost $4.21
17
+
18
+ CURRENT SESSION · my-api · 9f3c1a2b ● live
19
+ in 5,279 out 75,542 cache-r 5,721,161 cache-w 585,706
20
+ requests 26 span 1h 48m updated 7s ago cost $10.63
21
+
22
+ TODAY 2026-06-15 tokens 7,197,622 requests 29 cost $13.52
23
+ model input output cache-r cache-w cost
24
+ opus-4-8 11,613 77,092 6,268,883 840,034 $13.52
25
+
26
+ ALL TIME tokens 41,883,902 requests 612 cost $96.40
27
+ model input output cache-r cache-w cost
28
+ opus-4-8 198,221 1,402,944 38,902,113 1,279,624 $89.71
29
+ sonnet-4-6 12,004 88,210 1,902,556 410,338 $6.69
30
+
31
+ RECENT
32
+ 14:32:01 opus-4-8 in 2 out 1,373 cr 277,762 $0.1821
33
+ 14:31:39 opus-4-8 in 2 out 175 cr 276,853 $0.1519
34
+ ────────────────────────────────────────────────────────────────────────────────
35
+ 3 projects · 28 sessions · log:daily · keys m span q quit
36
+ ```
37
+
38
+ > Numbers above are illustrative.
39
+
40
+ ## Contents
41
+
42
+ - [Why](#why)
43
+ - [Features](#features)
44
+ - [Requirements](#requirements)
45
+ - [Install](#install)
46
+ - [Quick start](#quick-start)
47
+ - [The dashboard](#the-dashboard)
48
+ - [Spans — measuring a unit of work](#spans--measuring-a-unit-of-work)
49
+ - [Logs](#logs)
50
+ - [Configuration](#configuration)
51
+ - [How it works](#how-it-works)
52
+ - [Cost model](#cost-model)
53
+ - [FAQ & notes](#faq--notes)
54
+ - [Contributing](#contributing)
55
+ - [License](#license)
56
+
57
+ ## Why
58
+
59
+ Claude Code doesn't show you a running total of what a piece of work *cost* — and
60
+ if you want to know "how many tokens did building the auth system take?", there's
61
+ nowhere to look. This tool reads Claude Code's own local session transcripts and
62
+ turns them into a live dashboard, so you can watch usage accrue, break it down by
63
+ model, and mark start/end points around a task to measure it exactly.
64
+
65
+ It's a passive **reader** — it never touches the Claude Code request path, needs no
66
+ API key, and nothing leaves your machine.
67
+
68
+ ## Features
69
+
70
+ - **Live dashboard** — current session, today, and all-time totals, refreshed as
71
+ Claude Code works (instant on file change, with a 1-second backstop).
72
+ - **Per-model breakdown** — Opus / Sonnet / Haiku / Fable, separately.
73
+ - **Token detail** — input, output, cache-read, and cache-write, not just a single number.
74
+ - **USD cost** — priced per model, with cache writes split into 5-minute vs 1-hour TTL for accuracy.
75
+ - **Spans** — mark a start and end point to measure a unit of work (e.g. "the auth system").
76
+ - **Logs** — append-only span history plus rolling daily / per-session usage reports.
77
+ - **Zero dependencies** — a single Node file; `npx` and go.
78
+
79
+ ## Requirements
80
+
81
+ - **Node.js ≥ 20** (uses recursive `fs.readdirSync` / `fs.watch`).
82
+ - **Claude Code** installed and used at least once, so `~/.claude/projects/` exists.
83
+
84
+ Works on macOS, Linux, and Windows (Windows Terminal / any ANSI-capable terminal).
85
+
86
+ ## Install
87
+
88
+ Run without installing (recommended):
89
+
90
+ ```bash
91
+ npx claude-token-counter
92
+ ```
93
+
94
+ Install the command globally:
95
+
96
+ ```bash
97
+ npm install -g claude-token-counter
98
+ # provides both `claude-token-counter` and the short `claude-tokens`
99
+ ```
100
+
101
+ No npm account needed — you can also run straight from this repo:
102
+
103
+ ```bash
104
+ npx github:maludb/claude-token-counter
105
+ ```
106
+
107
+ Or from a clone:
108
+
109
+ ```bash
110
+ git clone https://github.com/maludb/claude-token-counter.git
111
+ cd claude-token-counter
112
+ node token-counter.js
113
+ ```
114
+
115
+ ## Quick start
116
+
117
+ ```bash
118
+ claude-token-counter # launch the live dashboard
119
+ # in another terminal, while you work in Claude Code:
120
+ claude-token-counter mark start "auth system"
121
+ # ...build it...
122
+ claude-token-counter mark end # prints + logs the token/cost total
123
+ ```
124
+
125
+ ## The dashboard
126
+
127
+ ```bash
128
+ claude-token-counter # live dashboard (alias: claude-tokens)
129
+ claude-token-counter --once # print one snapshot and exit
130
+ claude-token-counter --log session # rolling report per session (default: daily)
131
+ NO_COLOR=1 claude-token-counter # disable color
132
+ ```
133
+
134
+ The **current session** is whichever transcript was most recently appended to, so
135
+ the dashboard automatically follows your active Claude Code window. The `● live`
136
+ badge means that session was active in the last 30 seconds.
137
+
138
+ **Keys**
139
+
140
+ | Key | Action |
141
+ |-----|--------|
142
+ | `m` | Start a span (prompts for a label) / stop the running span |
143
+ | `q` or `Ctrl-C` | Quit |
144
+
145
+ ## Spans — measuring a unit of work
146
+
147
+ A *span* marks a start and end point. Its total is the sum of token usage across
148
+ every turn whose timestamp falls in that window — that's how you answer "how much
149
+ did this task take?" State is shared between the dashboard and the CLI, so a span
150
+ you start in one terminal shows up live in the other (and `m` in the dashboard does
151
+ the same thing).
152
+
153
+ ```bash
154
+ claude-token-counter mark start "auth system" # begin measuring
155
+ claude-token-counter mark status # live total of the running span
156
+ claude-token-counter mark end # finish: print + log the total
157
+ claude-token-counter mark list # recent finished spans
158
+ claude-token-counter mark cancel # discard the running span (no log)
159
+ ```
160
+
161
+ **Options**
162
+
163
+ | Option | Applies to | Meaning |
164
+ |--------|-----------|---------|
165
+ | `--session <id>` | `mark start` | Count only that session's usage in the window (default: all sessions) |
166
+ | `--force` | `mark start` | Replace an already-running span |
167
+
168
+ Example `mark end` output:
169
+
170
+ ```text
171
+ ■ span "auth system" — 29m 57s
172
+ tokens 1,659,048 (in 4,210 · out 18,940 · cache-r 1,402,883 · cache-w 233,015)
173
+ cost $4.21 · 11 requests across 1 session(s)
174
+ logged to ~/.claude-token-counter/logs/markers.log
175
+ ```
176
+
177
+ > A span counts **all** Claude Code activity in its window. If you run unrelated
178
+ > sessions at the same time, scope it with `--session <id>` for an exact figure.
179
+
180
+ ## Logs
181
+
182
+ State and logs live under `~/.claude-token-counter` (override with `$CTC_DIR`).
183
+ Run `claude-token-counter paths` to print the exact locations.
184
+
185
+ | File | What |
186
+ |------|------|
187
+ | `markers.json` | Active + recently completed spans (the shared state file) |
188
+ | `logs/markers.log` | One human-readable line per finished span (append-only) |
189
+ | `logs/markers.jsonl` | The same span events as JSON, one per line — for scripts |
190
+ | `logs/daily-YYYY-MM-DD.log` | Rolling daily usage report (totals, per-model, spans) |
191
+ | `logs/session-<id>.log` | Same report per session (with `--log session` or `both`) |
192
+
193
+ Rolling reports are refreshed (every ~60s and on exit) while the dashboard runs;
194
+ `--log off` disables them. Span logs are written by `mark end` whether or not the
195
+ dashboard is running.
196
+
197
+ `markers.log` line:
198
+
199
+ ```text
200
+ 2026-06-15 14:32:08 auth system 29m 57s (14:02:11→14:32:08) in=4,210 out=18,940 cache_r=1,402,883 cache_w=233,015 total=1,659,048 cost=$4.2103 requests=11 sessions=1
201
+ ```
202
+
203
+ `markers.jsonl` is machine-readable — total today's span spend with `jq`:
204
+
205
+ ```bash
206
+ jq -s 'map(.cost_usd) | add' ~/.claude-token-counter/logs/markers.jsonl
207
+ ```
208
+
209
+ ## Configuration
210
+
211
+ | Flag / env | Default | Meaning |
212
+ |------------|---------|---------|
213
+ | `--log <daily\|session\|both\|off>` | `daily` | Which rolling report to keep updated |
214
+ | `--once` | — | Print one snapshot and exit (no logs written) |
215
+ | `--dir <path>` / `$CTC_DIR` | `~/.claude-token-counter` | State + log directory |
216
+ | `$NO_COLOR` | — | Any value disables ANSI color |
217
+
218
+ Other commands: `claude-token-counter paths` (show file locations),
219
+ `claude-token-counter --help`.
220
+
221
+ ## How it works
222
+
223
+ Claude Code writes one JSONL transcript per session under
224
+ `~/.claude/projects/<encoded-project>/<session-id>.jsonl`, appending a line for
225
+ every turn. Each assistant turn carries a `message.usage` block:
226
+
227
+ ```json
228
+ {
229
+ "input_tokens": 2951,
230
+ "output_tokens": 843,
231
+ "cache_read_input_tokens": 15582,
232
+ "cache_creation_input_tokens": 2444,
233
+ "cache_creation": { "ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 2444 }
234
+ }
235
+ ```
236
+
237
+ The tracker:
238
+
239
+ 1. **Tails** every transcript — repaints instantly on change via recursive
240
+ `fs.watch`, with a 1-second poll as a backstop, reading only newly-appended bytes.
241
+ 2. **Dedupes** turns by `message.id` + `requestId` (resumed/compacted sessions can
242
+ replay earlier messages).
243
+ 3. **Aggregates** into current-session, per-day, and all-time buckets, plus a flat
244
+ record list used to compute span windows.
245
+ 4. **Prices** each turn per model, splitting cache writes into 5-minute vs 1-hour
246
+ TTL using the `cache_creation` breakdown.
247
+
248
+ No API key, no network, no write access to Claude Code's data.
249
+
250
+ ## Cost model
251
+
252
+ USD per 1,000,000 tokens. Cache reads bill at 0.1× input; cache writes at 1.25×
253
+ input (5-minute TTL) or 2× input (1-hour TTL). The transcript records the 5m/1h
254
+ split, so writes are priced exactly rather than estimated.
255
+
256
+ | Model | Input | Output | Cache read | Cache write 5m | Cache write 1h |
257
+ |-------|-------|--------|------------|----------------|----------------|
258
+ | Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | $10.00 |
259
+ | Sonnet 4.6 | $3.00 | $15.00 | $0.30 | $3.75 | $6.00 |
260
+ | Haiku 4.5 | $1.00 | $5.00 | $0.10 | $1.25 | $2.00 |
261
+ | Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | $20.00 |
262
+
263
+ Rates live in the `PRICES` table at the top of `token-counter.js` — edit there if
264
+ they change.
265
+
266
+ ## FAQ & notes
267
+
268
+ **Is this a bill?** No. It's the **list-price equivalent** of the tokens consumed.
269
+ If you're on a Claude subscription (Pro/Max) rather than metered API billing, read
270
+ it as "what this usage would cost at API rates," not an invoice.
271
+
272
+ **Does it send my data anywhere?** No. It only reads local files under
273
+ `~/.claude/projects/` and writes logs under `~/.claude-token-counter/`. Nothing
274
+ leaves your machine.
275
+
276
+ **Why is a span showing 0 tokens?** A span only captures turns that *complete*
277
+ between its start and end. A span ended seconds after starting, with no Claude
278
+ turn in between, is correctly 0.
279
+
280
+ **Can Claude Code start spans for me?** Yes — `mark start` / `mark end` are plain
281
+ shell commands, so you can run them yourself, alias them, or have Claude Code run them.
282
+
283
+ **An unknown model shows $0.** Only Opus / Sonnet / Haiku / Fable are priced. Add a
284
+ row to the `PRICES` table for anything else.
285
+
286
+ ## Contributing
287
+
288
+ Issues and PRs welcome. The whole tool is one dependency-free file
289
+ (`token-counter.js`); `node --check token-counter.js` and a quick
290
+ `node token-counter.js --once` cover most smoke-testing.
291
+
292
+ ## License
293
+
294
+ [MIT](LICENSE) © Edward Honour
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "claude-token-counter",
3
+ "version": "1.0.0",
4
+ "description": "Live terminal dashboard for Claude Code token usage and cost",
5
+ "keywords": [
6
+ "claude",
7
+ "claude-code",
8
+ "anthropic",
9
+ "tokens",
10
+ "token-counter",
11
+ "usage",
12
+ "cost",
13
+ "cli",
14
+ "tui",
15
+ "terminal",
16
+ "dashboard"
17
+ ],
18
+ "homepage": "https://github.com/maludb/claude-token-counter#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/maludb/claude-token-counter/issues"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/maludb/claude-token-counter.git"
25
+ },
26
+ "author": "Edward Honour <edward.honour@gmail.com>",
27
+ "license": "MIT",
28
+ "type": "commonjs",
29
+ "bin": {
30
+ "claude-token-counter": "token-counter.js",
31
+ "claude-tokens": "token-counter.js"
32
+ },
33
+ "files": [
34
+ "token-counter.js",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "scripts": {
39
+ "start": "node token-counter.js",
40
+ "once": "node token-counter.js --once"
41
+ },
42
+ "engines": {
43
+ "node": ">=20"
44
+ }
45
+ }
@@ -0,0 +1,631 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * Claude Code token tracker — a live terminal dashboard, span markers, and logs
6
+ * for your Claude Code token usage, read straight from the local transcripts.
7
+ *
8
+ * Claude Code appends one JSONL transcript per session under
9
+ * ~/.claude/projects/<encoded-project>/<session-id>.jsonl
10
+ * Every assistant turn carries a `message.usage` block. We tail those files,
11
+ * dedupe by message/request id, aggregate, and render. No external deps.
12
+ *
13
+ * A "span" marks a start/end point so you can measure a unit of work
14
+ * (e.g. "the auth system"): its total is the sum of usage in that time window.
15
+ * State + logs live under ~/.claude-token-counter (override with $CTC_DIR).
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const os = require('os');
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Pricing — USD per 1,000,000 tokens.
24
+ // Cache read = 0.1x input; cache write (5m) = 1.25x input; (1h) = 2x input.
25
+ // ---------------------------------------------------------------------------
26
+ const PRICES = [
27
+ { match: 'fable', in: 10, out: 50 },
28
+ { match: 'opus', in: 5, out: 25 },
29
+ { match: 'sonnet', in: 3, out: 15 },
30
+ { match: 'haiku', in: 1, out: 5 },
31
+ ];
32
+ const UNKNOWN_PRICE = { in: 0, out: 0 };
33
+ const PER = 1e6;
34
+
35
+ function priceFor(model) {
36
+ const m = (model || '').toLowerCase();
37
+ for (const p of PRICES) if (m.includes(p.match)) return p;
38
+ return UNKNOWN_PRICE;
39
+ }
40
+
41
+ function costFor(model, u) {
42
+ const p = priceFor(model);
43
+ const input = u.input_tokens || 0;
44
+ const output = u.output_tokens || 0;
45
+ const cacheRead = u.cache_read_input_tokens || 0;
46
+ const cc = u.cache_creation || {};
47
+ let write5 = cc.ephemeral_5m_input_tokens || 0;
48
+ let write1 = cc.ephemeral_1h_input_tokens || 0;
49
+ if (!write5 && !write1) write5 = u.cache_creation_input_tokens || 0; // assume 5m
50
+ const cost =
51
+ (input * p.in + output * p.out + cacheRead * p.in * 0.1 +
52
+ write5 * p.in * 1.25 + write1 * p.in * 2.0) / PER;
53
+ return { input, output, cacheRead, cacheWrite: write5 + write1, cost };
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Paths / state dir
58
+ // ---------------------------------------------------------------------------
59
+ const HOME = os.homedir();
60
+ const PROJECTS_DIR = path.join(HOME, '.claude', 'projects');
61
+
62
+ let BASE = process.env.CTC_DIR || path.join(HOME, '.claude-token-counter');
63
+ let LOGS_DIR = path.join(BASE, 'logs');
64
+ let MARKERS_FILE = path.join(BASE, 'markers.json');
65
+ function setBase(dir) {
66
+ BASE = path.resolve(dir);
67
+ LOGS_DIR = path.join(BASE, 'logs');
68
+ MARKERS_FILE = path.join(BASE, 'markers.json');
69
+ }
70
+ function ensureDirs() { fs.mkdirSync(LOGS_DIR, { recursive: true }); }
71
+ function atomicWrite(file, str) {
72
+ ensureDirs();
73
+ const tmp = file + '.tmp' + process.pid;
74
+ fs.writeFileSync(tmp, str);
75
+ fs.renameSync(tmp, file);
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Ingest state
80
+ // ---------------------------------------------------------------------------
81
+ const files = new Map(); // path -> { offset, partial }
82
+ const seen = new Set(); // dedupe keys
83
+ const records = []; // slim per-turn records, for span windows
84
+ const sessions = new Map(); // sessionId -> bucket(+meta)
85
+ const days = new Map(); // localDateKey -> bucket
86
+ const allTime = newBucket();
87
+ const feed = []; // recent turns (newest kept)
88
+ let latestTs = 0;
89
+ let currentSessionId = null;
90
+ let startedAt = Date.now();
91
+
92
+ let LOG_MODE = 'daily'; // daily | session | both | off
93
+ let markers = { active: null, completed: [] };
94
+ let prompt = null; // { buffer } when typing a span label in the TUI
95
+ let flash = null; // { msg, until }
96
+
97
+ function newBucket() {
98
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, requests: 0, models: new Map() };
99
+ }
100
+ function addToBucket(b, model, r) {
101
+ b.input += r.input; b.output += r.output; b.cacheRead += r.cacheRead;
102
+ b.cacheWrite += r.cacheWrite; b.cost += r.cost; b.requests += 1;
103
+ let m = b.models.get(model);
104
+ if (!m) { m = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, requests: 0 }; b.models.set(model, m); }
105
+ m.input += r.input; m.output += r.output; m.cacheRead += r.cacheRead;
106
+ m.cacheWrite += r.cacheWrite; m.cost += r.cost; m.requests += 1;
107
+ }
108
+ function totalTokens(b) { return b.input + b.output + b.cacheRead + b.cacheWrite; }
109
+ function localDateKey(d) {
110
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
111
+ }
112
+
113
+ function processLine(line, fallbackSid) {
114
+ let o;
115
+ try { o = JSON.parse(line); } catch { return; }
116
+ if (o.type !== 'assistant') return;
117
+ const msg = o.message;
118
+ if (!msg || !msg.usage) return;
119
+ const model = msg.model;
120
+ if (!model || model.startsWith('<')) return;
121
+
122
+ const key = `${msg.id || ''}|${o.requestId || ''}`;
123
+ if (seen.has(key)) return;
124
+ seen.add(key);
125
+
126
+ const r = costFor(model, msg.usage);
127
+ const sid = o.sessionId || fallbackSid;
128
+ const ts = o.timestamp ? Date.parse(o.timestamp) : Date.now();
129
+ const project = o.cwd ? path.basename(o.cwd) : '?';
130
+
131
+ records.push({ ts, session: sid, input: r.input, output: r.output, cacheRead: r.cacheRead, cacheWrite: r.cacheWrite, cost: r.cost });
132
+
133
+ addToBucket(allTime, model, r);
134
+ const dk = localDateKey(new Date(ts));
135
+ let day = days.get(dk);
136
+ if (!day) { day = newBucket(); days.set(dk, day); }
137
+ addToBucket(day, model, r);
138
+
139
+ let s = sessions.get(sid);
140
+ if (!s) { s = newBucket(); s.firstTs = ts; s.project = project; s.id = sid; sessions.set(sid, s); }
141
+ s.lastTs = ts; s.project = project;
142
+ addToBucket(s, model, r);
143
+
144
+ feed.push({ ts, model, project, ...r });
145
+ if (feed.length > 6) { feed.sort((a, b) => a.ts - b.ts); feed.splice(0, feed.length - 6); }
146
+
147
+ if (ts >= latestTs) { latestTs = ts; currentSessionId = sid; }
148
+ }
149
+
150
+ function readChunk(p, start, end) {
151
+ const len = end - start;
152
+ if (len <= 0) return '';
153
+ const fd = fs.openSync(p, 'r');
154
+ try {
155
+ const buf = Buffer.allocUnsafe(len);
156
+ fs.readSync(fd, buf, 0, len, start);
157
+ return buf.toString('utf8');
158
+ } finally { fs.closeSync(fd); }
159
+ }
160
+ function ingestFile(p) {
161
+ let meta = files.get(p);
162
+ if (!meta) { meta = { offset: 0, partial: '' }; files.set(p, meta); }
163
+ let st;
164
+ try { st = fs.statSync(p); } catch { return; }
165
+ if (st.size < meta.offset) { meta.offset = 0; meta.partial = ''; }
166
+ if (st.size === meta.offset) return;
167
+ const chunk = readChunk(p, meta.offset, st.size);
168
+ meta.offset = st.size;
169
+ const data = meta.partial + chunk;
170
+ const lines = data.split('\n');
171
+ meta.partial = lines.pop();
172
+ const sid = path.basename(p, '.jsonl');
173
+ for (const line of lines) if (line.trim()) processLine(line, sid);
174
+ }
175
+ function discover() {
176
+ let entries;
177
+ try { entries = fs.readdirSync(PROJECTS_DIR, { recursive: true }); } catch { return []; }
178
+ const out = [];
179
+ for (const e of entries) if (typeof e === 'string' && e.endsWith('.jsonl')) out.push(path.join(PROJECTS_DIR, e));
180
+ return out;
181
+ }
182
+ function scan() { for (const p of discover()) ingestFile(p); }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Spans (markers)
186
+ // ---------------------------------------------------------------------------
187
+ function loadMarkers() {
188
+ try {
189
+ const o = JSON.parse(fs.readFileSync(MARKERS_FILE, 'utf8'));
190
+ return { active: o.active || null, completed: Array.isArray(o.completed) ? o.completed : [] };
191
+ } catch { return { active: null, completed: [] }; }
192
+ }
193
+ function saveMarkers(m) { atomicWrite(MARKERS_FILE, JSON.stringify(m, null, 2) + '\n'); }
194
+
195
+ function sumWindow(start, end, session) {
196
+ const a = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, requests: 0, sessions: new Set() };
197
+ for (const r of records) {
198
+ if (r.ts < start || r.ts > end) continue;
199
+ if (session && r.session !== session) continue;
200
+ a.input += r.input; a.output += r.output; a.cacheRead += r.cacheRead;
201
+ a.cacheWrite += r.cacheWrite; a.cost += r.cost; a.requests += 1; a.sessions.add(r.session);
202
+ }
203
+ return a;
204
+ }
205
+ function finishSpan(active, end) {
206
+ const a = sumWindow(active.start, end, active.session);
207
+ const e = {
208
+ label: active.label, start: active.start, end, session: active.session || null,
209
+ input: a.input, output: a.output, cacheRead: a.cacheRead, cacheWrite: a.cacheWrite,
210
+ cost: a.cost, requests: a.requests, sessions: a.sessions.size,
211
+ total: a.input + a.output + a.cacheRead + a.cacheWrite,
212
+ };
213
+ appendSpanLog(e);
214
+ return e;
215
+ }
216
+ function appendSpanLog(e) {
217
+ ensureDirs();
218
+ const line =
219
+ `${localDateKey(new Date(e.end))} ${hms(e.end)} ${e.label} ${dur(e.end - e.start)} ` +
220
+ `(${hms(e.start)}→${hms(e.end)}) in=${fmtN(e.input)} out=${fmtN(e.output)} ` +
221
+ `cache_r=${fmtN(e.cacheRead)} cache_w=${fmtN(e.cacheWrite)} total=${fmtN(e.total)} ` +
222
+ `cost=${money(e.cost, 4)} requests=${e.requests} sessions=${e.sessions}\n`;
223
+ fs.appendFileSync(path.join(LOGS_DIR, 'markers.log'), line);
224
+ const obj = {
225
+ type: 'span', label: e.label,
226
+ start: new Date(e.start).toISOString(), end: new Date(e.end).toISOString(),
227
+ duration_seconds: Math.round((e.end - e.start) / 1000),
228
+ input: e.input, output: e.output, cache_read: e.cacheRead, cache_write: e.cacheWrite,
229
+ total_tokens: e.total, cost_usd: Number(e.cost.toFixed(6)),
230
+ requests: e.requests, sessions: e.sessions, session: e.session,
231
+ };
232
+ fs.appendFileSync(path.join(LOGS_DIR, 'markers.jsonl'), JSON.stringify(obj) + '\n');
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Rolling reports (daily / session)
237
+ // ---------------------------------------------------------------------------
238
+ function rollupText(title, subtitle, bucket, spans) {
239
+ const L = [title];
240
+ if (subtitle) L.push(subtitle);
241
+ L.push('updated: ' + localDateKey(new Date()) + ' ' + hms(Date.now()), '');
242
+ L.push(`TOTAL tokens ${fmtN(totalTokens(bucket))} requests ${fmtN(bucket.requests)} cost ${money(bucket.cost, 4)}`);
243
+ L.push(` input ${fmtN(bucket.input)} · output ${fmtN(bucket.output)} · cache-read ${fmtN(bucket.cacheRead)} · cache-write ${fmtN(bucket.cacheWrite)}`, '');
244
+ L.push('By model');
245
+ const rows = [...bucket.models.entries()].sort((a, b) => b[1].cost - a[1].cost);
246
+ if (!rows.length) L.push(' (none)');
247
+ for (const [m, v] of rows)
248
+ L.push(` ${shortModel(m).padEnd(14)} in ${fmtN(v.input)} out ${fmtN(v.output)} cache-r ${fmtN(v.cacheRead)} cache-w ${fmtN(v.cacheWrite)} ${money(v.cost, 2)}`);
249
+ L.push('', 'Completed spans');
250
+ if (!spans.length) L.push(' (none)');
251
+ for (const e of spans)
252
+ L.push(` ${hms(e.start)}→${hms(e.end)} ${e.label} ${fmtN(e.total)} tokens ${money(e.cost, 2)}`);
253
+ L.push('');
254
+ return L.join('\n');
255
+ }
256
+ function writeRollupSafe() {
257
+ try {
258
+ if (LOG_MODE === 'off') return;
259
+ if (LOG_MODE === 'daily' || LOG_MODE === 'both') {
260
+ const key = localDateKey(new Date());
261
+ const b = days.get(key) || newBucket();
262
+ const spans = (markers.completed || []).filter(e => localDateKey(new Date(e.end)) === key);
263
+ atomicWrite(path.join(LOGS_DIR, `daily-${key}.log`), rollupText('Claude Code token usage — daily report', 'date: ' + key, b, spans));
264
+ }
265
+ if ((LOG_MODE === 'session' || LOG_MODE === 'both') && currentSessionId) {
266
+ const s = sessions.get(currentSessionId);
267
+ if (s) {
268
+ const spans = (markers.completed || []).filter(e => e.session === currentSessionId);
269
+ atomicWrite(path.join(LOGS_DIR, `session-${currentSessionId}.log`),
270
+ rollupText('Claude Code token usage — session report', `session: ${currentSessionId} (${s.project})`, s, spans));
271
+ }
272
+ }
273
+ } catch { /* logging must never crash the tracker */ }
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Formatting helpers
278
+ // ---------------------------------------------------------------------------
279
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
280
+ const sgr = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
281
+ const dim = s => sgr('2', s);
282
+ const bold = s => sgr('1', s);
283
+ const green = s => sgr('32', s);
284
+ const cyan = s => sgr('36', s);
285
+ const yellow = s => sgr('33', s);
286
+ const mag = s => sgr('35', s);
287
+
288
+ function fmtN(n) { return Math.round(n).toLocaleString('en-US'); }
289
+ function money(n, dp) {
290
+ if (dp == null) dp = n < 10 ? 4 : 2;
291
+ return '$' + n.toLocaleString('en-US', { minimumFractionDigits: dp, maximumFractionDigits: dp });
292
+ }
293
+ function shortModel(m) { return m.replace(/^claude-/, '').replace(/\[1m\]$/, '').replace(/-\d{8}$/, ''); }
294
+ function hms(ts) { return new Date(ts).toLocaleTimeString('en-US', { hour12: false }); }
295
+ function rel(ts) {
296
+ const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
297
+ if (s < 60) return `${s}s ago`;
298
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
299
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
300
+ return `${Math.floor(s / 86400)}d ago`;
301
+ }
302
+ function dur(ms) {
303
+ const s = Math.round(ms / 1000);
304
+ if (s < 60) return `${s}s`;
305
+ if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
306
+ return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
307
+ }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Dashboard rendering
311
+ // ---------------------------------------------------------------------------
312
+ function width() { return Math.min(process.stdout.columns || 80, 80); }
313
+ function rule() { return dim('─'.repeat(width())); }
314
+
315
+ function modelTable(bucket, pad) {
316
+ pad = pad || ' ';
317
+ const lines = [];
318
+ const head = 'model'.padEnd(18) + 'input'.padStart(11) + 'output'.padStart(10) +
319
+ 'cache-r'.padStart(12) + 'cache-w'.padStart(11) + 'cost'.padStart(11);
320
+ lines.push(pad + dim(head));
321
+ for (const [model, m] of [...bucket.models.entries()].sort((a, b) => b[1].cost - a[1].cost)) {
322
+ lines.push(pad +
323
+ shortModel(model).slice(0, 17).padEnd(18) + fmtN(m.input).padStart(11) +
324
+ fmtN(m.output).padStart(10) + fmtN(m.cacheRead).padStart(12) +
325
+ fmtN(m.cacheWrite).padStart(11) + money(m.cost, 2).padStart(11));
326
+ }
327
+ return lines;
328
+ }
329
+
330
+ function render() {
331
+ const L = [];
332
+ const now = new Date();
333
+
334
+ const titlePlain = ' Claude Code Token Tracker';
335
+ const clock = now.toLocaleTimeString('en-US', { hour12: false });
336
+ const gap = Math.max(1, width() - titlePlain.length - clock.length);
337
+ L.push(bold(cyan(titlePlain)) + ' '.repeat(gap) + dim(clock));
338
+ L.push(rule());
339
+
340
+ // Active span (or last completed)
341
+ if (markers.active) {
342
+ const mk = markers.active;
343
+ const a = sumWindow(mk.start, Date.now(), mk.session);
344
+ const tot = a.input + a.output + a.cacheRead + a.cacheWrite;
345
+ L.push(' ' + bold(green('▶ SPAN ')) + yellow(mk.label) +
346
+ dim(' since ' + hms(mk.start) + ' (' + dur(Date.now() - mk.start) + ')') +
347
+ (mk.session ? dim(' · session ' + mk.session.slice(0, 8)) : ''));
348
+ L.push(' ' + dim('in ') + bold(fmtN(a.input)) + ' ' + dim('out ') + bold(fmtN(a.output)) +
349
+ ' ' + dim('cache-r ') + bold(fmtN(a.cacheRead)) + ' ' + dim('cache-w ') + bold(fmtN(a.cacheWrite)));
350
+ L.push(' ' + dim('tokens ') + bold(fmtN(tot)) + ' ' + dim('requests ') + bold(fmtN(a.requests)) +
351
+ ' ' + dim('cost ') + green(money(a.cost)));
352
+ L.push('');
353
+ } else if (markers.completed && markers.completed[0]) {
354
+ const e = markers.completed[0];
355
+ L.push(' ' + dim('last span: ') + e.label + dim(' ' + fmtN(e.total) + ' tokens · ' + money(e.cost)) + '');
356
+ L.push('');
357
+ }
358
+
359
+ // Current session
360
+ const cs = currentSessionId && sessions.get(currentSessionId);
361
+ if (cs) {
362
+ const live = Date.now() - cs.lastTs < 30000;
363
+ L.push(' ' + bold('CURRENT SESSION') + dim(' · ') + yellow(cs.project) +
364
+ dim(' · ') + dim((cs.id || '').slice(0, 8)) + ' ' + (live ? green('● live') : dim('○ idle')));
365
+ L.push(' ' + dim('in ') + bold(fmtN(cs.input)) + ' ' + dim('out ') + bold(fmtN(cs.output)) +
366
+ ' ' + dim('cache-r ') + bold(fmtN(cs.cacheRead)) + ' ' + dim('cache-w ') + bold(fmtN(cs.cacheWrite)));
367
+ L.push(' ' + dim('requests ') + bold(fmtN(cs.requests)) + ' ' + dim('span ') + bold(dur(cs.lastTs - cs.firstTs)) +
368
+ ' ' + dim('updated ') + bold(rel(cs.lastTs)) + ' ' + dim('cost ') + green(money(cs.cost)));
369
+ } else {
370
+ L.push(' ' + dim('No session activity recorded yet — waiting for usage…'));
371
+ }
372
+ L.push('');
373
+
374
+ const today = days.get(localDateKey(now)) || newBucket();
375
+ L.push(' ' + bold('TODAY') + dim(' ' + localDateKey(now)) + ' ' + dim('tokens ') + bold(fmtN(totalTokens(today))) +
376
+ ' ' + dim('requests ') + bold(fmtN(today.requests)) + ' ' + dim('cost ') + green(money(today.cost)));
377
+ if (today.models.size) L.push(...modelTable(today));
378
+ L.push('');
379
+
380
+ L.push(' ' + bold('ALL TIME') + ' ' + dim('tokens ') + bold(fmtN(totalTokens(allTime))) +
381
+ ' ' + dim('requests ') + bold(fmtN(allTime.requests)) + ' ' + dim('cost ') + green(money(allTime.cost)));
382
+ if (allTime.models.size) L.push(...modelTable(allTime));
383
+ L.push('');
384
+
385
+ if (feed.length) {
386
+ L.push(' ' + dim('RECENT'));
387
+ for (let i = feed.length - 1; i >= 0; i--) {
388
+ const f = feed[i];
389
+ L.push(' ' + dim(hms(f.ts)) + ' ' + mag(shortModel(f.model).padEnd(12)) + ' ' +
390
+ dim('in ') + fmtN(f.input).padStart(7) + ' ' + dim('out ') + fmtN(f.output).padStart(6) + ' ' +
391
+ dim('cr ') + fmtN(f.cacheRead).padStart(9) + ' ' + green(money(f.cost).padStart(9)));
392
+ }
393
+ L.push('');
394
+ }
395
+
396
+ L.push(rule());
397
+ if (prompt) {
398
+ L.push(' ' + bold(green('label this span: ')) + prompt.buffer + (useColor ? '\x1b[7m \x1b[0m' : '_') +
399
+ dim(' (Enter to start · Esc to cancel)'));
400
+ } else {
401
+ if (flash && Date.now() < flash.until) L.push(' ' + yellow(flash.msg));
402
+ L.push(' ' + dim(`${countProjects()} projects · ${sessions.size} sessions · log:${LOG_MODE} · `) +
403
+ dim('keys ') + bold('m') + dim(' span ') + bold('q') + dim(' quit'));
404
+ }
405
+ return L;
406
+ }
407
+ function countProjects() {
408
+ const set = new Set();
409
+ for (const p of files.keys()) set.add(path.dirname(p));
410
+ return set.size;
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // Terminal driver
415
+ // ---------------------------------------------------------------------------
416
+ function draw() {
417
+ const lines = render();
418
+ const rows = (process.stdout.rows || 50) - 1;
419
+ process.stdout.write('\x1b[H' + lines.slice(0, rows).join('\x1b[K\n') + '\x1b[K\x1b[0J');
420
+ }
421
+ function enterAlt() { process.stdout.write('\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H'); }
422
+ function leaveAlt() { process.stdout.write('\x1b[?25h\x1b[?1049l'); }
423
+ function setFlash(msg) { flash = { msg, until: Date.now() + 5000 }; }
424
+ function quit(code) {
425
+ writeRollupSafe();
426
+ if (process.stdin.isTTY) { try { process.stdin.setRawMode(false); } catch {} }
427
+ if (process.stdout.isTTY) leaveAlt();
428
+ process.exit(code || 0);
429
+ }
430
+
431
+ function tuiStart(label) {
432
+ markers = loadMarkers();
433
+ if (markers.active) { setFlash('a span is already running'); return; }
434
+ markers.active = { label: label || ('span @ ' + hms(Date.now())), start: Date.now(), session: null };
435
+ saveMarkers(markers);
436
+ setFlash('▶ span started: ' + markers.active.label);
437
+ }
438
+ function tuiStop() {
439
+ markers = loadMarkers();
440
+ if (!markers.active) { setFlash('no active span'); return; }
441
+ const e = finishSpan(markers.active, Date.now());
442
+ markers.completed.unshift(e);
443
+ markers.completed = markers.completed.slice(0, 50);
444
+ markers.active = null;
445
+ saveMarkers(markers);
446
+ writeRollupSafe();
447
+ setFlash(`■ ${e.label}: ${fmtN(e.total)} tokens, ${money(e.cost)}`);
448
+ }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // CLI: spans
452
+ // ---------------------------------------------------------------------------
453
+ function takeOpt(argv, name) { const i = argv.indexOf(name); if (i >= 0) { const v = argv[i + 1]; argv.splice(i, 2); return v; } return undefined; }
454
+ function takeFlag(argv, name) { const i = argv.indexOf(name); if (i >= 0) { argv.splice(i, 1); return true; } return false; }
455
+
456
+ function handleMark(argv) {
457
+ ensureDirs();
458
+ const sub = argv.shift();
459
+ const session = takeOpt(argv, '--session');
460
+ const force = takeFlag(argv, '--force');
461
+ const label = argv.join(' ').trim();
462
+ const m = loadMarkers();
463
+
464
+ if (sub === 'start' || sub === 'begin') {
465
+ if (m.active && !force) {
466
+ console.error(`A span "${m.active.label}" is already running (started ${hms(m.active.start)}, ${dur(Date.now() - m.active.start)} ago).`);
467
+ console.error('Finish it with "mark end", or replace it with "mark start --force <label>".');
468
+ process.exit(1);
469
+ }
470
+ m.active = { label: label || ('span @ ' + hms(Date.now())), start: Date.now(), session: session || null };
471
+ saveMarkers(m);
472
+ console.log(`▶ span started: ${m.active.label}${session ? ` (session ${session})` : ''}`);
473
+ console.log(' run "claude-token-counter mark end" when the work is done.');
474
+ return;
475
+ }
476
+ if (sub === 'end' || sub === 'stop') {
477
+ if (!m.active) { console.error('No active span. Start one with: mark start <label>'); process.exit(1); }
478
+ scan();
479
+ const e = finishSpan(m.active, Date.now());
480
+ m.completed.unshift(e); m.completed = m.completed.slice(0, 50); m.active = null; saveMarkers(m);
481
+ console.log(`■ span "${e.label}" — ${dur(e.end - e.start)}`);
482
+ console.log(` tokens ${fmtN(e.total)} (in ${fmtN(e.input)} · out ${fmtN(e.output)} · cache-r ${fmtN(e.cacheRead)} · cache-w ${fmtN(e.cacheWrite)})`);
483
+ console.log(` cost ${money(e.cost)} · ${fmtN(e.requests)} requests across ${e.sessions} session(s)`);
484
+ console.log(` logged to ${path.join(LOGS_DIR, 'markers.log')}`);
485
+ return;
486
+ }
487
+ if (sub === 'cancel') {
488
+ if (!m.active) { console.log('No active span.'); return; }
489
+ const lbl = m.active.label; m.active = null; saveMarkers(m);
490
+ console.log(`span "${lbl}" cancelled (nothing logged).`);
491
+ return;
492
+ }
493
+ if (sub === 'status') {
494
+ if (!m.active) {
495
+ console.log('No active span.');
496
+ if (m.completed[0]) console.log(`Last: "${m.completed[0].label}" — ${fmtN(m.completed[0].total)} tokens, ${money(m.completed[0].cost)}`);
497
+ return;
498
+ }
499
+ scan();
500
+ const a = sumWindow(m.active.start, Date.now(), m.active.session);
501
+ const tot = a.input + a.output + a.cacheRead + a.cacheWrite;
502
+ console.log(`▶ "${m.active.label}" running — ${dur(Date.now() - m.active.start)} (since ${hms(m.active.start)})`);
503
+ console.log(` tokens ${fmtN(tot)} cost ${money(a.cost)} requests ${fmtN(a.requests)} sessions ${a.sessions.size}`);
504
+ return;
505
+ }
506
+ if (sub === 'list') {
507
+ if (!m.completed.length) { console.log('No completed spans yet.'); return; }
508
+ for (const e of m.completed.slice(0, 20))
509
+ console.log(`${localDateKey(new Date(e.end))} ${hms(e.end)} ${e.label.padEnd(24)} ${fmtN(e.total).padStart(12)} tokens ${money(e.cost, 2).padStart(10)} (${dur(e.end - e.start)})`);
510
+ return;
511
+ }
512
+ console.error('Usage: claude-token-counter mark <start|end|status|list|cancel> [label] [--session ID] [--force]');
513
+ process.exit(1);
514
+ }
515
+
516
+ function printPaths() {
517
+ console.log('state dir : ' + BASE);
518
+ console.log('markers : ' + MARKERS_FILE);
519
+ console.log('logs dir : ' + LOGS_DIR);
520
+ console.log(' spans : ' + path.join(LOGS_DIR, 'markers.log') + ' (+ markers.jsonl)');
521
+ console.log(' rollups : ' + path.join(LOGS_DIR, 'daily-YYYY-MM-DD.log') + ' / session-<id>.log');
522
+ }
523
+
524
+ function printHelp() {
525
+ process.stdout.write(
526
+ `Claude Code token tracker
527
+
528
+ Dashboard
529
+ claude-token-counter [--log <daily|session|both|off>] [--dir <path>]
530
+ live dashboard (default --log daily)
531
+ claude-token-counter --once print one snapshot and exit (no logs written)
532
+ claude-token-counter paths show where state and logs are stored
533
+
534
+ Spans — measure a unit of work (e.g. "the auth system")
535
+ claude-token-counter mark start <label> begin measuring
536
+ claude-token-counter mark end finish, print + log the total
537
+ claude-token-counter mark status live total of the running span
538
+ claude-token-counter mark list recent finished spans
539
+ claude-token-counter mark cancel discard the running span
540
+ options: --session <id> limit a span to one session
541
+ --force replace an already-running span (with start)
542
+
543
+ Dashboard keys: m = start/stop a span · q / Ctrl-C = quit
544
+ Env: NO_COLOR=1 disables color · CTC_DIR overrides the state dir
545
+ State dir: ${BASE}
546
+ Reads transcripts from: ${PROJECTS_DIR}
547
+ `);
548
+ }
549
+
550
+ // ---------------------------------------------------------------------------
551
+ // Main
552
+ // ---------------------------------------------------------------------------
553
+ function main() {
554
+ const argv = process.argv.slice(2);
555
+
556
+ const dir = takeOpt(argv, '--dir') || process.env.CTC_DIR;
557
+ if (dir) setBase(dir);
558
+
559
+ const cmd = argv[0];
560
+ if (cmd === 'mark') return handleMark(argv.slice(1));
561
+ if (cmd === 'paths' || cmd === 'where') return printPaths();
562
+ if (cmd === '--help' || cmd === '-h' || cmd === 'help') return printHelp();
563
+
564
+ const once = takeFlag(argv, '--once');
565
+ const lm = takeOpt(argv, '--log');
566
+ if (lm) {
567
+ if (!['daily', 'session', 'both', 'off'].includes(lm)) {
568
+ console.error(`Invalid --log "${lm}". Use daily | session | both | off.`);
569
+ process.exit(1);
570
+ }
571
+ LOG_MODE = lm;
572
+ }
573
+
574
+ if (!fs.existsSync(PROJECTS_DIR)) {
575
+ process.stderr.write(`No Claude Code data found at ${PROJECTS_DIR}\n`);
576
+ process.exit(1);
577
+ }
578
+
579
+ startedAt = Date.now();
580
+ scan();
581
+ markers = loadMarkers();
582
+
583
+ if (once) { process.stdout.write(render().join('\n') + '\n'); return; }
584
+
585
+ if (!process.stdout.isTTY) {
586
+ setInterval(() => { scan(); markers = loadMarkers(); writeRollupSafe(); process.stdout.write(render().join('\n') + '\n\n'); }, 1000);
587
+ return;
588
+ }
589
+
590
+ ensureDirs();
591
+ writeRollupSafe();
592
+ enterAlt();
593
+ draw();
594
+
595
+ process.stdin.setRawMode(true);
596
+ process.stdin.resume();
597
+ process.stdin.on('data', (buf) => {
598
+ const k = buf.toString();
599
+ if (prompt) {
600
+ if (k === '') return quit(0);
601
+ if (k === '\r' || k === '\n') { const l = prompt.buffer.trim(); prompt = null; tuiStart(l); return draw(); }
602
+ if (k === '') { prompt = null; setFlash('span start cancelled'); return draw(); }
603
+ if (k === '' || k === '\b') { prompt.buffer = prompt.buffer.slice(0, -1); return draw(); }
604
+ if (/^[\x20-\x7e]+$/.test(k)) { prompt.buffer += k; return draw(); }
605
+ return;
606
+ }
607
+ if (k === 'q' || k === '') return quit(0);
608
+ if (k === 'm') {
609
+ markers = loadMarkers();
610
+ if (markers.active) { tuiStop(); } else { prompt = { buffer: '' }; }
611
+ return draw();
612
+ }
613
+ });
614
+
615
+ process.on('SIGINT', () => quit(0));
616
+ process.on('SIGTERM', () => quit(0));
617
+ process.stdout.on('resize', draw);
618
+
619
+ setInterval(() => { scan(); markers = loadMarkers(); draw(); }, 1000);
620
+ setInterval(writeRollupSafe, 60000);
621
+
622
+ let pending = null;
623
+ try {
624
+ fs.watch(PROJECTS_DIR, { recursive: true }, () => {
625
+ if (pending) return;
626
+ pending = setTimeout(() => { pending = null; scan(); markers = loadMarkers(); draw(); }, 120);
627
+ });
628
+ } catch { /* recursive watch unsupported — 1s poll still covers it */ }
629
+ }
630
+
631
+ main();