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 +21 -0
- package/README.md +294 -0
- package/package.json +45 -0
- package/token-counter.js +631 -0
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)
|
|
8
|
+

|
|
9
|
+

|
|
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
|
+
}
|
package/token-counter.js
ADDED
|
@@ -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();
|