@jv-k/claude-gauge 0.0.1 → 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/README.md +622 -3
- package/dist/cli.js +395 -0
- package/dist/launcher.js +78 -0
- package/dist/settings-file.js +137 -0
- package/dist/settings.js +257 -0
- package/dist/statusline.js +2304 -0
- package/dist/tokenline.js +293 -0
- package/dist/wizard.js +264 -0
- package/package.json +26 -4
|
@@ -0,0 +1,2304 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// claude-gauge status line for the Claude Code terminal CLI.
|
|
4
|
+
//
|
|
5
|
+
// Reads the JSON Claude Code sends on stdin and prints one or more rows. The
|
|
6
|
+
// default is two:
|
|
7
|
+
//
|
|
8
|
+
// ctx 43% ▓▓░░░ 86.0k │ 5h 9% ░░┃░░ → 14:10 │ 7d 41% ▓▓░┃░ → 3d
|
|
9
|
+
// 14:58 │ 1h12m │ jv-k/claude-gauge │ ⎇ main* ↑1 │ Opus 5.5 │ effort high
|
|
10
|
+
//
|
|
11
|
+
// Almost everything comes from Claude Code's own payload
|
|
12
|
+
// (https://code.claude.com/docs/en/statusline): `rate_limits` carries the
|
|
13
|
+
// claude.ai 5-hour and 7-day windows, and any per-model weekly windows. The
|
|
14
|
+
// side calls are one `git status`, when a git part is shown, the modification
|
|
15
|
+
// times of the changed files, when the files part is, `vm_stat` for the ram
|
|
16
|
+
// part on macOS, the command --command names, for the command part, and, only
|
|
17
|
+
// when a part that needs it is shown, a read of the bytes the session
|
|
18
|
+
// transcript has gained since the last render. The env and plan parts read
|
|
19
|
+
// Claude Code's own config files, and the model part reads the provider from
|
|
20
|
+
// the environment. The today and week parts add the cost ledger, which each
|
|
21
|
+
// render keeps in the state folder.
|
|
22
|
+
//
|
|
23
|
+
// The parts it can show are in PART_REGISTRY, the switches it takes in
|
|
24
|
+
// SWITCHES and the --theme presets in THEME_REGISTRY, all below. README.md
|
|
25
|
+
// documents each in a table, and a test fails when the tables and the
|
|
26
|
+
// registry disagree. test/snapshots/themes/ holds the default rows in every
|
|
27
|
+
// theme.
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.COMMAND_TIMEOUT_MS = exports.payloadFile = exports.INSTRUCT_HOSTS = exports.SWITCHES = exports.DEFAULT_ROWS = exports.THEMES = exports.PARTS = void 0;
|
|
30
|
+
exports.render = render;
|
|
31
|
+
exports.parseArgs = parseArgs;
|
|
32
|
+
exports.readSwitches = readSwitches;
|
|
33
|
+
exports.payloadFromTranscript = payloadFromTranscript;
|
|
34
|
+
exports.recordCost = recordCost;
|
|
35
|
+
exports.withLock = withLock;
|
|
36
|
+
exports.modelName = modelName;
|
|
37
|
+
exports.repoFromRemote = repoFromRemote;
|
|
38
|
+
exports.worktreeFromGitDir = worktreeFromGitDir;
|
|
39
|
+
exports.instruction = instruction;
|
|
40
|
+
exports.readTranscriptActivity = readTranscriptActivity;
|
|
41
|
+
exports.readSetup = readSetup;
|
|
42
|
+
exports.readMemory = readMemory;
|
|
43
|
+
exports.runCommand = runCommand;
|
|
44
|
+
const fs = require("node:fs");
|
|
45
|
+
const os = require("node:os");
|
|
46
|
+
const path = require("node:path");
|
|
47
|
+
const node_child_process_1 = require("node:child_process");
|
|
48
|
+
const node_crypto_1 = require("node:crypto");
|
|
49
|
+
const node_url_1 = require("node:url");
|
|
50
|
+
// Every status line part, by the name --show takes. The default parts come
|
|
51
|
+
// first, in the order their rows show them; the order of the rest is the
|
|
52
|
+
// README's.
|
|
53
|
+
const partRegistry = {
|
|
54
|
+
ctx: { description: 'context window in use: percentage, bar and token count', row: 0, build: ({ data, config, theme }) => contextPart(data, config, theme) },
|
|
55
|
+
'5h': { description: '5-hour usage, with pace marker and reset time', row: 0, build: ({ data, config, theme, nowMs }) => windowPart('5h', data, config, theme, nowMs) },
|
|
56
|
+
'7d': { description: 'weekly usage, with pace marker and days to reset', row: 0, build: ({ data, config, theme, nowMs }) => windowPart('7d', data, config, theme, nowMs) },
|
|
57
|
+
time: { description: 'current local time', row: 1, build: ({ config, theme, nowMs }) => timePart(config, theme, nowMs) },
|
|
58
|
+
duration: { description: 'how long the session has run', row: 1, build: ({ data, theme }) => durationPart(data, theme) },
|
|
59
|
+
repo: {
|
|
60
|
+
description: 'owner/name from the origin remote, else the folder name',
|
|
61
|
+
row: 1,
|
|
62
|
+
build: ({ data, theme, folder }) => repoPart(data, theme, folder),
|
|
63
|
+
link: folderUrl,
|
|
64
|
+
},
|
|
65
|
+
branch: {
|
|
66
|
+
description: 'current git branch, dirty marker, ahead and behind, and the linked worktree',
|
|
67
|
+
row: 1,
|
|
68
|
+
build: ({ data, config, theme, git }) => branchPart(data, config, theme, git()),
|
|
69
|
+
link: ({ data, git }) => branchUrl(data.workspace?.repo, git().upstream),
|
|
70
|
+
},
|
|
71
|
+
model: { description: 'model name, and the API provider when not first-party', row: 1, build: ({ data, theme, processEnv }) => modelPart(data, theme, processEnv) },
|
|
72
|
+
effort: { description: 'reasoning effort', row: 1, build: ({ data, config, theme }) => effortPart(data, config, theme) },
|
|
73
|
+
dir: { description: 'folder Claude Code runs in', build: ({ theme, folder }) => `${theme.info}${folder}${RESET}`, link: folderUrl },
|
|
74
|
+
cost: { description: 'estimated session cost', build: ({ data, theme }) => costPart(data, theme) },
|
|
75
|
+
lines: { description: 'lines added and removed this session', build: ({ data, theme }) => linesPart(data, theme) },
|
|
76
|
+
name: { description: 'session name or title', build: ({ data, theme }) => namePart(data, theme) },
|
|
77
|
+
thinking: { description: 'extended thinking, when on', build: ({ data, theme }) => thinkingPart(data, theme) },
|
|
78
|
+
fast: { description: 'fast mode, when on', build: ({ data, theme }) => fastPart(data, theme) },
|
|
79
|
+
style: { description: 'output style, when not the default', build: ({ data, config, theme }) => stylePart(data, config, theme) },
|
|
80
|
+
git: { description: 'modified, staged, deleted and untracked file counts, when any', build: ({ theme, git }) => gitCountsPart(git(), theme) },
|
|
81
|
+
files: { description: 'the most recently changed files', build: ({ theme, git, mtimeOf }) => filesPart(git(), theme, mtimeOf) },
|
|
82
|
+
worktree: { description: 'linked git worktree', build: ({ data, config, theme }) => worktreePart(data, config, theme) },
|
|
83
|
+
pr: { description: "the branch's open pull request and its review state", build: ({ data, theme }) => prPart(data, theme), link: ({ data }) => webUrl(data.pr?.url) },
|
|
84
|
+
agent: { description: 'agent name, with --agent', build: ({ data, config, theme }) => agentPart(data, config, theme) },
|
|
85
|
+
cache: { description: 'prompt cache hit ratio and warmth', build: ({ data, config, theme }) => cachePart(data, config, theme) },
|
|
86
|
+
spend: { description: 'spend against a gateway spend limit', build: ({ data, config, theme }) => spendPart(data, config, theme) },
|
|
87
|
+
version: { description: 'Claude Code version', build: ({ data, theme }) => versionPart(data, theme) },
|
|
88
|
+
today: { description: "today's spend across sessions, from the cost ledger", build: (ctx) => spentPart('today', ctx) },
|
|
89
|
+
week: { description: "this week's spend across sessions, from the cost ledger", build: (ctx) => spentPart('week', ctx) },
|
|
90
|
+
tools: { description: 'the running tool and its target, and completed tools with counts', build: ({ activity, theme, cwd }) => toolsPart(activity(), theme, cwd) },
|
|
91
|
+
agents: { description: 'running subagents, and those finished in the last minute', build: ({ activity, theme, nowMs }) => agentsPart(activity(), theme, nowMs) },
|
|
92
|
+
todos: { description: 'the todo in progress, and how many todos are done', build: ({ activity, config, theme }) => todosPart(activity(), config, theme) },
|
|
93
|
+
skills: { description: 'skills used, and MCP servers called, marking those whose last call failed', build: ({ activity, config, theme }) => skillsPart(activity(), config, theme) },
|
|
94
|
+
compactions: { description: 'how many times the conversation was compacted', build: ({ activity, config, theme }) => compactionsPart(activity(), config, theme) },
|
|
95
|
+
reply: { description: 'time since the last reply', build: ({ activity, config, theme, nowMs }) => replyPart(activity(), config, theme, nowMs) },
|
|
96
|
+
speed: { description: 'output tokens per second of the last response', build: ({ activity, theme }) => speedPart(activity(), theme) },
|
|
97
|
+
env: { description: 'CLAUDE.md files, rules, MCP servers and hooks loaded', build: ({ config, theme, setup }) => envPart(setup(), config, theme) },
|
|
98
|
+
plan: { description: 'subscription plan and signed-in user', build: ({ theme, setup }) => planPart(setup(), theme) },
|
|
99
|
+
models: { description: 'per-model weekly usage, as 7d shows the week', build: ({ data, config, theme, nowMs }) => modelsPart(data, config, theme, nowMs) },
|
|
100
|
+
limit: { description: 'a notice naming each exhausted window and its reset', build: ({ data, config, theme, nowMs }) => limitPart(data, config, theme, nowMs) },
|
|
101
|
+
ram: { description: 'system memory in use: percentage, bar and amount', build: ({ config, theme, memory }) => ramPart(memory(), config, theme) },
|
|
102
|
+
text: { description: 'fixed text, with --text', build: ({ config, theme }) => outsideText(config.text, theme) },
|
|
103
|
+
command: { description: 'first line of output of a shell command, with --command', build: ({ theme, commandOutput }) => outsideText(commandOutput(), theme) },
|
|
104
|
+
};
|
|
105
|
+
// The same object, typed so that every entry reads as a PartSpec: the literal
|
|
106
|
+
// above keeps the names for the Part type, this keeps row optional on each.
|
|
107
|
+
const PART_REGISTRY = partRegistry;
|
|
108
|
+
const PARTS = Object.keys(PART_REGISTRY);
|
|
109
|
+
exports.PARTS = PARTS;
|
|
110
|
+
const isPart = (name) => PARTS.includes(name);
|
|
111
|
+
// The rows shown when no --show names a known part: the headroom figures on
|
|
112
|
+
// top, the session around them below.
|
|
113
|
+
const DEFAULT_ROWS = PARTS.reduce((rows, part) => {
|
|
114
|
+
const { row } = PART_REGISTRY[part];
|
|
115
|
+
if (row != null)
|
|
116
|
+
(rows[row] ??= []).push(part);
|
|
117
|
+
return rows;
|
|
118
|
+
}, []);
|
|
119
|
+
exports.DEFAULT_ROWS = DEFAULT_ROWS;
|
|
120
|
+
const DEFAULTS = {
|
|
121
|
+
rows: DEFAULT_ROWS,
|
|
122
|
+
segments: 5,
|
|
123
|
+
labels: true,
|
|
124
|
+
bars: true,
|
|
125
|
+
pace: true,
|
|
126
|
+
reset: true,
|
|
127
|
+
hour12: false,
|
|
128
|
+
compact: false,
|
|
129
|
+
links: true,
|
|
130
|
+
right: [],
|
|
131
|
+
text: '',
|
|
132
|
+
command: '',
|
|
133
|
+
theme: 'default',
|
|
134
|
+
colors: {},
|
|
135
|
+
barFilled: '▓',
|
|
136
|
+
barEmpty: '░',
|
|
137
|
+
};
|
|
138
|
+
// Every status line switch.
|
|
139
|
+
const SWITCHES = [
|
|
140
|
+
{
|
|
141
|
+
name: '--show',
|
|
142
|
+
value: '<parts>',
|
|
143
|
+
description: 'one row: the parts to show, in order, comma-separated; repeat it for more rows',
|
|
144
|
+
apply: (config, value) => {
|
|
145
|
+
const parts = value.split(',').map((p) => p.trim()).filter(isPart);
|
|
146
|
+
if (parts.length)
|
|
147
|
+
(config.rows ??= []).push(parts);
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{ name: '--segments', value: '<5|10>', description: 'cells per bar (default 5)', apply: (config, value) => { config.segments = value; } },
|
|
151
|
+
{ name: '--no-labels', description: 'drop the labels in front of values', apply: (config) => { config.labels = false; } },
|
|
152
|
+
{ name: '--no-bars', description: 'drop the bars', apply: (config) => { config.bars = false; } },
|
|
153
|
+
{ name: '--no-pace', description: 'drop the pace markers', apply: (config) => { config.pace = false; } },
|
|
154
|
+
{ name: '--no-reset', description: 'drop the reset times', apply: (config) => { config.reset = false; } },
|
|
155
|
+
{ name: '--12h', description: '12-hour clock for the time part and reset times', apply: (config) => { config.hour12 = true; } },
|
|
156
|
+
{ name: '--compact', description: 'shorter separators and labels, for narrow terminals', apply: (config) => { config.compact = true; } },
|
|
157
|
+
{ name: '--no-links', description: 'drop the links on dir, repo, branch and pr', apply: (config) => { config.links = false; } },
|
|
158
|
+
{
|
|
159
|
+
name: '--right',
|
|
160
|
+
value: '<parts>',
|
|
161
|
+
description: 'the parts to right-align within their row, comma-separated, when the terminal width is known',
|
|
162
|
+
apply: (config, value) => {
|
|
163
|
+
config.right = [...(config.right ?? []), ...value.split(',').map((p) => p.trim()).filter(isPart)];
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
{ name: '--text', value: '<text>', description: 'the text the text part shows', apply: (config, value) => { config.text = value; } },
|
|
167
|
+
{
|
|
168
|
+
name: '--command',
|
|
169
|
+
value: '<command>',
|
|
170
|
+
description: 'the shell command the command part runs, with a short timeout',
|
|
171
|
+
apply: (config, value) => { config.command = value; },
|
|
172
|
+
},
|
|
173
|
+
{ name: '--theme', value: '<name>', description: 'the colour preset: default, mono, high-contrast or pastel', apply: (config, value) => { config.theme = value.trim(); } },
|
|
174
|
+
{
|
|
175
|
+
name: '--color',
|
|
176
|
+
value: '<part>=<colour>',
|
|
177
|
+
description: "one part's colour: a name, a 256-colour number or a hex colour; comma-separate or repeat it for more parts",
|
|
178
|
+
apply: (config, value) => {
|
|
179
|
+
for (const pair of value.split(',')) {
|
|
180
|
+
const [part, color] = pair.split(/=(.*)/s).map((p) => p.trim());
|
|
181
|
+
const code = colorCode(color ?? '');
|
|
182
|
+
if (isPart(part) && code)
|
|
183
|
+
config.colors = { ...config.colors, [part]: code };
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
{ name: '--bar-filled', value: '<char>', description: 'the character of a filled bar cell (default ▓)', apply: (config, value) => { if (isBarChar(value))
|
|
188
|
+
config.barFilled = value; } },
|
|
189
|
+
{ name: '--bar-empty', value: '<char>', description: 'the character of an empty bar cell (default ░)', apply: (config, value) => { if (isBarChar(value))
|
|
190
|
+
config.barEmpty = value; } },
|
|
191
|
+
{ name: '--latest', description: "print the calling session's rows from its transcript, as plain text" },
|
|
192
|
+
{ name: '--window', value: '<size>', description: 'with --latest: the context window size, such as 200k or 1m' },
|
|
193
|
+
{ name: '--instruct', description: 'as a SessionStart hook: have Claude end each reply with the --latest rows' },
|
|
194
|
+
];
|
|
195
|
+
exports.SWITCHES = SWITCHES;
|
|
196
|
+
// A bar cell is one character, and never a control character: the switch is
|
|
197
|
+
// printed as it is, in every cell.
|
|
198
|
+
const isBarChar = (value) => Array.from(value).length === 1 && sanitise(value) === value;
|
|
199
|
+
// The switches in `argv` as the status line reads them: a known switch that
|
|
200
|
+
// takes a value takes the next word, unless it has one after `=`. Any other
|
|
201
|
+
// word stands alone.
|
|
202
|
+
function readSwitches(argv) {
|
|
203
|
+
const read = [];
|
|
204
|
+
for (let i = 0; i < argv.length; i++) {
|
|
205
|
+
const start = i;
|
|
206
|
+
const [name, inline] = argv[i].split(/=(.*)/s);
|
|
207
|
+
const known = SWITCHES.find((s) => s.name === name && s.apply);
|
|
208
|
+
const value = known?.value ? (inline ?? argv[++i] ?? '') : '';
|
|
209
|
+
read.push({ name, value, known, words: argv.slice(start, i + 1) });
|
|
210
|
+
}
|
|
211
|
+
return read;
|
|
212
|
+
}
|
|
213
|
+
// Turns the switches into a config. Unknown switches and part names are
|
|
214
|
+
// ignored, and a --show with no known part adds no row: a status line should
|
|
215
|
+
// show something rather than fail.
|
|
216
|
+
function parseArgs(argv) {
|
|
217
|
+
const config = {};
|
|
218
|
+
for (const { known, value } of readSwitches(argv))
|
|
219
|
+
known?.apply?.(config, value);
|
|
220
|
+
return config;
|
|
221
|
+
}
|
|
222
|
+
const RESET = '\x1b[0m';
|
|
223
|
+
const BLUE = '\x1b[0;34m';
|
|
224
|
+
const GREEN = '\x1b[0;32m';
|
|
225
|
+
const GRAY = '\x1b[0;90m';
|
|
226
|
+
const YELLOW = '\x1b[0;33m';
|
|
227
|
+
const CYAN = '\x1b[0;36m';
|
|
228
|
+
const RED = '\x1b[0;31m';
|
|
229
|
+
const ansi256 = (n) => `\x1b[38;5;${n}m`;
|
|
230
|
+
// The 16-colour palette's bright half, and its bold form.
|
|
231
|
+
const bright = (n) => `\x1b[0;${n}m`;
|
|
232
|
+
const bold = (n) => `\x1b[1;${n}m`;
|
|
233
|
+
// The --theme presets, by name. default is the first, and what an unknown
|
|
234
|
+
// name gives.
|
|
235
|
+
const THEME_REGISTRY = {
|
|
236
|
+
default: {
|
|
237
|
+
description: 'green-to-red usage, cyan context, grey details',
|
|
238
|
+
levels: [22, 28, 34, 100, 142, 178, 172, 166, 160, 124].map(ansi256),
|
|
239
|
+
pace: [34, 37, 178, 208, 160, 135].map(ansi256),
|
|
240
|
+
context: [CYAN, YELLOW, ansi256(160)],
|
|
241
|
+
muted: GRAY,
|
|
242
|
+
accent: YELLOW,
|
|
243
|
+
good: GREEN,
|
|
244
|
+
bad: RED,
|
|
245
|
+
info: BLUE,
|
|
246
|
+
},
|
|
247
|
+
mono: {
|
|
248
|
+
description: "no colour: the terminal's own text colour throughout",
|
|
249
|
+
levels: Array(10).fill(''),
|
|
250
|
+
pace: Array(6).fill(''),
|
|
251
|
+
context: ['', '', ''],
|
|
252
|
+
muted: '',
|
|
253
|
+
accent: '',
|
|
254
|
+
good: '',
|
|
255
|
+
bad: '',
|
|
256
|
+
info: '',
|
|
257
|
+
},
|
|
258
|
+
'high-contrast': {
|
|
259
|
+
description: "the terminal's bright colours, and white details, for dim screens and low vision",
|
|
260
|
+
levels: [92, 92, 92, 92, 93, 93, 93, 91, 91].map(bright).concat(bold(91)),
|
|
261
|
+
pace: [bright(92), bright(96), bright(93), bold(93), bright(91), bright(95)],
|
|
262
|
+
context: [bright(96), bright(93), bright(91)],
|
|
263
|
+
muted: bright(97),
|
|
264
|
+
accent: bright(93),
|
|
265
|
+
good: bright(92),
|
|
266
|
+
bad: bright(91),
|
|
267
|
+
info: bright(94),
|
|
268
|
+
},
|
|
269
|
+
pastel: {
|
|
270
|
+
description: 'soft 256-colour tones on the same green-to-red scale',
|
|
271
|
+
levels: [157, 151, 150, 187, 229, 223, 216, 217, 210, 211].map(ansi256),
|
|
272
|
+
pace: [151, 152, 229, 216, 210, 183].map(ansi256),
|
|
273
|
+
context: [152, 229, 210].map(ansi256),
|
|
274
|
+
muted: ansi256(248),
|
|
275
|
+
accent: ansi256(229),
|
|
276
|
+
good: ansi256(151),
|
|
277
|
+
bad: ansi256(210),
|
|
278
|
+
info: ansi256(153),
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
const THEMES = Object.keys(THEME_REGISTRY);
|
|
282
|
+
exports.THEMES = THEMES;
|
|
283
|
+
// A --color value as a colour code: a name such as red or bright-red, a
|
|
284
|
+
// 256-colour number, or a hex colour as #f80 or #ff8800. Anything else is
|
|
285
|
+
// none, so a switch can never print a code of its own.
|
|
286
|
+
const NAMED_COLORS = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'];
|
|
287
|
+
function colorCode(text) {
|
|
288
|
+
const name = text.toLowerCase();
|
|
289
|
+
if (name === 'gray' || name === 'grey')
|
|
290
|
+
return GRAY;
|
|
291
|
+
const named = NAMED_COLORS.indexOf(name.replace(/^bright-/, ''));
|
|
292
|
+
if (named >= 0)
|
|
293
|
+
return `\x1b[0;${(name.startsWith('bright-') ? 90 : 30) + named}m`;
|
|
294
|
+
if (/^\d{1,3}$/.test(name) && Number(name) <= 255)
|
|
295
|
+
return ansi256(Number(name));
|
|
296
|
+
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(name)?.[1];
|
|
297
|
+
if (!hex)
|
|
298
|
+
return undefined;
|
|
299
|
+
const full = hex.length === 3 ? [...hex].map((d) => d + d).join('') : hex;
|
|
300
|
+
const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16));
|
|
301
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
302
|
+
}
|
|
303
|
+
// A theme that draws everything in one colour, for a part --color names. The
|
|
304
|
+
// pace marker keeps the theme's colours, because its colour is what it says.
|
|
305
|
+
const solid = (theme, color) => ({
|
|
306
|
+
description: theme.description,
|
|
307
|
+
levels: theme.levels.map(() => color),
|
|
308
|
+
pace: theme.pace,
|
|
309
|
+
context: theme.context.map(() => color),
|
|
310
|
+
muted: color,
|
|
311
|
+
accent: color,
|
|
312
|
+
good: color,
|
|
313
|
+
bad: color,
|
|
314
|
+
info: color,
|
|
315
|
+
});
|
|
316
|
+
// A record's own value for a key, never one it inherits, such as
|
|
317
|
+
// constructor: names from switches and the payload reach these lookups.
|
|
318
|
+
const ownValue = (record, key) => Object.hasOwn(record, key) ? record[key] : undefined;
|
|
319
|
+
// The preset a --theme names, else the default.
|
|
320
|
+
const themeOf = (name) => ownValue(THEME_REGISTRY, name) ?? THEME_REGISTRY.default;
|
|
321
|
+
// Terminal escape sequences, whole: CSI (colours, cursor moves, erases), OSC
|
|
322
|
+
// (window titles, hyperlinks, the clipboard), the DCS, SOS, PM and APC
|
|
323
|
+
// strings, and every other ESC sequence, each in its 7-bit and 8-bit forms.
|
|
324
|
+
// An OSC or string left unterminated runs to the end of the text, as a
|
|
325
|
+
// terminal would read it.
|
|
326
|
+
const ESCAPE_SEQUENCE = /(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]|(?:\x1b\]|\x9d)[^\x07\x1b\x9c]*(?:\x07|\x1b\\|\x9c)?|(?:\x1b[PX^_]|[\x90\x98\x9e\x9f])[^\x1b\x9c]*(?:\x1b\\|\x9c)?|\x1b[ -/]*[0-~]/g;
|
|
327
|
+
// The control characters an escape sequence leaves behind or that act alone:
|
|
328
|
+
// C0, DEL, C1, and the bidirectional formatting characters that reorder text.
|
|
329
|
+
const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
|
|
330
|
+
// Text from outside claude-gauge (the payload, the transcript, git), safe to
|
|
331
|
+
// print: nothing in it can move the cursor, change colours or reorder the
|
|
332
|
+
// row. claude-gauge's own colours are added around it afterwards. render
|
|
333
|
+
// cleans what reaches the parts through their context; a part that prints
|
|
334
|
+
// text from anywhere else, such as a switch or a command, calls this itself.
|
|
335
|
+
const sanitise = (text) => text.replace(ESCAPE_SEQUENCE, '').replace(CONTROL_CHARACTER, '');
|
|
336
|
+
// Text without claude-gauge's own colour codes and OSC 8 links, the only
|
|
337
|
+
// escapes left in a rendered row once its parts are sanitised.
|
|
338
|
+
const stripOwnCodes = (text) => text.replace(/\x1b\[[0-9;]*m|\x1b\]8;;[^\x07]*\x07/g, '');
|
|
339
|
+
// A parsed payload with every string in it sanitised, at any depth.
|
|
340
|
+
function sanitiseAll(value) {
|
|
341
|
+
if (typeof value === 'string')
|
|
342
|
+
return sanitise(value);
|
|
343
|
+
if (Array.isArray(value))
|
|
344
|
+
return value.map(sanitiseAll);
|
|
345
|
+
if (value && typeof value === 'object') {
|
|
346
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, sanitiseAll(v)]));
|
|
347
|
+
}
|
|
348
|
+
return value;
|
|
349
|
+
}
|
|
350
|
+
// The labels --compact shortens, and their short forms. A label not named
|
|
351
|
+
// here is short already.
|
|
352
|
+
const COMPACT_LABELS = {
|
|
353
|
+
ctx: 'c',
|
|
354
|
+
effort: 'eff',
|
|
355
|
+
style: 'sty',
|
|
356
|
+
agent: 'agt',
|
|
357
|
+
cache: 'cch',
|
|
358
|
+
spend: 'spd',
|
|
359
|
+
today: 'tdy',
|
|
360
|
+
week: 'wk',
|
|
361
|
+
compactions: 'cmp',
|
|
362
|
+
};
|
|
363
|
+
// A part's label and the space after it: none with --no-labels, the short
|
|
364
|
+
// form with --compact.
|
|
365
|
+
const labelOf = (config, name) => config.labels ? `${config.compact ? (COMPACT_LABELS[name] ?? name) : name} ` : '';
|
|
366
|
+
// The separator between a row's segments.
|
|
367
|
+
const separatorOf = (config, theme) => `${theme.muted}${config.compact ? '│' : ' │ '}${RESET}`;
|
|
368
|
+
// The usage level's colour, in ten steps: in the default theme, dark green
|
|
369
|
+
// at 0-10% and deep red above 90%.
|
|
370
|
+
const levelColor = (theme, pct) => theme.levels[Math.min(9, Math.max(0, Math.ceil(pct / 10) - 1))];
|
|
371
|
+
// The pace marker's colour, by the usage the current rate projects for the
|
|
372
|
+
// end of the window.
|
|
373
|
+
const PACE_LIMITS = [50, 75, 90, 100, 120]; // comfortable, on track, warming, pressing, critical; then runaway
|
|
374
|
+
const paceColor = (theme, projected) => {
|
|
375
|
+
const step = PACE_LIMITS.findIndex((limit) => projected < limit);
|
|
376
|
+
return theme.pace[step < 0 ? PACE_LIMITS.length : step];
|
|
377
|
+
};
|
|
378
|
+
const WINDOWS = {
|
|
379
|
+
'5h': { key: 'five_hour', seconds: 5 * 3600, minElapsed: 540, resetText: (at, config) => formatTime(at, config) }, // 9 minutes
|
|
380
|
+
'7d': { key: 'seven_day', seconds: 7 * 86400, minElapsed: 3024, resetText: (at, config, nowMs) => formatDaysOrTime(at, config, nowMs) }, // about 50 minutes
|
|
381
|
+
};
|
|
382
|
+
// Compact counts, the same as the token line's: 1.69M, 427k, 6.5k, 830.
|
|
383
|
+
const fmt = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(2)}M`
|
|
384
|
+
: n >= 1e5 ? `${Math.round(n / 1e3)}k`
|
|
385
|
+
: n >= 1e3 ? `${(n / 1e3).toFixed(1)}k`
|
|
386
|
+
: String(n);
|
|
387
|
+
// A word with its first letter in capitals: opus → Opus.
|
|
388
|
+
const capitalised = (word) => word[0].toUpperCase() + word.slice(1);
|
|
389
|
+
// Bars come in 5 or 10 cells; anything else falls back to 5.
|
|
390
|
+
const segmentsOf = (value) => (Number(value) === 10 ? 10 : 5);
|
|
391
|
+
// The cells of a bar, filled in proportion to pct, one character each.
|
|
392
|
+
const cellsFor = (pct, { segments, barFilled, barEmpty }) => {
|
|
393
|
+
const filled = Math.min(segments, Math.max(0, Math.round((pct * segments) / 100)));
|
|
394
|
+
return [...Array(filled).fill(barFilled), ...Array(segments - filled).fill(barEmpty)];
|
|
395
|
+
};
|
|
396
|
+
// How every git call runs: for at most a second, with its output as text
|
|
397
|
+
// and its errors dropped.
|
|
398
|
+
const GIT_OPTIONS = { timeout: 1000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] };
|
|
399
|
+
// A program's output, with no input and a one-second timeout. Throws when
|
|
400
|
+
// the program fails.
|
|
401
|
+
const runQuietly = (command, args, cwd) => (0, node_child_process_1.execFileSync)(command, args, { cwd, timeout: 1000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
|
|
402
|
+
function git(cwd, args) {
|
|
403
|
+
try {
|
|
404
|
+
return runQuietly('git', args, cwd).trim();
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return ''; // not a repository, or git is missing
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// The branch HEAD names, read from the repository's files rather than from
|
|
411
|
+
// git: the fallback when git status takes too long, so the render never
|
|
412
|
+
// waits on a second git process. Inside a linked worktree .git is a file
|
|
413
|
+
// that names the worktree's own git folder. '' outside a repository and on
|
|
414
|
+
// a detached HEAD.
|
|
415
|
+
function headBranch(cwd) {
|
|
416
|
+
try {
|
|
417
|
+
for (let dir = path.resolve(cwd);; dir = path.dirname(dir)) {
|
|
418
|
+
const dotGit = path.join(dir, '.git');
|
|
419
|
+
if (fs.existsSync(dotGit)) {
|
|
420
|
+
const gitDir = fs.statSync(dotGit).isFile()
|
|
421
|
+
? path.resolve(dir, /^gitdir: (.+)$/m.exec(fs.readFileSync(dotGit, 'utf8'))?.[1].trim() ?? '.git')
|
|
422
|
+
: dotGit;
|
|
423
|
+
return /^ref: refs\/heads\/(.+)$/m.exec(fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8'))?.[1].trim() ?? '';
|
|
424
|
+
}
|
|
425
|
+
if (path.dirname(dir) === dir)
|
|
426
|
+
return '';
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
return '';
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// What `git status --porcelain=v2 --branch` prints for the folder: '' when
|
|
434
|
+
// it is not a repository or git is missing, and undefined when git gave no
|
|
435
|
+
// answer in time or printed more than a status line can use. It takes no
|
|
436
|
+
// optional locks, so it never blocks a git command the user runs, and the
|
|
437
|
+
// two settings fix its paths as relative to the folder and unquoted where
|
|
438
|
+
// they can be.
|
|
439
|
+
function gitStatus(cwd) {
|
|
440
|
+
try {
|
|
441
|
+
return (0, node_child_process_1.execFileSync)('git', ['--no-optional-locks', '-c', 'core.quotePath=false', '-c', 'status.relativePaths=true', 'status', '--porcelain=v2', '--branch'], { ...GIT_OPTIONS, cwd, maxBuffer: 1024 * 1024 });
|
|
442
|
+
}
|
|
443
|
+
catch (e) {
|
|
444
|
+
const code = e.code;
|
|
445
|
+
return code === 'ETIMEDOUT' || code === 'ENOBUFS' ? undefined : '';
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// A changed file's last modification time; none for a deleted file. A
|
|
449
|
+
// symbolic link is the change, so its own time counts, not its target's, and
|
|
450
|
+
// a link whose target is gone still has one.
|
|
451
|
+
function fileMtime(file) {
|
|
452
|
+
try {
|
|
453
|
+
return fs.lstatSync(file).mtimeMs;
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const noChanges = (branch) => ({ branch, upstream: '', ahead: 0, behind: 0, staged: 0, modified: 0, deleted: 0, untracked: 0, changed: [] });
|
|
460
|
+
// A path as git prints it, unquoted. Git quotes a path that holds a control
|
|
461
|
+
// character, a double quote or a backslash, C-style, with octal for bytes.
|
|
462
|
+
function unquotePath(text) {
|
|
463
|
+
if (text.length < 2 || !text.startsWith('"') || !text.endsWith('"'))
|
|
464
|
+
return text;
|
|
465
|
+
const named = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13 };
|
|
466
|
+
const bytes = [];
|
|
467
|
+
const chars = [...text.slice(1, -1)];
|
|
468
|
+
for (let i = 0; i < chars.length; i++) {
|
|
469
|
+
if (chars[i] !== '\\' || i + 1 === chars.length) {
|
|
470
|
+
bytes.push(...Buffer.from(chars[i]));
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
const octal = /^[0-7]{3}/.exec(chars.slice(i + 1, i + 4).join(''));
|
|
474
|
+
if (octal) {
|
|
475
|
+
bytes.push(parseInt(octal[0], 8));
|
|
476
|
+
i += 3;
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
const next = chars[++i];
|
|
480
|
+
bytes.push(...(named[next] != null ? [named[next]] : Buffer.from(next)));
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return Buffer.from(bytes).toString('utf8');
|
|
484
|
+
}
|
|
485
|
+
// A changed file from its path as git prints it. An untracked folder's path
|
|
486
|
+
// ends in /, and so does its name.
|
|
487
|
+
function changedFile(printed) {
|
|
488
|
+
const file = unquotePath(printed);
|
|
489
|
+
return { path: file, name: sanitise(path.basename(file) + (file.endsWith('/') ? '/' : '')) };
|
|
490
|
+
}
|
|
491
|
+
// The fields before the path on each kind of porcelain v2 entry line:
|
|
492
|
+
// ordinary, renamed or copied, and unmerged.
|
|
493
|
+
const FIELDS_BEFORE_PATH = { '1': 8, '2': 9, u: 10 };
|
|
494
|
+
// The state in porcelain v2 status output. Each side of an entry counts on
|
|
495
|
+
// its own: a deletion on either side as deleted, any other change in the
|
|
496
|
+
// index as staged and in the work tree as modified. So a file staged and
|
|
497
|
+
// then changed or deleted again counts twice. An unmerged file counts as
|
|
498
|
+
// modified.
|
|
499
|
+
function parseStatus(output) {
|
|
500
|
+
const state = noChanges('');
|
|
501
|
+
for (const line of output.split(/\r?\n/)) {
|
|
502
|
+
const fields = line.split(' ');
|
|
503
|
+
const [kind, key] = fields;
|
|
504
|
+
if (kind === '#') {
|
|
505
|
+
if (key === 'branch.head' && fields[2] !== '(detached)')
|
|
506
|
+
state.branch = fields.slice(2).join(' ');
|
|
507
|
+
if (key === 'branch.upstream')
|
|
508
|
+
state.upstream = fields.slice(2).join(' ');
|
|
509
|
+
if (key === 'branch.ab') {
|
|
510
|
+
state.ahead = Math.abs(Number.parseInt(fields[2], 10)) || 0;
|
|
511
|
+
state.behind = Math.abs(Number.parseInt(fields[3], 10)) || 0;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
else if (kind === '?') {
|
|
515
|
+
state.untracked++;
|
|
516
|
+
state.changed.push(changedFile(line.slice(2)));
|
|
517
|
+
}
|
|
518
|
+
else if (Object.hasOwn(FIELDS_BEFORE_PATH, kind)) {
|
|
519
|
+
// A renamed entry ends <path>TAB<original path>; git quotes a path
|
|
520
|
+
// that holds a tab, so the first tab is the separator.
|
|
521
|
+
state.changed.push(changedFile(fields.slice(FIELDS_BEFORE_PATH[kind]).join(' ').split('\t')[0]));
|
|
522
|
+
const [x, y] = key;
|
|
523
|
+
if (kind === 'u')
|
|
524
|
+
state.modified++;
|
|
525
|
+
else {
|
|
526
|
+
if (x === 'D' || y === 'D')
|
|
527
|
+
state.deleted++;
|
|
528
|
+
if (x !== '.' && x !== 'D')
|
|
529
|
+
state.staged++;
|
|
530
|
+
if (y !== '.' && y !== 'D')
|
|
531
|
+
state.modified++;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return state;
|
|
536
|
+
}
|
|
537
|
+
// The folder's git state from one status call, or the branch alone, read
|
|
538
|
+
// from HEAD, when the status took too long.
|
|
539
|
+
function readGit(cwd, statusOf, branchOf) {
|
|
540
|
+
const output = statusOf(cwd);
|
|
541
|
+
const state = output === undefined ? noChanges(branchOf(cwd)) : parseStatus(output);
|
|
542
|
+
return { ...state, branch: sanitise(state.branch) };
|
|
543
|
+
}
|
|
544
|
+
// Reset times are rounded to the nearest minute, so 6:59:45 shows as 07:00.
|
|
545
|
+
const resetDate = (epochSeconds) => new Date(Math.round(epochSeconds / 60) * 60 * 1000);
|
|
546
|
+
// hourCycle, not hour12: with hour12, Node 18 and 20 put en-GB on the 0-11
|
|
547
|
+
// clock, so noon reads 00:00 pm. hourCycle names the clock outright.
|
|
548
|
+
const clockOf = (config) => ({
|
|
549
|
+
hour: '2-digit',
|
|
550
|
+
minute: '2-digit',
|
|
551
|
+
hourCycle: config.hour12 ? 'h12' : 'h23',
|
|
552
|
+
});
|
|
553
|
+
function formatTime(epochSeconds, config) {
|
|
554
|
+
return resetDate(epochSeconds).toLocaleTimeString('en-GB', clockOf(config));
|
|
555
|
+
}
|
|
556
|
+
// The weekly window resets days away: show the calendar days until the
|
|
557
|
+
// reset ("3d"), or the time when the reset falls today.
|
|
558
|
+
function formatDaysOrTime(epochSeconds, config, nowMs) {
|
|
559
|
+
const midnight = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
560
|
+
const days = Math.round((midnight(resetDate(epochSeconds)) - midnight(new Date(nowMs))) / 86400000);
|
|
561
|
+
return days > 0 ? `${days}d` : formatTime(epochSeconds, config);
|
|
562
|
+
}
|
|
563
|
+
// A usage bar. With a pace marker, ┃ replaces the cell where "now" falls in
|
|
564
|
+
// the window, coloured by the projected end-of-window usage.
|
|
565
|
+
function usageBar(pct, color, window, resetsAt, config, theme, nowMs) {
|
|
566
|
+
const cells = cellsFor(pct, config);
|
|
567
|
+
const remaining = resetsAt ? resetsAt - nowMs / 1000 : 0;
|
|
568
|
+
if (!config.pace || remaining <= 0 || remaining >= window.seconds)
|
|
569
|
+
return ` ${cells.join('')}`;
|
|
570
|
+
const elapsed = window.seconds - remaining;
|
|
571
|
+
const pos = Math.min(config.segments - 1, Math.max(0, Math.round((elapsed * config.segments) / window.seconds)));
|
|
572
|
+
// Early in a window the projection is noise, so keep the usage colour. A
|
|
573
|
+
// pace colour of '' (mono) resets, so the marker never takes a --color.
|
|
574
|
+
const marker = elapsed >= window.minElapsed ? paceColor(theme, (pct * window.seconds) / elapsed) || (color && RESET) : color;
|
|
575
|
+
return ` ${cells.slice(0, pos).join('')}${marker}┃${RESET}${color}${cells.slice(pos + 1).join('')}`;
|
|
576
|
+
}
|
|
577
|
+
// The 5h or 7d part. rate_limits is present only for claude.ai Pro and Max
|
|
578
|
+
// subscribers, after the first response of a session; "~" marks a window
|
|
579
|
+
// Claude Code has not reported yet.
|
|
580
|
+
function windowPart(name, data, config, theme, nowMs) {
|
|
581
|
+
const window = WINDOWS[name];
|
|
582
|
+
const label = labelOf(config, name);
|
|
583
|
+
const limit = data.rate_limits?.[window.key];
|
|
584
|
+
if (limit?.used_percentage == null)
|
|
585
|
+
return `${theme.accent}${label}~${RESET}`;
|
|
586
|
+
return usageSegment(label, { ...limit, used_percentage: limit.used_percentage }, window, config, theme, nowMs);
|
|
587
|
+
}
|
|
588
|
+
// A reported window's percentage, bar and reset, behind its label.
|
|
589
|
+
function usageSegment(label, limit, window, config, theme, nowMs) {
|
|
590
|
+
const pct = Math.round(limit.used_percentage);
|
|
591
|
+
const color = levelColor(theme, pct);
|
|
592
|
+
const bar = config.bars ? usageBar(pct, color, window, limit.resets_at, config, theme, nowMs) : '';
|
|
593
|
+
const reset = config.reset && limit.resets_at ? ` → ${window.resetText(limit.resets_at, config, nowMs)}` : '';
|
|
594
|
+
return `${color}${label}${pct}%${bar}${reset}${RESET}`;
|
|
595
|
+
}
|
|
596
|
+
// The per-model weekly windows Claude Code reports, as seven_day_<model>
|
|
597
|
+
// keys, sorted by key: the model's name and the window. Keys reach the row
|
|
598
|
+
// unsanitised, so a name is letters, digits, underscores, dots and hyphens
|
|
599
|
+
// only, and a key with anything else stays out. Underscores read as spaces:
|
|
600
|
+
// seven_day_opus is Opus, seven_day_oauth_apps is Oauth Apps.
|
|
601
|
+
function modelWindows(data) {
|
|
602
|
+
return Object.entries(data.rate_limits ?? {})
|
|
603
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
604
|
+
.flatMap(([key, limit]) => {
|
|
605
|
+
const model = /^seven_day_([a-z0-9][\w.-]*)$/i.exec(key)?.[1];
|
|
606
|
+
const used = limit?.used_percentage;
|
|
607
|
+
if (!model || used == null)
|
|
608
|
+
return [];
|
|
609
|
+
const name = model.split('_').filter(Boolean).map(capitalised).join(' ');
|
|
610
|
+
return [{ name, limit: { ...limit, used_percentage: used } }];
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
// The models part: each per-model weekly window as 7d shows the week, with
|
|
614
|
+
// the model's name after the 7d label. The name stays with --no-labels,
|
|
615
|
+
// since without it two windows read alike.
|
|
616
|
+
function modelsPart(data, config, theme, nowMs) {
|
|
617
|
+
return modelWindows(data)
|
|
618
|
+
.map(({ name, limit }) => usageSegment(`${labelOf(config, '7d')}${name} `, limit, WINDOWS['7d'], config, theme, nowMs))
|
|
619
|
+
.join(separatorOf(config, theme));
|
|
620
|
+
}
|
|
621
|
+
// The context in the token line's shape: ctx 43% ▓▓░░░ 86.0k. Both figures
|
|
622
|
+
// count input only (fresh input plus cache writes and reads), as Claude
|
|
623
|
+
// Code's used_percentage does.
|
|
624
|
+
function contextPart(data, config, theme) {
|
|
625
|
+
const ctx = data.context_window;
|
|
626
|
+
if (!ctx)
|
|
627
|
+
return '';
|
|
628
|
+
const u = ctx.current_usage;
|
|
629
|
+
const size = ctx.context_window_size;
|
|
630
|
+
let tokens = u
|
|
631
|
+
? (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0)
|
|
632
|
+
: (ctx.total_input_tokens ?? null);
|
|
633
|
+
const pct = ctx.used_percentage ?? (tokens != null && size ? (tokens * 100) / size : null);
|
|
634
|
+
if (pct == null)
|
|
635
|
+
return '';
|
|
636
|
+
if (tokens == null && size)
|
|
637
|
+
tokens = Math.round((pct * size) / 100);
|
|
638
|
+
const color = theme.context[pct <= 50 ? 0 : pct <= 75 ? 1 : 2];
|
|
639
|
+
const label = labelOf(config, 'ctx');
|
|
640
|
+
const bar = config.bars ? ` ${cellsFor(pct, config).join('')}` : '';
|
|
641
|
+
const count = tokens != null ? ` ${fmt(tokens)}` : '';
|
|
642
|
+
return `${color}${label}${Math.round(pct)}%${bar}${count}${RESET}`;
|
|
643
|
+
}
|
|
644
|
+
// The current local time, on the same clock as the reset times.
|
|
645
|
+
function timePart(config, theme, nowMs) {
|
|
646
|
+
const time = new Date(nowMs).toLocaleTimeString('en-GB', clockOf(config));
|
|
647
|
+
return `${theme.muted}${time}${RESET}`;
|
|
648
|
+
}
|
|
649
|
+
// A session's running time: 45s, 12m, 1h12m, 2d3h. A zero lower unit is
|
|
650
|
+
// left off, so an hour on the dot reads 1h.
|
|
651
|
+
function formatDuration(ms) {
|
|
652
|
+
const s = Math.floor(ms / 1000);
|
|
653
|
+
if (s < 60)
|
|
654
|
+
return `${s}s`;
|
|
655
|
+
const m = Math.floor(s / 60);
|
|
656
|
+
if (m < 60)
|
|
657
|
+
return `${m}m`;
|
|
658
|
+
const h = Math.floor(m / 60);
|
|
659
|
+
if (h < 24)
|
|
660
|
+
return m % 60 ? `${h}h${m % 60}m` : `${h}h`;
|
|
661
|
+
const d = Math.floor(h / 24);
|
|
662
|
+
return h % 24 ? `${d}d${h % 24}h` : `${d}d`;
|
|
663
|
+
}
|
|
664
|
+
function durationPart(data, theme) {
|
|
665
|
+
const ms = data.cost?.total_duration_ms;
|
|
666
|
+
return ms != null ? `${theme.muted}${formatDuration(ms)}${RESET}` : '';
|
|
667
|
+
}
|
|
668
|
+
// The session's estimated cost. Behind a spend limit it takes the usage
|
|
669
|
+
// colour of the limit's percentage; otherwise it is plain metadata.
|
|
670
|
+
function costPart(data, theme) {
|
|
671
|
+
const usd = data.cost?.total_cost_usd;
|
|
672
|
+
if (usd == null)
|
|
673
|
+
return '';
|
|
674
|
+
const spent = data.rate_limits?.spend_limit?.used_percentage;
|
|
675
|
+
const color = spent != null ? levelColor(theme, Math.round(spent)) : theme.muted;
|
|
676
|
+
return `${color}$${usd.toFixed(2)}${RESET}`;
|
|
677
|
+
}
|
|
678
|
+
function linesPart(data, theme) {
|
|
679
|
+
const added = data.cost?.total_lines_added;
|
|
680
|
+
const removed = data.cost?.total_lines_removed;
|
|
681
|
+
if (added == null && removed == null)
|
|
682
|
+
return '';
|
|
683
|
+
return `${theme.good}+${added ?? 0}${RESET} ${theme.bad}−${removed ?? 0}${RESET}`;
|
|
684
|
+
}
|
|
685
|
+
// Text cut to at most chars characters, ending in … when it was cut. It counts
|
|
686
|
+
// code points, so a cut never splits an emoji in two.
|
|
687
|
+
const cut = (text, chars) => {
|
|
688
|
+
const points = [...text];
|
|
689
|
+
return points.length <= chars ? text : `${points.slice(0, chars - 1).join('')}…`;
|
|
690
|
+
};
|
|
691
|
+
// The session's custom name or AI-generated title, cut to 30 characters.
|
|
692
|
+
function namePart(data, theme) {
|
|
693
|
+
const name = data.session_name;
|
|
694
|
+
if (!name)
|
|
695
|
+
return '';
|
|
696
|
+
const shown = cut(name, 30);
|
|
697
|
+
return `${theme.muted}${shown}${RESET}`;
|
|
698
|
+
}
|
|
699
|
+
// Model state, in the model's colour, yellow by default. effort is labelled
|
|
700
|
+
// because "high" on its own could mean anything; thinking and fast are their
|
|
701
|
+
// own label and show only when on; style shows only when it is not the
|
|
702
|
+
// default.
|
|
703
|
+
function effortPart(data, config, theme) {
|
|
704
|
+
const level = data.effort?.level;
|
|
705
|
+
return level ? `${theme.accent}${labelOf(config, 'effort')}${level}${RESET}` : '';
|
|
706
|
+
}
|
|
707
|
+
const thinkingPart = (data, theme) => (data.thinking?.enabled ? `${theme.accent}think${RESET}` : '');
|
|
708
|
+
const fastPart = (data, theme) => (data.fast_mode ? `${theme.accent}fast${RESET}` : '');
|
|
709
|
+
function stylePart(data, config, theme) {
|
|
710
|
+
const name = data.output_style?.name;
|
|
711
|
+
if (!name || name === 'default')
|
|
712
|
+
return '';
|
|
713
|
+
return `${theme.muted}${labelOf(config, 'style')}${name}${RESET}`;
|
|
714
|
+
}
|
|
715
|
+
// The repository as owner/name from the origin remote. Without one (outside
|
|
716
|
+
// git, or no origin) it falls back to the folder name, so a row that leads
|
|
717
|
+
// with repo never loses its location.
|
|
718
|
+
function repoPart(data, theme, folder) {
|
|
719
|
+
const repo = data.workspace?.repo;
|
|
720
|
+
const shown = repo?.owner && repo?.name ? `${repo.owner}/${repo.name}` : folder;
|
|
721
|
+
return `${theme.muted}${shown}${RESET}`;
|
|
722
|
+
}
|
|
723
|
+
// The folder Claude Code runs in as a file URL that names its machine, as
|
|
724
|
+
// the OSC 8 spec asks, so a terminal can tell a folder on a remote machine
|
|
725
|
+
// from a local one. dir and repo both link to it.
|
|
726
|
+
function folderUrl({ cwd, hostname }) {
|
|
727
|
+
try {
|
|
728
|
+
return `file://${hostname}${(0, node_url_1.pathToFileURL)(cwd).pathname}`;
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
return undefined;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// The linked git worktree the session is in, if any. workspace.git_worktree
|
|
735
|
+
// covers every linked worktree; worktree.name only Claude Code's own
|
|
736
|
+
// worktree sessions.
|
|
737
|
+
const worktreeName = (data) => data.workspace?.git_worktree || data.worktree?.name || '';
|
|
738
|
+
// The path to a branch's page from a repository's page, on each host whose
|
|
739
|
+
// addresses are known.
|
|
740
|
+
const BRANCH_PAGES = { 'github.com': '/tree/', 'gitlab.com': '/-/tree/' };
|
|
741
|
+
// The branch's page on GitHub or GitLab, by the name its upstream has on
|
|
742
|
+
// origin, the remote the repo comes from. Unknown without an upstream on
|
|
743
|
+
// origin, since the branch may not be on the remote, and on any other host.
|
|
744
|
+
function branchUrl(repo, upstream) {
|
|
745
|
+
const { host, owner, name } = repo ?? {};
|
|
746
|
+
if (typeof host !== 'string' || typeof owner !== 'string' || typeof name !== 'string' || !owner || !name)
|
|
747
|
+
return undefined;
|
|
748
|
+
const forge = host.toLowerCase();
|
|
749
|
+
const page = ownValue(BRANCH_PAGES, forge);
|
|
750
|
+
const branch = /^origin\/(.+)$/.exec(upstream)?.[1];
|
|
751
|
+
if (!page || !branch)
|
|
752
|
+
return undefined;
|
|
753
|
+
// Each name between slashes percent-encoded, the slashes kept: a GitLab
|
|
754
|
+
// group and a branch name may both hold them.
|
|
755
|
+
const encoded = (text) => text.split('/').map(encodeURIComponent).join('/');
|
|
756
|
+
try {
|
|
757
|
+
return `https://${forge}/${encoded(owner)}/${encoded(name)}${page}${encoded(branch)}`;
|
|
758
|
+
}
|
|
759
|
+
catch {
|
|
760
|
+
return undefined; // text that is not valid UTF-16
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
// The branch, * when the work tree has changes, ↑n and ↓n for the commits
|
|
764
|
+
// it is ahead of and behind its upstream, and the worktree name inside a
|
|
765
|
+
// linked worktree.
|
|
766
|
+
function branchPart(data, config, theme, git) {
|
|
767
|
+
if (!git.branch)
|
|
768
|
+
return '';
|
|
769
|
+
const dirty = git.changed.length ? `${theme.accent}*${theme.good}` : '';
|
|
770
|
+
const ahead = git.ahead ? ` ↑${git.ahead}` : '';
|
|
771
|
+
const behind = git.behind ? ` ↓${git.behind}` : '';
|
|
772
|
+
const wt = worktreeName(data);
|
|
773
|
+
const inWorktree = wt ? ` (${labelOf(config, 'wt')}${wt})` : '';
|
|
774
|
+
return `${theme.good}⎇ ${git.branch}${dirty}${ahead}${behind}${inWorktree}${RESET}`;
|
|
775
|
+
}
|
|
776
|
+
// The work tree's changes, as counts: !modified +staged ✘deleted ?untracked.
|
|
777
|
+
// Only counts above 0 show, and nothing shows when the tree is clean.
|
|
778
|
+
function gitCountsPart(git, theme) {
|
|
779
|
+
const counts = [
|
|
780
|
+
[git.modified, '!', theme.accent],
|
|
781
|
+
[git.staged, '+', theme.good],
|
|
782
|
+
[git.deleted, '✘', theme.bad],
|
|
783
|
+
[git.untracked, '?', theme.muted],
|
|
784
|
+
];
|
|
785
|
+
return counts
|
|
786
|
+
.filter(([n]) => n > 0)
|
|
787
|
+
.map(([n, mark, color]) => `${color}${mark}${n}${RESET}`)
|
|
788
|
+
.join(' ');
|
|
789
|
+
}
|
|
790
|
+
// The changed files the files part names, at most.
|
|
791
|
+
const RECENT_FILES = 3;
|
|
792
|
+
// The changed files whose times the files part reads, at most, so a huge
|
|
793
|
+
// change set costs a bounded number of reads.
|
|
794
|
+
const TIMED_FILES = 1000;
|
|
795
|
+
// The most recently changed files, newest first, of the first TIMED_FILES
|
|
796
|
+
// that git lists. A deleted file has no time, so it follows the files that
|
|
797
|
+
// have one; files with the same time keep git's order.
|
|
798
|
+
function filesPart(git, theme, mtimeOf) {
|
|
799
|
+
return git.changed
|
|
800
|
+
.slice(0, TIMED_FILES)
|
|
801
|
+
.map((file) => ({ ...file, time: mtimeOf(file.path) ?? -Infinity }))
|
|
802
|
+
.sort((a, b) => b.time - a.time || 0)
|
|
803
|
+
.slice(0, RECENT_FILES)
|
|
804
|
+
.map((file) => `${theme.muted}${file.name}${RESET}`)
|
|
805
|
+
.join(' ');
|
|
806
|
+
}
|
|
807
|
+
function worktreePart(data, config, theme) {
|
|
808
|
+
const wt = worktreeName(data);
|
|
809
|
+
return wt ? `${theme.muted}${labelOf(config, 'wt')}${wt}${RESET}` : '';
|
|
810
|
+
}
|
|
811
|
+
const PR_ROLES = { approved: 'good', pending: 'accent', changes_requested: 'bad', draft: 'muted' };
|
|
812
|
+
function prPart(data, theme) {
|
|
813
|
+
const pr = data.pr;
|
|
814
|
+
if (pr?.number == null)
|
|
815
|
+
return '';
|
|
816
|
+
const number = `${pr.kind === 'mr' ? '!' : '#'}${pr.number}`;
|
|
817
|
+
const state = pr.review_state ? ` ${pr.review_state}` : '';
|
|
818
|
+
return `${theme[ownValue(PR_ROLES, pr.review_state ?? '') ?? 'muted']}${number}${state}${RESET}`;
|
|
819
|
+
}
|
|
820
|
+
// A web page's address as a link can carry it: http or https only, with
|
|
821
|
+
// every character outside ASCII percent-encoded. The payload is JSON from
|
|
822
|
+
// outside, so the address may not be text at all.
|
|
823
|
+
function webUrl(text) {
|
|
824
|
+
if (typeof text !== 'string')
|
|
825
|
+
return undefined;
|
|
826
|
+
try {
|
|
827
|
+
const url = new URL(text);
|
|
828
|
+
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : undefined;
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
return undefined;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
function agentPart(data, config, theme) {
|
|
835
|
+
const name = data.agent?.name;
|
|
836
|
+
return name ? `${theme.muted}${labelOf(config, 'agent')}${name}${RESET}` : '';
|
|
837
|
+
}
|
|
838
|
+
// The prompt cache's hit ratio and whether it is still warm. A high hit
|
|
839
|
+
// ratio is good, so the colour follows the miss rate on the usage scale.
|
|
840
|
+
function cachePart(data, config, theme) {
|
|
841
|
+
const cache = data.prompt_cache;
|
|
842
|
+
if (!cache)
|
|
843
|
+
return '';
|
|
844
|
+
const label = labelOf(config, 'cache');
|
|
845
|
+
const state = cache.warm ? 'warm' : 'cold';
|
|
846
|
+
if (cache.hit_ratio == null)
|
|
847
|
+
return `${theme.muted}${label}${state}${RESET}`;
|
|
848
|
+
const hit = Math.round(cache.hit_ratio * 100);
|
|
849
|
+
return `${levelColor(theme, 100 - hit)}${label}${hit}% ${state}${RESET}`;
|
|
850
|
+
}
|
|
851
|
+
// The spend limit behind a Claude apps gateway: dollars when Claude Code has
|
|
852
|
+
// them, which arrive a little after the percentage, and the percentage until
|
|
853
|
+
// then.
|
|
854
|
+
function spendPart(data, config, theme) {
|
|
855
|
+
const limit = data.rate_limits?.spend_limit;
|
|
856
|
+
if (limit?.used_percentage == null)
|
|
857
|
+
return '';
|
|
858
|
+
const color = levelColor(theme, Math.round(limit.used_percentage));
|
|
859
|
+
if (limit.used_usd != null && limit.limit_usd != null) {
|
|
860
|
+
return `${color}$${Math.round(limit.used_usd)}/$${Math.round(limit.limit_usd)}${RESET}`;
|
|
861
|
+
}
|
|
862
|
+
return `${color}${labelOf(config, 'spend')}${Math.round(limit.used_percentage)}%${RESET}`;
|
|
863
|
+
}
|
|
864
|
+
// The API provider when requests do not go to the first-party API, from the
|
|
865
|
+
// variables that select it: Bedrock (with its Mantle endpoint), Vertex,
|
|
866
|
+
// Foundry, Claude Platform on AWS, or a gateway at a base URL of its own.
|
|
867
|
+
// Claude Code reads these switches as on for 1, true, yes or on.
|
|
868
|
+
const isOn = (value) => /^(?:1|true|yes|on)$/i.test(value?.trim() ?? '');
|
|
869
|
+
function providerOf(env) {
|
|
870
|
+
if (isOn(env.CLAUDE_CODE_USE_BEDROCK) || isOn(env.CLAUDE_CODE_USE_MANTLE))
|
|
871
|
+
return 'Bedrock';
|
|
872
|
+
if (isOn(env.CLAUDE_CODE_USE_VERTEX))
|
|
873
|
+
return 'Vertex';
|
|
874
|
+
if (isOn(env.CLAUDE_CODE_USE_FOUNDRY))
|
|
875
|
+
return 'Foundry';
|
|
876
|
+
if (isOn(env.CLAUDE_CODE_USE_ANTHROPIC_AWS))
|
|
877
|
+
return 'AWS';
|
|
878
|
+
const base = env.ANTHROPIC_BASE_URL?.trim();
|
|
879
|
+
if (!base)
|
|
880
|
+
return '';
|
|
881
|
+
let host = '';
|
|
882
|
+
try {
|
|
883
|
+
host = new URL(base).hostname;
|
|
884
|
+
}
|
|
885
|
+
catch {
|
|
886
|
+
/* not a URL: still not the first-party API */
|
|
887
|
+
}
|
|
888
|
+
return host === 'api.anthropic.com' ? '' : 'Enterprise';
|
|
889
|
+
}
|
|
890
|
+
// The model, with the provider after it: Opus 5.5 (Bedrock).
|
|
891
|
+
function modelPart(data, theme, env) {
|
|
892
|
+
const name = data.model?.display_name;
|
|
893
|
+
if (!name)
|
|
894
|
+
return '';
|
|
895
|
+
const provider = providerOf(env);
|
|
896
|
+
return `${theme.accent}${name}${provider ? ` (${provider})` : ''}${RESET}`;
|
|
897
|
+
}
|
|
898
|
+
// A count and what it counts, one or many: 1 rule, 4 rules.
|
|
899
|
+
const counted = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
900
|
+
// The kinds env counts, in order, and the words for one and for many.
|
|
901
|
+
const ENV_KINDS = [
|
|
902
|
+
['claudeMd', 'md', 'md'],
|
|
903
|
+
['rules', 'rule', 'rules'],
|
|
904
|
+
['mcp', 'mcp', 'mcp'],
|
|
905
|
+
['hooks', 'hook', 'hooks'],
|
|
906
|
+
];
|
|
907
|
+
// What the session loads, kind by kind, leaving out a kind with none.
|
|
908
|
+
function envPart(setup, config, theme) {
|
|
909
|
+
const counts = ENV_KINDS.filter(([kind]) => setup[kind]).map(([kind, one, many]) => counted(setup[kind], one, many));
|
|
910
|
+
return counts.length ? `${theme.muted}${labelOf(config, 'env')}${counts.join(' ')}${RESET}` : '';
|
|
911
|
+
}
|
|
912
|
+
// The plan, with the signed-in user after it: Claude Max 20x (me@example.com).
|
|
913
|
+
function planPart({ plan, user }, theme) {
|
|
914
|
+
const shown = plan && user ? `${plan} (${user})` : plan || user;
|
|
915
|
+
return shown ? `${theme.muted}${shown}${RESET}` : '';
|
|
916
|
+
}
|
|
917
|
+
// The limit part: every window at 100% or more, by its full name (5h, 7d,
|
|
918
|
+
// 7d Opus, spend) with or without --no-labels, since a notice that names no
|
|
919
|
+
// window says nothing. A reset shows as its window's part shows it. The
|
|
920
|
+
// spend part shows none, so a spend reset, when Claude Code sends one, shows
|
|
921
|
+
// as days or a time of day, as the weekly resets do.
|
|
922
|
+
function limitPart(data, config, theme, nowMs) {
|
|
923
|
+
const limits = data.rate_limits ?? {};
|
|
924
|
+
const windows = [
|
|
925
|
+
['5h', limits.five_hour, WINDOWS['5h'].resetText],
|
|
926
|
+
['7d', limits.seven_day, WINDOWS['7d'].resetText],
|
|
927
|
+
...modelWindows(data).map(({ name, limit }) => [`7d ${name}`, limit, WINDOWS['7d'].resetText]),
|
|
928
|
+
['spend', limits.spend_limit, formatDaysOrTime],
|
|
929
|
+
];
|
|
930
|
+
const reached = windows
|
|
931
|
+
.filter(([, limit]) => (limit?.used_percentage ?? 0) >= 100)
|
|
932
|
+
.map(([name, limit, resetText]) => config.reset && limit?.resets_at ? `${name} → ${resetText(limit.resets_at, config, nowMs)}` : name);
|
|
933
|
+
return reached.length ? `${theme.levels[9]}limit reached: ${reached.join(', ')}${RESET}` : '';
|
|
934
|
+
}
|
|
935
|
+
// Bytes in gibibytes, as the OS monitors count them: 0.4G, 10.5G, 120G.
|
|
936
|
+
const gib = (bytes) => {
|
|
937
|
+
const n = bytes / 1024 ** 3;
|
|
938
|
+
return `${n >= 100 ? Math.round(n) : n.toFixed(1)}G`;
|
|
939
|
+
};
|
|
940
|
+
// The memory in use in the ctx part's shape: ram 66% ▓▓▓░░ 10.5G, on the
|
|
941
|
+
// usage colours.
|
|
942
|
+
function ramPart(memory, config, theme) {
|
|
943
|
+
if (!memory)
|
|
944
|
+
return '';
|
|
945
|
+
const pct = (memory.used * 100) / memory.total;
|
|
946
|
+
const bar = config.bars ? ` ${cellsFor(pct, config).join('')}` : '';
|
|
947
|
+
return `${levelColor(theme, Math.round(pct))}${labelOf(config, 'ram')}${Math.round(pct)}%${bar} ${gib(memory.used)}${RESET}`;
|
|
948
|
+
}
|
|
949
|
+
// The text and command parts: text from --text or from the command's
|
|
950
|
+
// output. Neither comes through render's sanitised payload, so the part
|
|
951
|
+
// sanitises it here.
|
|
952
|
+
function outsideText(raw, theme) {
|
|
953
|
+
const text = sanitise(raw);
|
|
954
|
+
return text ? `${theme.muted}${text}${RESET}` : '';
|
|
955
|
+
}
|
|
956
|
+
const versionPart = (data, theme) => (data.version ? `${theme.muted}v${data.version}${RESET}` : '');
|
|
957
|
+
// A ledger from whatever the file held: its two tables where they are
|
|
958
|
+
// objects, else empty ones. The tables have no prototype, so a session id
|
|
959
|
+
// such as __proto__ is a key like any other.
|
|
960
|
+
function ledgerFrom(value) {
|
|
961
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
962
|
+
const table = (v) => Object.assign(Object.create(null), isObject(v) ? v : {});
|
|
963
|
+
const { days, sessions } = (isObject(value) ? value : {});
|
|
964
|
+
return { days: table(days), sessions: table(sessions) };
|
|
965
|
+
}
|
|
966
|
+
// A number from a file or a reader outside claude-gauge's control, such as a
|
|
967
|
+
// ledger value or a transcript counter, or undefined when it is not a finite
|
|
968
|
+
// one.
|
|
969
|
+
const finite = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
|
|
970
|
+
// A local day as the ledger keys it.
|
|
971
|
+
const dayKey = (ms) => {
|
|
972
|
+
const d = new Date(ms);
|
|
973
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
974
|
+
};
|
|
975
|
+
// What the session has spent since the ledger last recorded it: all of its
|
|
976
|
+
// cost when the ledger has not seen it, or when its cost fell, which means it
|
|
977
|
+
// started again from zero.
|
|
978
|
+
function unrecorded(ledger, data) {
|
|
979
|
+
const usd = finite(data.cost?.total_cost_usd);
|
|
980
|
+
if (usd === undefined)
|
|
981
|
+
return 0;
|
|
982
|
+
const recorded = data.session_id ? finite(ledger.sessions[data.session_id]?.usd) : undefined;
|
|
983
|
+
return recorded === undefined || usd < recorded ? usd : usd - recorded;
|
|
984
|
+
}
|
|
985
|
+
// The local days a period covers, newest first: today alone, or each day
|
|
986
|
+
// back to Monday.
|
|
987
|
+
function periodDays(period, nowMs) {
|
|
988
|
+
const now = new Date(nowMs);
|
|
989
|
+
const count = period === 'today' ? 1 : ((now.getDay() + 6) % 7) + 1;
|
|
990
|
+
return Array.from({ length: count }, (_, i) => dayKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - i).getTime()));
|
|
991
|
+
}
|
|
992
|
+
// Spend across sessions for today or this week: what the ledger holds for
|
|
993
|
+
// those days, and what this session has spent since it was last recorded.
|
|
994
|
+
// Nothing shows when neither has anything to add.
|
|
995
|
+
function spentPart(period, { data, config, theme, nowMs, ledger }) {
|
|
996
|
+
const byDay = periodDays(period, nowMs).map((day) => finite(ledger.days[day]));
|
|
997
|
+
const known = byDay.some((usd) => usd !== undefined) || finite(data.cost?.total_cost_usd) !== undefined;
|
|
998
|
+
if (!known)
|
|
999
|
+
return '';
|
|
1000
|
+
const usd = byDay.reduce((sum, day) => sum + (day ?? 0), 0) + unrecorded(ledger, data);
|
|
1001
|
+
return `${theme.muted}${labelOf(config, period)}$${usd.toFixed(2)}${RESET}`;
|
|
1002
|
+
}
|
|
1003
|
+
// The tools part shows the completed tools used most, up to this many, and
|
|
1004
|
+
// cuts a target to this many characters.
|
|
1005
|
+
const TOOLS_SHOWN = 5;
|
|
1006
|
+
const TARGET_CHARS = 30;
|
|
1007
|
+
// Tools whose target is a file: a cut keeps the end, where its name is.
|
|
1008
|
+
const FILE_TOOLS = ['Read', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit'];
|
|
1009
|
+
// A tool's target as the part prints it: a path inside the folder Claude
|
|
1010
|
+
// Code runs in made relative to it, then cut to TARGET_CHARS.
|
|
1011
|
+
function shortTarget(name, target, cwd) {
|
|
1012
|
+
const relative = path.isAbsolute(target) ? path.relative(cwd, target) : '';
|
|
1013
|
+
const shown = relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : target;
|
|
1014
|
+
if (shown.length <= TARGET_CHARS)
|
|
1015
|
+
return shown;
|
|
1016
|
+
return FILE_TOOLS.includes(name) ? `…${shown.slice(-(TARGET_CHARS - 1))}` : cut(shown, TARGET_CHARS);
|
|
1017
|
+
}
|
|
1018
|
+
// The tool running now, with its target, then the completed tools used most,
|
|
1019
|
+
// with counts: ◐ Edit src/a.ts ✓ Read ×12 ✓ Bash ×3.
|
|
1020
|
+
function toolsPart(activity, theme, cwd) {
|
|
1021
|
+
const { running, completed } = activity.tools;
|
|
1022
|
+
const items = [];
|
|
1023
|
+
const now = running.at(-1);
|
|
1024
|
+
if (now)
|
|
1025
|
+
items.push(`${theme.accent}◐ ${now.name}${now.target ? ` ${shortTarget(now.name, now.target, cwd)}` : ''}${RESET}`);
|
|
1026
|
+
const done = Object.entries(completed)
|
|
1027
|
+
.sort((a, b) => b[1] - a[1])
|
|
1028
|
+
.slice(0, TOOLS_SHOWN);
|
|
1029
|
+
for (const [name, count] of done)
|
|
1030
|
+
items.push(`${theme.good}✓${RESET} ${theme.muted}${name} ×${count}${RESET}`);
|
|
1031
|
+
return items.join(' ');
|
|
1032
|
+
}
|
|
1033
|
+
// The agents part shows this many subagents, those running first, and keeps
|
|
1034
|
+
// a finished one for AGENT_LINGER_MS. A description is cut to DESCRIPTION_CHARS.
|
|
1035
|
+
const AGENTS_SHOWN = 3;
|
|
1036
|
+
const AGENT_LINGER_MS = 60_000;
|
|
1037
|
+
const DESCRIPTION_CHARS = 30;
|
|
1038
|
+
// One subagent: ◐ Explore (Haiku 4.5) Find the config loader 1m while it
|
|
1039
|
+
// runs, ✓ when it finished, ✗ when it failed or was stopped.
|
|
1040
|
+
function agentItem(agent, theme, nowMs) {
|
|
1041
|
+
const model = agent.model ? ` (${agent.model})` : '';
|
|
1042
|
+
const description = agent.description ? ` ${cut(agent.description, DESCRIPTION_CHARS)}` : '';
|
|
1043
|
+
const elapsed = agent.startedAt !== undefined ? ` ${formatDuration(Math.max(0, (agent.endedAt ?? nowMs) - agent.startedAt))}` : '';
|
|
1044
|
+
const text = `${agent.type}${model}${description}${elapsed}`;
|
|
1045
|
+
if (agent.endedAt === undefined)
|
|
1046
|
+
return `${theme.accent}◐ ${text}${RESET}`;
|
|
1047
|
+
return `${agent.failed ? `${theme.bad}✗` : `${theme.good}✓`}${RESET} ${theme.muted}${text}${RESET}`;
|
|
1048
|
+
}
|
|
1049
|
+
// The subagents running now, oldest first, then those finished in the last
|
|
1050
|
+
// AGENT_LINGER_MS, newest first, up to AGENTS_SHOWN in all.
|
|
1051
|
+
function agentsPart(activity, theme, nowMs) {
|
|
1052
|
+
const { agents } = activity;
|
|
1053
|
+
const running = agents.filter((a) => a.endedAt === undefined);
|
|
1054
|
+
const finished = agents
|
|
1055
|
+
.filter((a) => a.endedAt !== undefined && nowMs - a.endedAt < AGENT_LINGER_MS)
|
|
1056
|
+
.sort((a, b) => b.endedAt - a.endedAt);
|
|
1057
|
+
return [...running, ...finished]
|
|
1058
|
+
.slice(0, AGENTS_SHOWN)
|
|
1059
|
+
.map((a) => agentItem(a, theme, nowMs))
|
|
1060
|
+
.join(' ');
|
|
1061
|
+
}
|
|
1062
|
+
// The todo in progress, by the form Claude Code shows while it runs, then
|
|
1063
|
+
// how many todos are done: ◐ Writing the tests 1/3. With none in progress,
|
|
1064
|
+
// todos 1/3, and ✓ todos 3/3 once all are done.
|
|
1065
|
+
function todosPart(activity, config, theme) {
|
|
1066
|
+
const { todos } = activity;
|
|
1067
|
+
if (!todos.length)
|
|
1068
|
+
return '';
|
|
1069
|
+
const done = todos.filter((t) => t.status === 'completed').length;
|
|
1070
|
+
const count = `${done}/${todos.length}`;
|
|
1071
|
+
const now = todos.find((t) => t.status === 'in_progress');
|
|
1072
|
+
if (now)
|
|
1073
|
+
return `${theme.accent}◐ ${cut(now.activeForm || now.content, DESCRIPTION_CHARS)}${RESET} ${theme.muted}${count}${RESET}`;
|
|
1074
|
+
const text = `${theme.muted}${labelOf(config, 'todos')}${count}${RESET}`;
|
|
1075
|
+
return done === todos.length ? `${theme.good}✓${RESET} ${text}` : text;
|
|
1076
|
+
}
|
|
1077
|
+
// The skills part shows this many skills and this many MCP servers, and
|
|
1078
|
+
// every server whose latest call failed. A name is cut to DESCRIPTION_CHARS.
|
|
1079
|
+
const SKILLS_PART_SHOWN = 3;
|
|
1080
|
+
// The skills used, newest first, then the MCP servers called, those whose
|
|
1081
|
+
// latest call failed first: skills tdd code-review mcp ✗ linear github.
|
|
1082
|
+
function skillsPart(activity, config, theme) {
|
|
1083
|
+
// The skills, then the servers, each with its label: none when it has nothing.
|
|
1084
|
+
const sections = [];
|
|
1085
|
+
const section = (label, items) => {
|
|
1086
|
+
if (items.length)
|
|
1087
|
+
sections.push(`${theme.muted}${labelOf(config, label)}${RESET}${items.join(' ')}`);
|
|
1088
|
+
};
|
|
1089
|
+
const skills = [...activity.skills].reverse().slice(0, SKILLS_PART_SHOWN);
|
|
1090
|
+
section('skills', skills.map((s) => `${theme.muted}${cut(s, DESCRIPTION_CHARS)}${RESET}`));
|
|
1091
|
+
const newest = [...activity.mcp].reverse();
|
|
1092
|
+
const failing = newest.filter((s) => s.failed);
|
|
1093
|
+
const working = newest.filter((s) => !s.failed).slice(0, Math.max(0, SKILLS_PART_SHOWN - failing.length));
|
|
1094
|
+
section('mcp', [
|
|
1095
|
+
...failing.map((s) => `${theme.bad}✗ ${cut(s.name, DESCRIPTION_CHARS)}${RESET}`),
|
|
1096
|
+
...working.map((s) => `${theme.muted}${cut(s.name, DESCRIPTION_CHARS)}${RESET}`),
|
|
1097
|
+
]);
|
|
1098
|
+
return sections.join(' ');
|
|
1099
|
+
}
|
|
1100
|
+
// How many times the conversation was compacted: compactions 2. Nothing
|
|
1101
|
+
// before the first.
|
|
1102
|
+
function compactionsPart(activity, config, theme) {
|
|
1103
|
+
const { compactions } = activity;
|
|
1104
|
+
return compactions > 0 ? `${theme.muted}${labelOf(config, 'compactions')}${compactions}${RESET}` : '';
|
|
1105
|
+
}
|
|
1106
|
+
// The time since Claude last replied: reply 3m ago. A reply stamped ahead of
|
|
1107
|
+
// this machine's clock counts as just now.
|
|
1108
|
+
function replyPart(activity, config, theme, nowMs) {
|
|
1109
|
+
const { lastReplyAt } = activity;
|
|
1110
|
+
if (lastReplyAt === undefined)
|
|
1111
|
+
return '';
|
|
1112
|
+
return `${theme.muted}${labelOf(config, 'reply')}${formatDuration(Math.max(0, nowMs - lastReplyAt))} ago${RESET}`;
|
|
1113
|
+
}
|
|
1114
|
+
// The output speed of the last response: 84 tok/s, or 6.3 tok/s below ten.
|
|
1115
|
+
function speedPart(activity, theme) {
|
|
1116
|
+
const { speed } = activity;
|
|
1117
|
+
if (speed === undefined)
|
|
1118
|
+
return '';
|
|
1119
|
+
return `${theme.muted}${speed < 10 ? speed.toFixed(1) : Math.round(speed)} tok/s${RESET}`;
|
|
1120
|
+
}
|
|
1121
|
+
const MANAGED_DIRS = {
|
|
1122
|
+
darwin: '/Library/Application Support/ClaudeCode',
|
|
1123
|
+
win32: 'C:\\Program Files\\ClaudeCode',
|
|
1124
|
+
};
|
|
1125
|
+
const managedDirOf = (platform) => MANAGED_DIRS[platform] ?? '/etc/claude-code';
|
|
1126
|
+
// Claude Code's config folder, and the .claude.json beside or inside it.
|
|
1127
|
+
const configDirOf = (env, home) => env.CLAUDE_CONFIG_DIR || path.join(home, '.claude');
|
|
1128
|
+
const claudeJsonOf = (env, home) => env.CLAUDE_CONFIG_DIR ? path.join(env.CLAUDE_CONFIG_DIR, '.claude.json') : path.join(home, '.claude.json');
|
|
1129
|
+
const isObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
1130
|
+
function readJson(file) {
|
|
1131
|
+
try {
|
|
1132
|
+
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1133
|
+
return isObject(value) ? value : {};
|
|
1134
|
+
}
|
|
1135
|
+
catch {
|
|
1136
|
+
return {};
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
const objectAt = (value, key) => (isObject(value) && isObject(value[key]) ? value[key] : {});
|
|
1140
|
+
const stringAt = (value, key) => {
|
|
1141
|
+
const field = isObject(value) ? value[key] : undefined;
|
|
1142
|
+
return typeof field === 'string' && field ? field : undefined;
|
|
1143
|
+
};
|
|
1144
|
+
const listAt = (value, key) => {
|
|
1145
|
+
const field = isObject(value) ? value[key] : undefined;
|
|
1146
|
+
return Array.isArray(field) ? field : [];
|
|
1147
|
+
};
|
|
1148
|
+
const isFile = (file) => {
|
|
1149
|
+
try {
|
|
1150
|
+
return fs.statSync(file).isFile();
|
|
1151
|
+
}
|
|
1152
|
+
catch {
|
|
1153
|
+
return false;
|
|
1154
|
+
}
|
|
1155
|
+
};
|
|
1156
|
+
// The .md files under a folder, at any depth.
|
|
1157
|
+
function markdownUnder(dir) {
|
|
1158
|
+
let entries;
|
|
1159
|
+
try {
|
|
1160
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1161
|
+
}
|
|
1162
|
+
catch {
|
|
1163
|
+
return [];
|
|
1164
|
+
}
|
|
1165
|
+
return entries.flatMap((e) => {
|
|
1166
|
+
const file = path.join(dir, e.name);
|
|
1167
|
+
if (e.isDirectory())
|
|
1168
|
+
return markdownUnder(file);
|
|
1169
|
+
return e.name.endsWith('.md') && isFile(file) ? [file] : [];
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
// A folder and every folder above it, from the root down.
|
|
1173
|
+
function foldersDownTo(cwd) {
|
|
1174
|
+
const folders = [];
|
|
1175
|
+
for (let dir = path.resolve(cwd);; dir = path.dirname(dir)) {
|
|
1176
|
+
folders.unshift(dir);
|
|
1177
|
+
if (path.dirname(dir) === dir)
|
|
1178
|
+
return folders;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
// Hook handlers in a settings file: one per command in each matcher group.
|
|
1182
|
+
const hooksIn = (settings) => Object.values(objectAt(settings, 'hooks')).reduce((n, groups) => n + (Array.isArray(groups) ? groups.reduce((m, group) => m + listAt(group, 'hooks').length, 0) : 0), 0);
|
|
1183
|
+
// claude.ai plan names from a subscription type and a rate-limit tier: max
|
|
1184
|
+
// with the default_claude_max_20x tier is Claude Max 20x, pro is Claude Pro.
|
|
1185
|
+
function planName(type, tier) {
|
|
1186
|
+
if (!type)
|
|
1187
|
+
return undefined;
|
|
1188
|
+
const multiple = /_(\d+x)$/.exec(tier ?? '')?.[1];
|
|
1189
|
+
return ['Claude', capitalised(type), multiple].filter(Boolean).join(' ');
|
|
1190
|
+
}
|
|
1191
|
+
function readSetup(cwd, { env = process.env, home = os.homedir(), managedDir = managedDirOf(process.platform) } = {}) {
|
|
1192
|
+
const config = configDirOf(env, home);
|
|
1193
|
+
const project = path.resolve(cwd);
|
|
1194
|
+
const folders = foldersDownTo(project);
|
|
1195
|
+
// CLAUDE.md files load from the managed folder, the user's config, and the
|
|
1196
|
+
// project and every folder above it. A path counts once, so the user's file
|
|
1197
|
+
// is not counted again as the home folder's .claude/CLAUDE.md.
|
|
1198
|
+
const claudeMd = new Set([
|
|
1199
|
+
path.join(managedDir, 'CLAUDE.md'),
|
|
1200
|
+
path.join(config, 'CLAUDE.md'),
|
|
1201
|
+
...folders.flatMap((dir) => ['CLAUDE.md', path.join('.claude', 'CLAUDE.md'), 'CLAUDE.local.md'].map((f) => path.join(dir, f))),
|
|
1202
|
+
].filter(isFile));
|
|
1203
|
+
const rules = new Set([config, ...folders.map((dir) => path.join(dir, '.claude'))].flatMap((dir) => markdownUnder(path.join(dir, 'rules'))));
|
|
1204
|
+
// Settings: user, project, local and managed. Each may hold hooks, and
|
|
1205
|
+
// the names of project MCP servers turned off.
|
|
1206
|
+
const settings = [
|
|
1207
|
+
path.join(config, 'settings.json'),
|
|
1208
|
+
path.join(project, '.claude', 'settings.json'),
|
|
1209
|
+
path.join(project, '.claude', 'settings.local.json'),
|
|
1210
|
+
path.join(managedDir, 'managed-settings.json'),
|
|
1211
|
+
]
|
|
1212
|
+
.filter((file, i, all) => all.indexOf(file) === i)
|
|
1213
|
+
.map(readJson);
|
|
1214
|
+
// MCP servers: user and local scope in .claude.json, the project's
|
|
1215
|
+
// .mcp.json and the managed file, each name once, less the project servers
|
|
1216
|
+
// turned off.
|
|
1217
|
+
const claudeJson = readJson(claudeJsonOf(env, home));
|
|
1218
|
+
const local = objectAt(objectAt(claudeJson, 'projects'), project);
|
|
1219
|
+
const turnedOff = new Set([...settings, local].flatMap((s) => listAt(s, 'disabledMcpjsonServers')));
|
|
1220
|
+
const projectServers = Object.keys(objectAt(readJson(path.join(project, '.mcp.json')), 'mcpServers')).filter((name) => !turnedOff.has(name));
|
|
1221
|
+
const mcp = new Set([
|
|
1222
|
+
...Object.keys(objectAt(claudeJson, 'mcpServers')),
|
|
1223
|
+
...Object.keys(objectAt(local, 'mcpServers')),
|
|
1224
|
+
...projectServers,
|
|
1225
|
+
...Object.keys(objectAt(readJson(path.join(managedDir, 'managed-mcp.json')), 'mcpServers')),
|
|
1226
|
+
]);
|
|
1227
|
+
for (const name of listAt(local, 'disabledMcpServers'))
|
|
1228
|
+
if (typeof name === 'string')
|
|
1229
|
+
mcp.delete(name);
|
|
1230
|
+
// The plan from the login's subscription fields where they are in a file,
|
|
1231
|
+
// else from the account Claude Code keeps in .claude.json, as on macOS,
|
|
1232
|
+
// where the login is in the Keychain, which this does not read. Only the
|
|
1233
|
+
// plan fields are taken from the credentials file.
|
|
1234
|
+
const oauth = objectAt(readJson(path.join(config, '.credentials.json')), 'claudeAiOauth');
|
|
1235
|
+
const account = objectAt(claudeJson, 'oauthAccount');
|
|
1236
|
+
const plan = planName(stringAt(oauth, 'subscriptionType'), stringAt(oauth, 'rateLimitTier')) ??
|
|
1237
|
+
planName(/^claude_(\w+)$/.exec(stringAt(account, 'organizationType') ?? '')?.[1], stringAt(account, 'organizationRateLimitTier'));
|
|
1238
|
+
const user = stringAt(account, 'emailAddress');
|
|
1239
|
+
return {
|
|
1240
|
+
claudeMd: claudeMd.size,
|
|
1241
|
+
rules: rules.size,
|
|
1242
|
+
mcp: mcp.size,
|
|
1243
|
+
hooks: settings.reduce((n, s) => n + hooksIn(s), 0),
|
|
1244
|
+
...(plan ? { plan } : {}),
|
|
1245
|
+
...(user ? { user } : {}),
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
// A number after a name in text such as /proc/meminfo or vm_stat's output.
|
|
1249
|
+
const figureAfter = (text, name) => {
|
|
1250
|
+
const m = new RegExp(`^${name}:\\s*(\\d+)`, 'm').exec(text);
|
|
1251
|
+
return m ? Number(m[1]) : undefined;
|
|
1252
|
+
};
|
|
1253
|
+
// Memory in use, or undefined when the total is unknown. Linux counts what
|
|
1254
|
+
// is not MemAvailable as used, so the page cache the kernel gives back on
|
|
1255
|
+
// demand stays out. macOS counts app memory, wired and compressed pages from
|
|
1256
|
+
// vm_stat, as Activity Monitor does, because Node's free figure there leaves
|
|
1257
|
+
// out the inactive pages and reads close to full. Windows, and any OS or
|
|
1258
|
+
// reading that fails, take Node's figures, which on Windows are the
|
|
1259
|
+
// available memory already.
|
|
1260
|
+
function readMemory({ platform = process.platform, totalmem = os.totalmem, freemem = os.freemem, readFile = (file) => fs.readFileSync(file, 'utf8'), run = runQuietly, } = {}) {
|
|
1261
|
+
const attempt = (read) => {
|
|
1262
|
+
try {
|
|
1263
|
+
return read();
|
|
1264
|
+
}
|
|
1265
|
+
catch {
|
|
1266
|
+
return undefined;
|
|
1267
|
+
}
|
|
1268
|
+
};
|
|
1269
|
+
let reading;
|
|
1270
|
+
if (platform === 'linux') {
|
|
1271
|
+
const meminfo = attempt(() => readFile('/proc/meminfo')) ?? '';
|
|
1272
|
+
const total = figureAfter(meminfo, 'MemTotal');
|
|
1273
|
+
const available = figureAfter(meminfo, 'MemAvailable');
|
|
1274
|
+
if (total && available != null)
|
|
1275
|
+
reading = { used: (total - available) * 1024, total: total * 1024 };
|
|
1276
|
+
}
|
|
1277
|
+
else if (platform === 'darwin') {
|
|
1278
|
+
const vmStat = attempt(() => run('vm_stat', [])) ?? '';
|
|
1279
|
+
const pageSize = Number(/page size of (\d+) bytes/.exec(vmStat)?.[1]);
|
|
1280
|
+
const anonymous = figureAfter(vmStat, 'Anonymous pages');
|
|
1281
|
+
const wired = figureAfter(vmStat, 'Pages wired down');
|
|
1282
|
+
const compressed = figureAfter(vmStat, 'Pages occupied by compressor') ?? 0;
|
|
1283
|
+
const purgeable = figureAfter(vmStat, 'Pages purgeable') ?? 0;
|
|
1284
|
+
const total = attempt(totalmem) ?? 0;
|
|
1285
|
+
if (pageSize && anonymous != null && wired != null && total) {
|
|
1286
|
+
reading = { used: (Math.max(0, anonymous - purgeable) + wired + compressed) * pageSize, total };
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
if (!reading) {
|
|
1290
|
+
const total = attempt(totalmem) ?? 0;
|
|
1291
|
+
const free = attempt(freemem) ?? 0;
|
|
1292
|
+
if (total > 0)
|
|
1293
|
+
reading = { used: total - free, total };
|
|
1294
|
+
}
|
|
1295
|
+
return reading && { used: Math.min(reading.total, Math.max(0, reading.used)), total: reading.total };
|
|
1296
|
+
}
|
|
1297
|
+
// command: the one part that runs a command the user names. It runs only
|
|
1298
|
+
// when --command names one and a row shows the part, in the folder Claude
|
|
1299
|
+
// Code runs in, with no input. It has COMMAND_TIMEOUT_MS to finish, and is
|
|
1300
|
+
// then killed, so a slow or hung command costs the status line that long and
|
|
1301
|
+
// no longer. Its output is cut to the first line with text in it, and
|
|
1302
|
+
// capped at MAX_COMMAND_OUTPUT bytes; failure, a timeout or empty output
|
|
1303
|
+
// shows nothing.
|
|
1304
|
+
const COMMAND_TIMEOUT_MS = 500;
|
|
1305
|
+
exports.COMMAND_TIMEOUT_MS = COMMAND_TIMEOUT_MS;
|
|
1306
|
+
const MAX_COMMAND_OUTPUT = 64 * 1024;
|
|
1307
|
+
function runCommand(command, cwd) {
|
|
1308
|
+
// Outside Windows the shell leads a process group of its own, so that
|
|
1309
|
+
// whatever the command starts can be stopped with it.
|
|
1310
|
+
// Node 18 and Bun take detached in spawnSync, though Node's types list it
|
|
1311
|
+
// for spawn only.
|
|
1312
|
+
const grouped = process.platform !== 'win32';
|
|
1313
|
+
const options = {
|
|
1314
|
+
shell: true,
|
|
1315
|
+
detached: grouped,
|
|
1316
|
+
cwd,
|
|
1317
|
+
timeout: COMMAND_TIMEOUT_MS,
|
|
1318
|
+
killSignal: 'SIGKILL',
|
|
1319
|
+
maxBuffer: MAX_COMMAND_OUTPUT,
|
|
1320
|
+
encoding: 'utf8',
|
|
1321
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1322
|
+
windowsHide: true,
|
|
1323
|
+
};
|
|
1324
|
+
const result = (0, node_child_process_1.spawnSync)(command, options);
|
|
1325
|
+
// The timeout kills the shell alone, and a job it put in the background
|
|
1326
|
+
// outlives it, so stop the whole group, finished or not.
|
|
1327
|
+
if (grouped && result.pid) {
|
|
1328
|
+
try {
|
|
1329
|
+
process.kill(-result.pid, 'SIGKILL');
|
|
1330
|
+
}
|
|
1331
|
+
catch {
|
|
1332
|
+
/* the group has exited already */
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
// Failed, timed out, too much output, or no such folder.
|
|
1336
|
+
if (result.error || result.status !== 0)
|
|
1337
|
+
return '';
|
|
1338
|
+
// The first line with text left once sanitised, so a line of control codes
|
|
1339
|
+
// alone does not hide the line after it. The part sanitises what it shows.
|
|
1340
|
+
return result.stdout.split(/\r?\n/).find((line) => sanitise(line).trim())?.trim() ?? '';
|
|
1341
|
+
}
|
|
1342
|
+
// A terminal width, from render's option or the text of COLUMNS, which
|
|
1343
|
+
// Claude Code sets for the status line command; unknown unless it is a whole
|
|
1344
|
+
// number above 0.
|
|
1345
|
+
const columnsOf = (value) => {
|
|
1346
|
+
const n = Number(value);
|
|
1347
|
+
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
1348
|
+
};
|
|
1349
|
+
// Characters that take exactly one terminal column: printable Latin,
|
|
1350
|
+
// Greek and Cyrillic, and the punctuation, arrows, maths signs, box drawing,
|
|
1351
|
+
// blocks and the git part's ✘ that claude-gauge draws with. CJK and emoji
|
|
1352
|
+
// take two in most terminals and combining marks none, so they are left out.
|
|
1353
|
+
const ONE_COLUMN = /^[\x20-\x7e\u00a0-\u02ff\u0370-\u0482\u048a-\u052f\u2010-\u2027\u2030-\u205e\u2190-\u22ff\u2387\u2500-\u259f\u2718]*$/;
|
|
1354
|
+
// The columns a rendered text takes, or undefined when some character in it
|
|
1355
|
+
// may take more or less than one.
|
|
1356
|
+
const visibleWidth = (text) => {
|
|
1357
|
+
const shown = stripOwnCodes(text);
|
|
1358
|
+
return ONE_COLUMN.test(shown) ? [...shown].length : undefined;
|
|
1359
|
+
};
|
|
1360
|
+
// An address a link can carry: printable ASCII only, as OSC 8 requires, so
|
|
1361
|
+
// nothing in it can end the sequence early or reach the terminal as a code.
|
|
1362
|
+
const LINKABLE = /^[\x21-\x7e]+$/;
|
|
1363
|
+
// Text as an OSC 8 hyperlink. BEL ends each sequence, as in the example in
|
|
1364
|
+
// Claude Code's status line docs. A terminal without OSC 8 shows the text.
|
|
1365
|
+
const hyperlink = (url, text) => `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
|
|
1366
|
+
// A row's shown parts joined by the separator. With a known width, the parts
|
|
1367
|
+
// named in right move to the end of the row, in row order, and spaces fill
|
|
1368
|
+
// the gap so the row ends at the terminal's edge. A row with none of those
|
|
1369
|
+
// parts, with text of uncertain width, or with too little room for a gap as
|
|
1370
|
+
// wide as the separator, is left as it is.
|
|
1371
|
+
function joinRow(shown, separator, right, columns) {
|
|
1372
|
+
const join = (parts) => parts.map((p) => p.text).join(separator);
|
|
1373
|
+
const atEnd = shown.filter((p) => right.includes(p.part));
|
|
1374
|
+
if (columns === undefined || !atEnd.length)
|
|
1375
|
+
return join(shown);
|
|
1376
|
+
const left = join(shown.filter((p) => !atEnd.includes(p)));
|
|
1377
|
+
const end = join(atEnd);
|
|
1378
|
+
const [leftWidth, endWidth, separatorWidth] = [left, end, separator].map(visibleWidth);
|
|
1379
|
+
if (leftWidth === undefined || endWidth === undefined || separatorWidth === undefined)
|
|
1380
|
+
return join(shown);
|
|
1381
|
+
const gap = columns - leftWidth - endWidth;
|
|
1382
|
+
if (gap < (left ? separatorWidth : 0))
|
|
1383
|
+
return join(shown);
|
|
1384
|
+
return `${left}${' '.repeat(gap)}${end}`;
|
|
1385
|
+
}
|
|
1386
|
+
function render(data, { config: overrides = {}, nowMs = Date.now(), statusOf = gitStatus, branchOf = headBranch, mtimeOf = fileMtime, env = process.env, setupOf, memoryOf = readMemory, commandOutputOf = runCommand, transcript = readTranscriptActivity, columns, ledger, hostname = os.hostname(), } = {}) {
|
|
1387
|
+
const merged = { ...DEFAULTS, ...overrides };
|
|
1388
|
+
const config = { ...merged, segments: segmentsOf(merged.segments) };
|
|
1389
|
+
const cwd = data.workspace?.current_dir || data.cwd || process.cwd();
|
|
1390
|
+
const theme = themeOf(config.theme);
|
|
1391
|
+
let gitState;
|
|
1392
|
+
// The transcript is read at most once, and only when a part asks. A
|
|
1393
|
+
// transcript that cannot be read shows nothing rather than fails.
|
|
1394
|
+
let activity;
|
|
1395
|
+
const readActivity = () => {
|
|
1396
|
+
const file = data.transcript_path;
|
|
1397
|
+
if (typeof file !== 'string' || !file)
|
|
1398
|
+
return emptyActivity();
|
|
1399
|
+
try {
|
|
1400
|
+
return sanitiseActivity(transcript(file));
|
|
1401
|
+
}
|
|
1402
|
+
catch {
|
|
1403
|
+
return emptyActivity();
|
|
1404
|
+
}
|
|
1405
|
+
};
|
|
1406
|
+
let setup;
|
|
1407
|
+
let memory;
|
|
1408
|
+
let commandOutput;
|
|
1409
|
+
const input = {
|
|
1410
|
+
data: sanitiseAll(data),
|
|
1411
|
+
config,
|
|
1412
|
+
theme,
|
|
1413
|
+
nowMs,
|
|
1414
|
+
ledger: ledgerFrom(ledger),
|
|
1415
|
+
cwd,
|
|
1416
|
+
folder: sanitise(path.basename(cwd)),
|
|
1417
|
+
hostname,
|
|
1418
|
+
git: () => (gitState ??= readGit(cwd, statusOf, branchOf)),
|
|
1419
|
+
mtimeOf: (file) => mtimeOf(path.resolve(cwd, file)),
|
|
1420
|
+
activity: () => (activity ??= readActivity()),
|
|
1421
|
+
processEnv: env,
|
|
1422
|
+
// Read once per render, however many parts ask, and only when one does.
|
|
1423
|
+
setup: () => {
|
|
1424
|
+
if (!setup) {
|
|
1425
|
+
const read = setupOf ? setupOf(cwd) : readSetup(cwd, { env });
|
|
1426
|
+
setup = { ...read, plan: read.plan && sanitise(read.plan), user: read.user && sanitise(read.user) };
|
|
1427
|
+
}
|
|
1428
|
+
return setup;
|
|
1429
|
+
},
|
|
1430
|
+
memory: () => (memory ??= { reading: memoryOf() }).reading,
|
|
1431
|
+
// Nothing runs without a command, and a command runs once per render.
|
|
1432
|
+
commandOutput: () => (commandOutput ??= config.command ? commandOutputOf(config.command, cwd) : ''),
|
|
1433
|
+
};
|
|
1434
|
+
const separator = separatorOf(config, theme);
|
|
1435
|
+
const width = columnsOf(columns);
|
|
1436
|
+
// One output line per row. A part with nothing to show drops out of its
|
|
1437
|
+
// row, and a row left with no parts drops out of the status line.
|
|
1438
|
+
return config.rows
|
|
1439
|
+
.map((row) => joinRow(row
|
|
1440
|
+
// Rows handed in from JavaScript may name parts the registry lacks;
|
|
1441
|
+
// those render as nothing, like every other part with nothing to show.
|
|
1442
|
+
.map((part) => {
|
|
1443
|
+
if (!isPart(part))
|
|
1444
|
+
return { part, text: '' };
|
|
1445
|
+
const color = ownValue(config.colors, part);
|
|
1446
|
+
const spec = PART_REGISTRY[part];
|
|
1447
|
+
const text = spec.build(color ? { ...input, theme: solid(theme, color) } : input);
|
|
1448
|
+
const url = text && config.links ? spec.link?.(input) : undefined;
|
|
1449
|
+
return { part, text: url && LINKABLE.test(url) ? hyperlink(url, text) : text };
|
|
1450
|
+
})
|
|
1451
|
+
.filter((p) => p.text), separator, config.right, width))
|
|
1452
|
+
.filter(Boolean)
|
|
1453
|
+
.join('\n');
|
|
1454
|
+
}
|
|
1455
|
+
// --latest: the status line where Claude Code runs none (the VS Code panel).
|
|
1456
|
+
// It rebuilds a payload from the session transcript, and takes the 5h and 7d
|
|
1457
|
+
// windows from the last terminal render, which saves them, and today and
|
|
1458
|
+
// week from the cost ledger terminal renders keep.
|
|
1459
|
+
const configDir = () => configDirOf(process.env, os.homedir());
|
|
1460
|
+
// A file in the state folder.
|
|
1461
|
+
const stateFile = (name) => path.join(configDir(), 'claude-gauge', '.state', name);
|
|
1462
|
+
const usageFile = () => stateFile('usage.json');
|
|
1463
|
+
// Best effort: a failed save never breaks the status line.
|
|
1464
|
+
function saveUsage(data, nowMs) {
|
|
1465
|
+
if (!data.rate_limits)
|
|
1466
|
+
return;
|
|
1467
|
+
try {
|
|
1468
|
+
fs.mkdirSync(path.dirname(usageFile()), { recursive: true });
|
|
1469
|
+
fs.writeFileSync(usageFile(), JSON.stringify({ savedAt: nowMs, rate_limits: data.rate_limits }));
|
|
1470
|
+
}
|
|
1471
|
+
catch {
|
|
1472
|
+
/* read-only home or similar: skip */
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
// The last payload a terminal render read, for the claude-gauge wizard to
|
|
1476
|
+
// preview its choices with. One file, overwritten by each render.
|
|
1477
|
+
const payloadFile = () => stateFile('last-payload.json');
|
|
1478
|
+
exports.payloadFile = payloadFile;
|
|
1479
|
+
// A payload past this size is not a status payload, and is not kept, so the
|
|
1480
|
+
// file stays small.
|
|
1481
|
+
const MAX_SAVED_PAYLOAD = 64 * 1024;
|
|
1482
|
+
// Best effort, like saveUsage. Replaced whole, so the wizard never reads
|
|
1483
|
+
// half of one.
|
|
1484
|
+
function savePayload(data) {
|
|
1485
|
+
const text = JSON.stringify(data);
|
|
1486
|
+
if (text.length <= MAX_SAVED_PAYLOAD)
|
|
1487
|
+
replaceQuietly(fs, payloadFile(), text);
|
|
1488
|
+
}
|
|
1489
|
+
// The saved windows, without any that have reset since they were saved.
|
|
1490
|
+
function loadUsage(nowMs) {
|
|
1491
|
+
try {
|
|
1492
|
+
const { rate_limits: saved } = JSON.parse(fs.readFileSync(usageFile(), 'utf8'));
|
|
1493
|
+
const live = Object.entries(saved ?? {}).filter(([, w]) => !w?.resets_at || w.resets_at * 1000 > nowMs);
|
|
1494
|
+
return live.length ? Object.fromEntries(live) : undefined;
|
|
1495
|
+
}
|
|
1496
|
+
catch {
|
|
1497
|
+
return undefined;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
// The cost ledger file. Each terminal render records its session's cost in
|
|
1501
|
+
// it; the today and week parts read it, with --latest too.
|
|
1502
|
+
const ledgerFile = () => stateFile('ledger.json');
|
|
1503
|
+
// A session records its cost at most once in this time. The parts still show
|
|
1504
|
+
// its latest cost, as the spend the ledger has not recorded yet.
|
|
1505
|
+
const LEDGER_THROTTLE_MS = 10_000;
|
|
1506
|
+
// Days and sessions older than this drop out of the ledger.
|
|
1507
|
+
const LEDGER_KEEP_MS = 31 * 86400 * 1000;
|
|
1508
|
+
// A lock this old was left by a render that stopped while holding it.
|
|
1509
|
+
const STALE_LOCK_MS = 10_000;
|
|
1510
|
+
// How long a render waits for another to finish writing, in all.
|
|
1511
|
+
const LOCK_WAIT_MS = 100;
|
|
1512
|
+
// The ledger the file holds, or an empty one when there is none or it is not
|
|
1513
|
+
// JSON.
|
|
1514
|
+
function readLedger(file) {
|
|
1515
|
+
try {
|
|
1516
|
+
return ledgerFrom(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
1517
|
+
}
|
|
1518
|
+
catch {
|
|
1519
|
+
return ledgerFrom(undefined);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
1523
|
+
const isStale = (file) => Date.now() - fs.statSync(file).mtimeMs > STALE_LOCK_MS;
|
|
1524
|
+
// Removes a lock a render left when it stopped while holding it. The lock is
|
|
1525
|
+
// first renamed to a name of this render's own, which only one render can do,
|
|
1526
|
+
// and then checked again, so a lock another render took in the meantime is
|
|
1527
|
+
// put back rather than removed.
|
|
1528
|
+
function breakStaleLock(lock, token) {
|
|
1529
|
+
const taken = `${lock}.${token}`;
|
|
1530
|
+
try {
|
|
1531
|
+
if (!isStale(lock))
|
|
1532
|
+
return;
|
|
1533
|
+
fs.renameSync(lock, taken);
|
|
1534
|
+
}
|
|
1535
|
+
catch {
|
|
1536
|
+
return; // the lock went, or another render took it first
|
|
1537
|
+
}
|
|
1538
|
+
try {
|
|
1539
|
+
if (!isStale(taken))
|
|
1540
|
+
fs.linkSync(taken, lock);
|
|
1541
|
+
}
|
|
1542
|
+
catch {
|
|
1543
|
+
/* a newer lock is in place: leave it */
|
|
1544
|
+
}
|
|
1545
|
+
fs.rmSync(taken, { force: true });
|
|
1546
|
+
}
|
|
1547
|
+
// Runs write while holding the ledger's lock, a file only one render at a
|
|
1548
|
+
// time can create, holding a token of the render's own. A render that cannot
|
|
1549
|
+
// take the lock in LOCK_WAIT_MS writes nothing, and loses nothing: its
|
|
1550
|
+
// session's spend stays unrecorded until a later render records it. write
|
|
1551
|
+
// gets held, which says whether the lock is still this render's: a render
|
|
1552
|
+
// that stopped for longer than STALE_LOCK_MS, as across a system sleep, can
|
|
1553
|
+
// find its lock broken and taken by another, and must then commit nothing.
|
|
1554
|
+
function withLock(file, write) {
|
|
1555
|
+
const lock = `${file}.lock`;
|
|
1556
|
+
const token = `${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
|
|
1557
|
+
for (let waited = 0;; waited += 5) {
|
|
1558
|
+
try {
|
|
1559
|
+
fs.writeFileSync(lock, token, { flag: 'wx' });
|
|
1560
|
+
break;
|
|
1561
|
+
}
|
|
1562
|
+
catch (error) {
|
|
1563
|
+
if (error.code !== 'EEXIST')
|
|
1564
|
+
throw error;
|
|
1565
|
+
breakStaleLock(lock, token);
|
|
1566
|
+
if (waited >= LOCK_WAIT_MS)
|
|
1567
|
+
return;
|
|
1568
|
+
sleep(5);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
const held = () => {
|
|
1572
|
+
try {
|
|
1573
|
+
return fs.readFileSync(lock, 'utf8') === token;
|
|
1574
|
+
}
|
|
1575
|
+
catch {
|
|
1576
|
+
return false;
|
|
1577
|
+
}
|
|
1578
|
+
};
|
|
1579
|
+
try {
|
|
1580
|
+
write(held);
|
|
1581
|
+
}
|
|
1582
|
+
finally {
|
|
1583
|
+
// Only this render's own lock: another may hold it once this one was
|
|
1584
|
+
// taken for stale.
|
|
1585
|
+
if (held())
|
|
1586
|
+
fs.rmSync(lock, { force: true });
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
// Records what the session has spent since the ledger last recorded it,
|
|
1590
|
+
// against today, and returns the ledger as it now stands. It writes only when
|
|
1591
|
+
// the cost has changed and LEDGER_THROTTLE_MS has passed since the session
|
|
1592
|
+
// last wrote. Under the lock it reads the file again, so a write never loses
|
|
1593
|
+
// another session's, and it replaces the file in one rename, so a reader never
|
|
1594
|
+
// sees half a ledger. Best effort: a failed write never breaks the status
|
|
1595
|
+
// line.
|
|
1596
|
+
function recordCost(data, { file = ledgerFile(), nowMs = Date.now() } = {}) {
|
|
1597
|
+
let ledger = readLedger(file);
|
|
1598
|
+
const id = data.session_id;
|
|
1599
|
+
const usd = finite(data.cost?.total_cost_usd);
|
|
1600
|
+
if (typeof id !== 'string' || !id || usd === undefined)
|
|
1601
|
+
return ledger;
|
|
1602
|
+
const last = ledger.sessions[id];
|
|
1603
|
+
if (finite(last?.usd) === usd)
|
|
1604
|
+
return ledger;
|
|
1605
|
+
const since = nowMs - (finite(last?.at) ?? -Infinity);
|
|
1606
|
+
if (since >= 0 && since < LEDGER_THROTTLE_MS)
|
|
1607
|
+
return ledger;
|
|
1608
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
1609
|
+
try {
|
|
1610
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1611
|
+
withLock(file, (held) => {
|
|
1612
|
+
const fresh = readLedger(file);
|
|
1613
|
+
const day = dayKey(nowMs);
|
|
1614
|
+
fresh.days[day] = (finite(fresh.days[day]) ?? 0) + unrecorded(fresh, data);
|
|
1615
|
+
fresh.sessions[id] = { usd, at: nowMs };
|
|
1616
|
+
forgetOld(fresh, nowMs);
|
|
1617
|
+
fs.writeFileSync(temp, JSON.stringify(fresh));
|
|
1618
|
+
// A lock lost while this render stopped: the ledger read above may be
|
|
1619
|
+
// out of date, so the session's spend waits for a later render.
|
|
1620
|
+
if (!held())
|
|
1621
|
+
return;
|
|
1622
|
+
fs.renameSync(temp, file);
|
|
1623
|
+
ledger = fresh;
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
catch {
|
|
1627
|
+
/* the folder cannot be written at all: the status line still shows */
|
|
1628
|
+
}
|
|
1629
|
+
try {
|
|
1630
|
+
fs.rmSync(temp, { force: true });
|
|
1631
|
+
}
|
|
1632
|
+
catch {
|
|
1633
|
+
/* nothing was left */
|
|
1634
|
+
}
|
|
1635
|
+
return ledger;
|
|
1636
|
+
}
|
|
1637
|
+
// Drops the days and sessions older than LEDGER_KEEP_MS, and any entry that
|
|
1638
|
+
// is not a ledger entry.
|
|
1639
|
+
function forgetOld(ledger, nowMs) {
|
|
1640
|
+
const oldest = dayKey(nowMs - LEDGER_KEEP_MS);
|
|
1641
|
+
for (const [day, usd] of Object.entries(ledger.days)) {
|
|
1642
|
+
if (day < oldest || finite(usd) === undefined)
|
|
1643
|
+
delete ledger.days[day];
|
|
1644
|
+
}
|
|
1645
|
+
for (const [id, entry] of Object.entries(ledger.sessions)) {
|
|
1646
|
+
const at = finite(entry?.at);
|
|
1647
|
+
if (at === undefined || at < nowMs - LEDGER_KEEP_MS || finite(entry?.usd) === undefined)
|
|
1648
|
+
delete ledger.sessions[id];
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
const emptyActivity = () => ({ tools: { running: [], completed: {} }, agents: [], todos: [], skills: [], mcp: [], compactions: 0 });
|
|
1652
|
+
// Activity with every name and target sanitised. Two tool names that differ
|
|
1653
|
+
// only in control codes count as one.
|
|
1654
|
+
// A reader handed in from JavaScript may leave out all but the tools, or give
|
|
1655
|
+
// counters that are not numbers.
|
|
1656
|
+
function sanitiseActivity({ tools, agents = [], todos = [], skills = [], mcp = [], compactions, lastReplyAt, speed }) {
|
|
1657
|
+
const running = tools.running.map(({ name, target }) => ({ name: sanitise(name), ...(target ? { target: sanitise(target) } : {}) }));
|
|
1658
|
+
const completed = {};
|
|
1659
|
+
for (const [name, count] of Object.entries(tools.completed))
|
|
1660
|
+
completed[sanitise(name)] = (completed[sanitise(name)] ?? 0) + count;
|
|
1661
|
+
const cleanAgents = agents.map(({ type, model, description, ...times }) => ({
|
|
1662
|
+
...times,
|
|
1663
|
+
type: sanitise(type),
|
|
1664
|
+
...(model ? { model: sanitise(model) } : {}),
|
|
1665
|
+
...(description ? { description: sanitise(description) } : {}),
|
|
1666
|
+
}));
|
|
1667
|
+
const cleanTodos = todos.map(({ content, activeForm, status }) => ({
|
|
1668
|
+
content: sanitise(content),
|
|
1669
|
+
...(activeForm ? { activeForm: sanitise(activeForm) } : {}),
|
|
1670
|
+
status,
|
|
1671
|
+
}));
|
|
1672
|
+
const cleanSkills = skills.map(sanitise);
|
|
1673
|
+
const cleanMcp = mcp.map(({ name, failed }) => ({ name: sanitise(name), ...(failed ? { failed } : {}) }));
|
|
1674
|
+
const replyAt = finite(lastReplyAt);
|
|
1675
|
+
const tokensPerSecond = finite(speed);
|
|
1676
|
+
return {
|
|
1677
|
+
tools: { running, completed },
|
|
1678
|
+
agents: cleanAgents,
|
|
1679
|
+
todos: cleanTodos,
|
|
1680
|
+
skills: cleanSkills,
|
|
1681
|
+
mcp: cleanMcp,
|
|
1682
|
+
compactions: finite(compactions) ?? 0,
|
|
1683
|
+
...(replyAt !== undefined ? { lastReplyAt: replyAt } : {}),
|
|
1684
|
+
...(tokensPerSecond !== undefined ? { speed: tokensPerSecond } : {}),
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
// What the reader keeps between renders. version changes when the shape
|
|
1688
|
+
// does, so a state from an older claude-gauge is rebuilt, not misread.
|
|
1689
|
+
const TRANSCRIPT_STATE_VERSION = 5;
|
|
1690
|
+
// The ended calls kept at most, newest first: a call whose result never
|
|
1691
|
+
// came must not grow the state for ever. Running calls are all kept, so a
|
|
1692
|
+
// large parallel batch counts in full. Ended subagents are capped the same,
|
|
1693
|
+
// and so are the todo tools' calls that wait for a result.
|
|
1694
|
+
const ENDED_KEPT = 20;
|
|
1695
|
+
// The tool that starts a subagent: Agent, named Task before Claude Code 2.1.
|
|
1696
|
+
const AGENT_TOOLS = ['Agent', 'Task'];
|
|
1697
|
+
// The tools that write the todo list: TodoWrite replaces it whole, and the
|
|
1698
|
+
// task tools that replaced it in Claude Code 2.1 add a task and change one.
|
|
1699
|
+
const TODO_TOOLS = ['TodoWrite', 'TaskCreate', 'TaskUpdate'];
|
|
1700
|
+
// The skills and the MCP servers kept at most, each the most recently used.
|
|
1701
|
+
const RECENT_KEPT = 20;
|
|
1702
|
+
// The text Claude Code adds as a meta message when a skill runs.
|
|
1703
|
+
const SKILL_TEXT = 'Base directory for this skill:';
|
|
1704
|
+
const transcriptStateDir = () => path.join(configDir(), 'claude-gauge', '.state', 'transcripts');
|
|
1705
|
+
// The input field that names what a tool works on, most telling first.
|
|
1706
|
+
const TARGET_FIELDS = ['file_path', 'notebook_path', 'pattern', 'command', 'url', 'query', 'description', 'skill', 'path'];
|
|
1707
|
+
function toolTarget(input) {
|
|
1708
|
+
if (!input || typeof input !== 'object')
|
|
1709
|
+
return undefined;
|
|
1710
|
+
for (const field of TARGET_FIELDS) {
|
|
1711
|
+
const value = input[field];
|
|
1712
|
+
if (typeof value === 'string' && value.trim())
|
|
1713
|
+
return value.trim().split('\n')[0];
|
|
1714
|
+
}
|
|
1715
|
+
return undefined;
|
|
1716
|
+
}
|
|
1717
|
+
// A record's time in ms since the epoch, if it has a valid one.
|
|
1718
|
+
const timeOf = (record) => {
|
|
1719
|
+
const ms = typeof record.timestamp === 'string' ? Date.parse(record.timestamp) : NaN;
|
|
1720
|
+
return Number.isFinite(ms) ? ms : undefined;
|
|
1721
|
+
};
|
|
1722
|
+
// A model as the agents part prints it, from an id or an alias: Haiku 4.5,
|
|
1723
|
+
// Sonnet. inherit names no model of its own.
|
|
1724
|
+
const agentModel = (value) => typeof value === 'string' && value.trim() && value.trim() !== 'inherit' ? modelName(value.trim()) : undefined;
|
|
1725
|
+
// The subagent a call to the Agent tool starts.
|
|
1726
|
+
function startAgent(state, block, startedAt) {
|
|
1727
|
+
const input = isObject(block.input) ? block.input : {};
|
|
1728
|
+
const model = agentModel(input.model);
|
|
1729
|
+
const description = stringAt(input, 'description');
|
|
1730
|
+
state.agents.push({
|
|
1731
|
+
id: String(block.id),
|
|
1732
|
+
type: stringAt(input, 'subagent_type') ?? 'general-purpose',
|
|
1733
|
+
...(description ? { description } : {}),
|
|
1734
|
+
...(model ? { model } : {}),
|
|
1735
|
+
...(startedAt !== undefined ? { startedAt } : {}),
|
|
1736
|
+
// Known from the call, so a prompt before the launch result does not stop it.
|
|
1737
|
+
...(input.run_in_background === true ? { background: true } : {}),
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
// A subagent's end: when it ended, and whether it failed or was stopped. An
|
|
1741
|
+
// end with no time counts as long ago, so the part drops it rather than
|
|
1742
|
+
// shows it as running.
|
|
1743
|
+
function endAgent(agent, endedAt, failed) {
|
|
1744
|
+
agent.endedAt = endedAt ?? 0;
|
|
1745
|
+
if (failed)
|
|
1746
|
+
agent.failed = true;
|
|
1747
|
+
else
|
|
1748
|
+
delete agent.failed;
|
|
1749
|
+
}
|
|
1750
|
+
// The result of an Agent call: the end of a subagent in the foreground, or,
|
|
1751
|
+
// for one in the background, only its launch, which names the model.
|
|
1752
|
+
function agentResult(agent, block, record, at) {
|
|
1753
|
+
const result = isObject(record.toolUseResult) ? record.toolUseResult : {};
|
|
1754
|
+
const model = agentModel(result.resolvedModel);
|
|
1755
|
+
if (model)
|
|
1756
|
+
agent.model = model;
|
|
1757
|
+
if (result.isAsync === true || result.status === 'async_launched') {
|
|
1758
|
+
agent.background = true;
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
endAgent(agent, at, block.is_error === true || (typeof result.status === 'string' && result.status !== 'completed'));
|
|
1762
|
+
}
|
|
1763
|
+
// The text of a message: its content when that is text, else its text blocks.
|
|
1764
|
+
const textOf = (content) => typeof content === 'string'
|
|
1765
|
+
? content
|
|
1766
|
+
: Array.isArray(content)
|
|
1767
|
+
? content.map((b) => (isObject(b) && typeof b.text === 'string' ? b.text : '')).join('\n')
|
|
1768
|
+
: '';
|
|
1769
|
+
// A task notification, which Claude Code adds when a task in the background
|
|
1770
|
+
// ends: the call that started the task, and its status. Not a prompt.
|
|
1771
|
+
function taskNotification(record) {
|
|
1772
|
+
// Only the origin tells one apart: a prompt the user types can hold the same text.
|
|
1773
|
+
if (!isObject(record.origin) || record.origin.kind !== 'task-notification')
|
|
1774
|
+
return undefined;
|
|
1775
|
+
const text = textOf(record.message?.content);
|
|
1776
|
+
return {
|
|
1777
|
+
id: /<tool-use-id>([^<]*)<\/tool-use-id>/.exec(text)?.[1]?.trim(),
|
|
1778
|
+
status: /<status>([^<]*)<\/status>/.exec(text)?.[1]?.trim(),
|
|
1779
|
+
};
|
|
1780
|
+
}
|
|
1781
|
+
// A status the todo tools write, or undefined for any other value.
|
|
1782
|
+
const todoStatus = (value) => value === 'pending' || value === 'in_progress' || value === 'completed' ? value : undefined;
|
|
1783
|
+
// A field that holds text, not only white space.
|
|
1784
|
+
const textAt = (value, key) => (stringAt(value, key)?.trim() ? stringAt(value, key) : undefined);
|
|
1785
|
+
// A field that holds an id, as a string or a number, as a string.
|
|
1786
|
+
const idAt = (value, key) => {
|
|
1787
|
+
const field = isObject(value) ? value[key] : undefined;
|
|
1788
|
+
return typeof field === 'number' ? String(field) : stringAt(value, key);
|
|
1789
|
+
};
|
|
1790
|
+
// A todo from the fields a todo tool names, or undefined when it has no text.
|
|
1791
|
+
function todoOf(content, activeForm, status, id) {
|
|
1792
|
+
if (!content)
|
|
1793
|
+
return undefined;
|
|
1794
|
+
return { ...(id ? { id } : {}), content, ...(activeForm ? { activeForm } : {}), status };
|
|
1795
|
+
}
|
|
1796
|
+
// The todos a TodoWrite call writes. One with no text drops out, and one
|
|
1797
|
+
// with a status the part does not know counts as pending.
|
|
1798
|
+
function writtenTodos(input) {
|
|
1799
|
+
const todos = Array.isArray(input.todos) ? input.todos : [];
|
|
1800
|
+
return todos.flatMap((todo) => {
|
|
1801
|
+
const status = (isObject(todo) && todoStatus(todo.status)) || 'pending';
|
|
1802
|
+
return todoOf(textAt(todo, 'content'), textAt(todo, 'activeForm'), status) ?? [];
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
// The id of the task a TaskCreate result names: from its data, else from
|
|
1806
|
+
// its text, Task #7 created successfully.
|
|
1807
|
+
function createdTaskId(block, record) {
|
|
1808
|
+
const task = isObject(record.toolUseResult) ? record.toolUseResult.task : undefined;
|
|
1809
|
+
return idAt(task, 'id') ?? /Task #(\S+) created/.exec(textOf(block.content))?.[1];
|
|
1810
|
+
}
|
|
1811
|
+
// A todo tool's call applied to the list, once its result says it worked:
|
|
1812
|
+
// TodoWrite replaces the list, TaskCreate adds a pending task, and
|
|
1813
|
+
// TaskUpdate changes one, or drops it when it is deleted.
|
|
1814
|
+
function applyTodoCall(state, todoCall, block, record) {
|
|
1815
|
+
if (resultFailed(block, record))
|
|
1816
|
+
return;
|
|
1817
|
+
const input = isObject(todoCall.input) ? todoCall.input : {};
|
|
1818
|
+
if (todoCall.name === 'TodoWrite') {
|
|
1819
|
+
state.todos = writtenTodos(input);
|
|
1820
|
+
}
|
|
1821
|
+
else if (todoCall.name === 'TaskCreate') {
|
|
1822
|
+
const task = todoOf(textAt(input, 'subject'), textAt(input, 'activeForm'), 'pending', createdTaskId(block, record));
|
|
1823
|
+
if (task)
|
|
1824
|
+
state.todos = [...state.todos, task];
|
|
1825
|
+
}
|
|
1826
|
+
else if (todoCall.name === 'TaskUpdate') {
|
|
1827
|
+
const id = idAt(input, 'taskId');
|
|
1828
|
+
const task = id === undefined ? undefined : state.todos.find((t) => t.id === id);
|
|
1829
|
+
if (!task)
|
|
1830
|
+
return;
|
|
1831
|
+
if (input.status === 'deleted') {
|
|
1832
|
+
state.todos = state.todos.filter((t) => t !== task);
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
const status = todoStatus(input.status);
|
|
1836
|
+
if (status)
|
|
1837
|
+
task.status = status;
|
|
1838
|
+
const content = textAt(input, 'subject');
|
|
1839
|
+
if (content)
|
|
1840
|
+
task.content = content;
|
|
1841
|
+
const activeForm = textAt(input, 'activeForm');
|
|
1842
|
+
if (activeForm)
|
|
1843
|
+
task.activeForm = activeForm;
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
// The MCP server a tool belongs to, from the name Claude Code gives an MCP
|
|
1847
|
+
// server's tools: mcp__github__search_issues is github's.
|
|
1848
|
+
const mcpServerOf = (tool) => /^mcp__(.+?)__./.exec(tool)?.[1];
|
|
1849
|
+
// A skill's name as the Skill tool or a command names it, with no slash.
|
|
1850
|
+
const skillName = (value) => value?.trim().replace(/^\//, '') || undefined;
|
|
1851
|
+
// The skill a prompt's command names, from the text Claude Code records for
|
|
1852
|
+
// a slash command: <command-name>/tdd</command-name>.
|
|
1853
|
+
const commandOf = (content) => skillName(/<command-name>([^<]*)<\/command-name>/.exec(textOf(content))?.[1]);
|
|
1854
|
+
// A list in the order last used, with item moved to the end, and the
|
|
1855
|
+
// oldest past RECENT_KEPT dropped.
|
|
1856
|
+
const usedNow = (list, item) => [...list.filter((i) => i !== item), item].slice(-RECENT_KEPT);
|
|
1857
|
+
// An MCP server as called now, keeping whether its latest call failed.
|
|
1858
|
+
function useServer(state, name) {
|
|
1859
|
+
state.mcp = usedNow(state.mcp, state.mcp.find((s) => s.name === name) ?? { name });
|
|
1860
|
+
}
|
|
1861
|
+
// A result that says its call did not work: an error, or data that says so.
|
|
1862
|
+
const resultFailed = (block, record) => block.is_error === true || (isObject(record.toolUseResult) && record.toolUseResult.success === false);
|
|
1863
|
+
// The error results Claude Code writes when the user rejects or interrupts a
|
|
1864
|
+
// call, or a permission rule denies it: the tool never ran, so they say
|
|
1865
|
+
// nothing about whether it works.
|
|
1866
|
+
const STOPPED_RESULTS = [/^The user doesn't want to proceed/, /^\[Request interrupted by user/, /^Permission to use /];
|
|
1867
|
+
const resultStopped = (block) => STOPPED_RESULTS.some((stopped) => stopped.test(textOf(block.content).trim()));
|
|
1868
|
+
// The ended subagents past the newest ENDED_KEPT drop out; running ones stay.
|
|
1869
|
+
function capAgents(state) {
|
|
1870
|
+
let ended = 0;
|
|
1871
|
+
state.agents = state.agents
|
|
1872
|
+
.reverse()
|
|
1873
|
+
.filter((a) => a.endedAt === undefined || ++ended <= ENDED_KEPT)
|
|
1874
|
+
.reverse();
|
|
1875
|
+
}
|
|
1876
|
+
// A response's record applied to the state: the first block of a message
|
|
1877
|
+
// starts a response, timed from the prompt or tool result that asked for it,
|
|
1878
|
+
// and each block after it moves the response's end. A reply Claude Code
|
|
1879
|
+
// writes itself, such as an API error, is no response of the model's.
|
|
1880
|
+
function applyResponse(state, record, at) {
|
|
1881
|
+
const message = record.message;
|
|
1882
|
+
if (at === undefined || typeof message?.id !== 'string' || message.model === '<synthetic>')
|
|
1883
|
+
return;
|
|
1884
|
+
const tokens = finite(message.usage?.output_tokens) ?? 0;
|
|
1885
|
+
if (state.response?.id === message.id) {
|
|
1886
|
+
state.response.endedAt = Math.max(state.response.endedAt, at);
|
|
1887
|
+
state.response.tokens = tokens;
|
|
1888
|
+
}
|
|
1889
|
+
else {
|
|
1890
|
+
state.response = { id: message.id, askedAt: state.askedAt ?? at, endedAt: at, tokens };
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
// The last response's output tokens per second, when it had tokens and took
|
|
1894
|
+
// time to write.
|
|
1895
|
+
function speedOf(response) {
|
|
1896
|
+
const seconds = response ? (response.endedAt - response.askedAt) / 1000 : 0;
|
|
1897
|
+
return response && response.tokens > 0 && seconds > 0 ? response.tokens / seconds : undefined;
|
|
1898
|
+
}
|
|
1899
|
+
// One transcript record applied to the state. Subagent records are left out,
|
|
1900
|
+
// as they are for --latest. A prompt from the user ends the turn, so a call
|
|
1901
|
+
// still running then was interrupted: it no longer shows as running, and a
|
|
1902
|
+
// subagent in the foreground shows as stopped. One in the background runs on
|
|
1903
|
+
// until its task notification. A prompt that runs a command names a skill
|
|
1904
|
+
// when the next record is the skill's text.
|
|
1905
|
+
function applyRecord(state, record) {
|
|
1906
|
+
if (!record || typeof record !== 'object' || record.isSidechain)
|
|
1907
|
+
return;
|
|
1908
|
+
const at = timeOf(record);
|
|
1909
|
+
if (record.type === 'system' && record.subtype === 'compact_boundary')
|
|
1910
|
+
state.compactions++;
|
|
1911
|
+
if (record.type === 'assistant')
|
|
1912
|
+
applyResponse(state, record, at);
|
|
1913
|
+
if (record.type === 'user' && at !== undefined)
|
|
1914
|
+
state.askedAt = at;
|
|
1915
|
+
const content = record.message?.content;
|
|
1916
|
+
const blocks = Array.isArray(content) ? content.filter((b) => b && typeof b === 'object') : [];
|
|
1917
|
+
const results = blocks.some((b) => b.type === 'tool_result');
|
|
1918
|
+
const { command } = state;
|
|
1919
|
+
delete state.command;
|
|
1920
|
+
if (record.type === 'user' && record.isMeta && !results) {
|
|
1921
|
+
if (command && textOf(content).startsWith(SKILL_TEXT))
|
|
1922
|
+
state.skills = usedNow(state.skills, command);
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
// A task notification ends a task in the background, and is no prompt.
|
|
1926
|
+
const notified = record.type === 'user' ? taskNotification(record) : undefined;
|
|
1927
|
+
if (notified) {
|
|
1928
|
+
const agent = state.agents.find((a) => a.id === notified.id);
|
|
1929
|
+
if (agent)
|
|
1930
|
+
endAgent(agent, at, notified.status !== 'completed');
|
|
1931
|
+
capAgents(state);
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
if (record.type === 'user' && !results) {
|
|
1935
|
+
if (typeof content === 'string' || blocks.length) {
|
|
1936
|
+
const named = commandOf(content);
|
|
1937
|
+
if (named)
|
|
1938
|
+
state.command = named;
|
|
1939
|
+
state.pending = state.pending.map((p) => ({ ...p, ended: true })).slice(-ENDED_KEPT);
|
|
1940
|
+
for (const agent of state.agents)
|
|
1941
|
+
if (agent.endedAt === undefined && !agent.background)
|
|
1942
|
+
endAgent(agent, at, true);
|
|
1943
|
+
capAgents(state);
|
|
1944
|
+
}
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
for (const block of blocks) {
|
|
1948
|
+
if (record.type === 'assistant' && block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') {
|
|
1949
|
+
const target = toolTarget(block.input);
|
|
1950
|
+
const skill = block.name === 'Skill' ? skillName(stringAt(block.input, 'skill')) : undefined;
|
|
1951
|
+
state.pending = [...state.pending, { id: block.id, name: block.name, ...(target ? { target } : {}), ...(skill ? { skill } : {}) }];
|
|
1952
|
+
const server = mcpServerOf(block.name);
|
|
1953
|
+
if (server)
|
|
1954
|
+
useServer(state, server);
|
|
1955
|
+
if (AGENT_TOOLS.includes(block.name))
|
|
1956
|
+
startAgent(state, block, at);
|
|
1957
|
+
if (TODO_TOOLS.includes(block.name))
|
|
1958
|
+
state.todoCalls = [...state.todoCalls, { id: block.id, name: block.name, input: block.input }].slice(-ENDED_KEPT);
|
|
1959
|
+
}
|
|
1960
|
+
else if (record.type === 'user' && block.type === 'tool_result') {
|
|
1961
|
+
const agent = state.agents.find((a) => a.id === block.tool_use_id);
|
|
1962
|
+
if (agent)
|
|
1963
|
+
agentResult(agent, block, record, at);
|
|
1964
|
+
const todoCall = state.todoCalls.find((c) => c.id === block.tool_use_id);
|
|
1965
|
+
if (todoCall) {
|
|
1966
|
+
state.todoCalls = state.todoCalls.filter((c) => c !== todoCall);
|
|
1967
|
+
applyTodoCall(state, todoCall, block, record);
|
|
1968
|
+
}
|
|
1969
|
+
const call = state.pending.find((p) => p.id === block.tool_use_id);
|
|
1970
|
+
if (!call)
|
|
1971
|
+
continue;
|
|
1972
|
+
state.pending = state.pending.filter((p) => p !== call);
|
|
1973
|
+
state.completed[call.name] = (state.completed[call.name] ?? 0) + 1;
|
|
1974
|
+
if (call.skill && !resultFailed(block, record))
|
|
1975
|
+
state.skills = usedNow(state.skills, call.skill);
|
|
1976
|
+
const server = state.mcp.find((s) => s.name === mcpServerOf(call.name));
|
|
1977
|
+
if (server && !resultStopped(block)) {
|
|
1978
|
+
if (resultFailed(block, record))
|
|
1979
|
+
server.failed = true;
|
|
1980
|
+
else
|
|
1981
|
+
delete server.failed;
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
capAgents(state);
|
|
1986
|
+
}
|
|
1987
|
+
// Reads the transcript from offset to size, applying each whole line, and
|
|
1988
|
+
// returns the offset after the last one. A line still being written is left
|
|
1989
|
+
// for the next render.
|
|
1990
|
+
function readLines(io, file, offset, size, apply) {
|
|
1991
|
+
const fd = io.openSync(file, 'r');
|
|
1992
|
+
try {
|
|
1993
|
+
// The bytes read since the last newline, in the chunks they came in, so
|
|
1994
|
+
// a long line is joined once rather than copied again for every chunk.
|
|
1995
|
+
let rest = [];
|
|
1996
|
+
let restLength = 0;
|
|
1997
|
+
let position = offset;
|
|
1998
|
+
while (position < size) {
|
|
1999
|
+
const chunk = Buffer.alloc(Math.min(1 << 16, size - position));
|
|
2000
|
+
const n = io.readSync(fd, chunk, 0, chunk.length, position);
|
|
2001
|
+
if (n <= 0)
|
|
2002
|
+
break;
|
|
2003
|
+
position += n;
|
|
2004
|
+
const read = chunk.subarray(0, n);
|
|
2005
|
+
const end = read.lastIndexOf(0x0a);
|
|
2006
|
+
if (end < 0) {
|
|
2007
|
+
rest.push(read);
|
|
2008
|
+
restLength += n;
|
|
2009
|
+
continue;
|
|
2010
|
+
}
|
|
2011
|
+
const lines = Buffer.concat([...rest, read.subarray(0, end)]).toString('utf8');
|
|
2012
|
+
for (const line of lines.split('\n'))
|
|
2013
|
+
apply(line);
|
|
2014
|
+
rest = [read.subarray(end + 1)];
|
|
2015
|
+
restLength = n - end - 1;
|
|
2016
|
+
}
|
|
2017
|
+
return position - restLength;
|
|
2018
|
+
}
|
|
2019
|
+
finally {
|
|
2020
|
+
io.closeSync(fd);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
function loadTranscriptState(io, stateFile) {
|
|
2024
|
+
try {
|
|
2025
|
+
const state = JSON.parse(String(io.readFileSync(stateFile, 'utf8')));
|
|
2026
|
+
const valid = state?.version === TRANSCRIPT_STATE_VERSION &&
|
|
2027
|
+
Number.isInteger(state.offset) &&
|
|
2028
|
+
Array.isArray(state.pending) &&
|
|
2029
|
+
Array.isArray(state.agents) &&
|
|
2030
|
+
Array.isArray(state.todos) &&
|
|
2031
|
+
Array.isArray(state.todoCalls) &&
|
|
2032
|
+
Array.isArray(state.skills) &&
|
|
2033
|
+
Array.isArray(state.mcp) &&
|
|
2034
|
+
Number.isInteger(state.compactions) &&
|
|
2035
|
+
state.completed &&
|
|
2036
|
+
typeof state.completed === 'object';
|
|
2037
|
+
return valid ? state : undefined;
|
|
2038
|
+
}
|
|
2039
|
+
catch {
|
|
2040
|
+
return undefined;
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
// Best effort, as saveUsage is. Unlike usage.json, which one render writes
|
|
2044
|
+
// in place, the state is written whole to a file of its own and renamed over
|
|
2045
|
+
// the old one: the status line can render twice at once, and the other
|
|
2046
|
+
// render must never read half a state.
|
|
2047
|
+
// Writes `text` beside `file` and renames it over the file, so a reader sees
|
|
2048
|
+
// the old file or the new one. Best effort: in a read-only home or similar
|
|
2049
|
+
// it writes nothing and leaves no temporary file.
|
|
2050
|
+
function replaceQuietly(io, file, text) {
|
|
2051
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
2052
|
+
try {
|
|
2053
|
+
io.mkdirSync(path.dirname(file), { recursive: true });
|
|
2054
|
+
io.writeFileSync(temporary, text);
|
|
2055
|
+
io.renameSync(temporary, file);
|
|
2056
|
+
}
|
|
2057
|
+
catch {
|
|
2058
|
+
try {
|
|
2059
|
+
io.rmSync(temporary, { force: true });
|
|
2060
|
+
}
|
|
2061
|
+
catch {
|
|
2062
|
+
/* nothing to remove */
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
// A state that is not saved makes the next render read from the start.
|
|
2067
|
+
function saveTranscriptState(io, stateFile, state) {
|
|
2068
|
+
replaceQuietly(io, stateFile, JSON.stringify(state));
|
|
2069
|
+
}
|
|
2070
|
+
// What the transcript shows, reading only what it gained since the last
|
|
2071
|
+
// call. A transcript that does not exist shows nothing.
|
|
2072
|
+
function readTranscriptActivity(file, { stateDir = transcriptStateDir(), fs: io = fs } = {}) {
|
|
2073
|
+
let stat;
|
|
2074
|
+
try {
|
|
2075
|
+
stat = io.statSync(file);
|
|
2076
|
+
}
|
|
2077
|
+
catch {
|
|
2078
|
+
return emptyActivity();
|
|
2079
|
+
}
|
|
2080
|
+
const stateFile = path.join(stateDir, `${(0, node_crypto_1.createHash)('sha1').update(file).digest('hex')}.json`);
|
|
2081
|
+
const saved = loadTranscriptState(io, stateFile);
|
|
2082
|
+
const unchanged = saved && saved.file === file && saved.dev === stat.dev && saved.ino === stat.ino && saved.offset <= stat.size;
|
|
2083
|
+
const state = unchanged
|
|
2084
|
+
? saved
|
|
2085
|
+
: {
|
|
2086
|
+
version: TRANSCRIPT_STATE_VERSION,
|
|
2087
|
+
file,
|
|
2088
|
+
dev: stat.dev,
|
|
2089
|
+
ino: stat.ino,
|
|
2090
|
+
offset: 0,
|
|
2091
|
+
pending: [],
|
|
2092
|
+
completed: {},
|
|
2093
|
+
agents: [],
|
|
2094
|
+
todos: [],
|
|
2095
|
+
todoCalls: [],
|
|
2096
|
+
skills: [],
|
|
2097
|
+
mcp: [],
|
|
2098
|
+
compactions: 0,
|
|
2099
|
+
};
|
|
2100
|
+
if (stat.size > state.offset || !unchanged) {
|
|
2101
|
+
state.offset = readLines(io, file, state.offset, stat.size, (line) => {
|
|
2102
|
+
// Only lines that can hold a tool call, a result, a prompt, a response
|
|
2103
|
+
// or a compaction are parsed.
|
|
2104
|
+
if (!['"tool_', '"user"', '"assistant"', '"compact_boundary"'].some((key) => line.includes(key)))
|
|
2105
|
+
return;
|
|
2106
|
+
try {
|
|
2107
|
+
applyRecord(state, JSON.parse(line));
|
|
2108
|
+
}
|
|
2109
|
+
catch {
|
|
2110
|
+
/* not a record: skip it */
|
|
2111
|
+
}
|
|
2112
|
+
});
|
|
2113
|
+
saveTranscriptState(io, stateFile, state);
|
|
2114
|
+
}
|
|
2115
|
+
const running = state.pending.filter((p) => !p.ended).map(({ name, target }) => ({ name, ...(target ? { target } : {}) }));
|
|
2116
|
+
const agents = state.agents.map(({ id, background, ...agent }) => agent);
|
|
2117
|
+
const todos = state.todos.map(({ id, ...todo }) => todo);
|
|
2118
|
+
const mcp = state.mcp.map((server) => ({ ...server }));
|
|
2119
|
+
const lastReplyAt = state.response?.endedAt;
|
|
2120
|
+
const speed = speedOf(state.response);
|
|
2121
|
+
return {
|
|
2122
|
+
tools: { running, completed: { ...state.completed } },
|
|
2123
|
+
agents,
|
|
2124
|
+
todos,
|
|
2125
|
+
skills: [...state.skills],
|
|
2126
|
+
mcp,
|
|
2127
|
+
compactions: state.compactions,
|
|
2128
|
+
...(lastReplyAt !== undefined ? { lastReplyAt } : {}),
|
|
2129
|
+
...(speed !== undefined ? { speed } : {}),
|
|
2130
|
+
};
|
|
2131
|
+
}
|
|
2132
|
+
// claude-opus-5-5 → Opus 5.5; claude-haiku-4-5-20251001 → Haiku 4.5.
|
|
2133
|
+
function modelName(id) {
|
|
2134
|
+
if (!id)
|
|
2135
|
+
return undefined;
|
|
2136
|
+
const parts = id.replace(/^claude-/, '').split('-').filter((p) => !/^\d{8}$/.test(p));
|
|
2137
|
+
const family = parts.shift();
|
|
2138
|
+
if (!family)
|
|
2139
|
+
return id;
|
|
2140
|
+
return [capitalised(family), parts.join('.')].filter(Boolean).join(' ');
|
|
2141
|
+
}
|
|
2142
|
+
const SCALES = { '': 1, k: 1e3, m: 1e6 };
|
|
2143
|
+
// --window when given (200k, 1m, 1000000); else 200k, or 1M once past it.
|
|
2144
|
+
function windowSize(tokens, explicit) {
|
|
2145
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*([km]?)\s*$/i.exec(String(explicit ?? ''));
|
|
2146
|
+
if (m)
|
|
2147
|
+
return Math.round(Number(m[1]) * SCALES[m[2].toLowerCase()]);
|
|
2148
|
+
return tokens > 200e3 ? 1e6 : 200e3;
|
|
2149
|
+
}
|
|
2150
|
+
// A status line payload rebuilt from transcript records.
|
|
2151
|
+
function payloadFromTranscript(records, { nowMs = Date.now(), window, usage } = {}) {
|
|
2152
|
+
const data = {};
|
|
2153
|
+
const cwd = [...records].reverse().find((r) => r.cwd)?.cwd;
|
|
2154
|
+
if (cwd)
|
|
2155
|
+
data.workspace = { current_dir: cwd };
|
|
2156
|
+
const last = records.filter((r) => r.type === 'assistant' && !r.isSidechain && r.message?.usage).at(-1);
|
|
2157
|
+
if (last) {
|
|
2158
|
+
const u = last.message?.usage ?? {};
|
|
2159
|
+
const tokens = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
|
|
2160
|
+
const size = windowSize(tokens, window);
|
|
2161
|
+
data.context_window = { context_window_size: size, used_percentage: (tokens * 100) / size, total_input_tokens: tokens };
|
|
2162
|
+
data.model = { display_name: modelName(last.message?.model) };
|
|
2163
|
+
const effort = typeof last.effort === 'string' ? last.effort : last.effort?.level;
|
|
2164
|
+
if (effort)
|
|
2165
|
+
data.effort = { level: effort };
|
|
2166
|
+
}
|
|
2167
|
+
const first = records.find((r) => r.timestamp)?.timestamp;
|
|
2168
|
+
if (first)
|
|
2169
|
+
data.cost = { total_duration_ms: nowMs - Date.parse(first) };
|
|
2170
|
+
if (usage)
|
|
2171
|
+
data.rate_limits = usage;
|
|
2172
|
+
return data;
|
|
2173
|
+
}
|
|
2174
|
+
// A remote URL as { host, owner, name }, the shape Claude Code sends:
|
|
2175
|
+
// git@github.com:jv-k/claude-gauge.git, https://github.com/jv-k/claude-gauge.
|
|
2176
|
+
function repoFromRemote(url) {
|
|
2177
|
+
const m = /^(?:[a-z][a-z0-9+.-]*:\/\/)?(?:[^@/]+@)?([^/:]+)(?::\d+)?[:/](.+)\/([^/]+?)(?:\.git)?\/?$/i.exec(url ?? '');
|
|
2178
|
+
return m ? { host: m[1], owner: m[2], name: m[3] } : undefined;
|
|
2179
|
+
}
|
|
2180
|
+
// The linked worktree's name, from its git dir: <common>/worktrees/<name>.
|
|
2181
|
+
function worktreeFromGitDir(gitDir) {
|
|
2182
|
+
return gitDir && path.basename(path.dirname(gitDir)) === 'worktrees' ? path.basename(gitDir) : undefined;
|
|
2183
|
+
}
|
|
2184
|
+
// What Claude Code's payload says about the repository, read from git, so
|
|
2185
|
+
// repo and branch show as they do in the terminal.
|
|
2186
|
+
function gitWorkspace(cwd) {
|
|
2187
|
+
const repo = repoFromRemote(git(cwd, ['remote', 'get-url', 'origin']));
|
|
2188
|
+
const worktree = worktreeFromGitDir(git(cwd, ['rev-parse', '--absolute-git-dir']));
|
|
2189
|
+
return { ...(repo ? { repo } : {}), ...(worktree ? { git_worktree: worktree } : {}) };
|
|
2190
|
+
}
|
|
2191
|
+
function readRecords(file) {
|
|
2192
|
+
const records = [];
|
|
2193
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
2194
|
+
if (!line)
|
|
2195
|
+
continue;
|
|
2196
|
+
try {
|
|
2197
|
+
records.push(JSON.parse(line));
|
|
2198
|
+
}
|
|
2199
|
+
catch {
|
|
2200
|
+
/* a partially flushed final line is expected */
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
return records;
|
|
2204
|
+
}
|
|
2205
|
+
// The calling session's transcript, found by the id Claude Code exports to
|
|
2206
|
+
// the commands it runs; else the newest transcript of this folder.
|
|
2207
|
+
function latestTranscript(cwd) {
|
|
2208
|
+
const projects = path.join(configDir(), 'projects');
|
|
2209
|
+
if (!fs.existsSync(projects))
|
|
2210
|
+
return null;
|
|
2211
|
+
const own = process.env.CLAUDE_CODE_SESSION_ID;
|
|
2212
|
+
if (own) {
|
|
2213
|
+
for (const d of fs.readdirSync(projects)) {
|
|
2214
|
+
const f = path.join(projects, d, `${own}.jsonl`);
|
|
2215
|
+
if (fs.existsSync(f))
|
|
2216
|
+
return f;
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
const dir = path.join(projects, cwd.replace(/[/.]/g, '-'));
|
|
2220
|
+
if (!fs.existsSync(dir))
|
|
2221
|
+
return null;
|
|
2222
|
+
const newest = fs
|
|
2223
|
+
.readdirSync(dir)
|
|
2224
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
2225
|
+
.map((f) => ({ f: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
|
|
2226
|
+
.sort((a, b) => b.mtime - a.mtime)[0];
|
|
2227
|
+
return newest ? newest.f : null;
|
|
2228
|
+
}
|
|
2229
|
+
// --instruct: the SessionStart hook for hosts that show no status line. In
|
|
2230
|
+
// the VS Code extension and the desktop app it prints an instruction that has
|
|
2231
|
+
// Claude end each reply with it. In the terminal CLI, where it already shows,
|
|
2232
|
+
// it prints nothing. Claude Code names the host in CLAUDE_CODE_ENTRYPOINT,
|
|
2233
|
+
// which hooks inherit; an unknown or missing value counts as a host that
|
|
2234
|
+
// needs nothing.
|
|
2235
|
+
const INSTRUCT_HOSTS = ['claude-vscode', 'claude-desktop', 'claude-desktop-3p'];
|
|
2236
|
+
exports.INSTRUCT_HOSTS = INSTRUCT_HOSTS;
|
|
2237
|
+
// A shell word: as is when plain, else in single quotes. ~ stays bare so the
|
|
2238
|
+
// shell expands a ~/ path.
|
|
2239
|
+
const shellWord = (s) => (/^[\w@%+=:,./~-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
|
|
2240
|
+
function instruction(argv, { host, script }) {
|
|
2241
|
+
if (host === undefined || !INSTRUCT_HOSTS.includes(host))
|
|
2242
|
+
return null;
|
|
2243
|
+
const command = ['node', script, '--latest', ...argv.filter((a) => a !== '--instruct')].map(shellWord).join(' ');
|
|
2244
|
+
return [
|
|
2245
|
+
'## Status line in replies',
|
|
2246
|
+
'',
|
|
2247
|
+
"End every reply with the claude-gauge status line, as a copyable code block, so this panel shows what the terminal's status line does:",
|
|
2248
|
+
'',
|
|
2249
|
+
'```sh',
|
|
2250
|
+
command,
|
|
2251
|
+
'```',
|
|
2252
|
+
'',
|
|
2253
|
+
"Run it as the last tool call of the turn, then paste its rows verbatim as the final thing in the reply, in one plain code block. `ctx` is the context the most recent request carried in. Skip it only if the command fails. Never guess the figures, and never reuse an earlier turn's rows.",
|
|
2254
|
+
'',
|
|
2255
|
+
].join('\n');
|
|
2256
|
+
}
|
|
2257
|
+
// This script's path as a hook command can name it: ~ for the home folder.
|
|
2258
|
+
const ownPath = () => process.argv[1].replace(new RegExp(`^${os.homedir()}(?=/)`), '~');
|
|
2259
|
+
const isMain = (typeof require !== 'undefined' && require.main === module) || (typeof Bun !== 'undefined' && Bun.main === __filename);
|
|
2260
|
+
if (isMain) {
|
|
2261
|
+
const argv = process.argv.slice(2);
|
|
2262
|
+
const config = parseArgs(argv);
|
|
2263
|
+
const nowMs = Date.now();
|
|
2264
|
+
if (argv.includes('--instruct')) {
|
|
2265
|
+
const text = instruction(argv, { host: process.env.CLAUDE_CODE_ENTRYPOINT, script: ownPath() });
|
|
2266
|
+
if (text)
|
|
2267
|
+
process.stdout.write(text);
|
|
2268
|
+
}
|
|
2269
|
+
else if (argv.includes('--latest')) {
|
|
2270
|
+
const at = argv.findIndex((a) => a === '--window' || a.startsWith('--window='));
|
|
2271
|
+
const window = at < 0 ? undefined : argv[at].includes('=') ? argv[at].split('=')[1] : argv[at + 1];
|
|
2272
|
+
const transcript = latestTranscript(process.cwd());
|
|
2273
|
+
const records = transcript ? readRecords(transcript) : [];
|
|
2274
|
+
const data = payloadFromTranscript(records, { nowMs, window, usage: loadUsage(nowMs) });
|
|
2275
|
+
const cwd = data.workspace?.current_dir;
|
|
2276
|
+
if (data.workspace && cwd)
|
|
2277
|
+
Object.assign(data.workspace, gitWorkspace(cwd));
|
|
2278
|
+
// Plain text: it is pasted into a reply, where colour codes and links
|
|
2279
|
+
// show as junk.
|
|
2280
|
+
process.stdout.write(stripOwnCodes(render(data, { config, nowMs, ledger: readLedger(ledgerFile()) })) + '\n');
|
|
2281
|
+
}
|
|
2282
|
+
else {
|
|
2283
|
+
const chunks = [];
|
|
2284
|
+
process.stdin.on('data', (c) => chunks.push(c));
|
|
2285
|
+
process.stdin.on('end', () => {
|
|
2286
|
+
let data = {};
|
|
2287
|
+
let parsed = false;
|
|
2288
|
+
try {
|
|
2289
|
+
data = JSON.parse(Buffer.concat(chunks).toString() || '{}');
|
|
2290
|
+
parsed = true;
|
|
2291
|
+
}
|
|
2292
|
+
catch {
|
|
2293
|
+
/* render what we can from an empty payload rather than print nothing */
|
|
2294
|
+
}
|
|
2295
|
+
saveUsage(data, nowMs);
|
|
2296
|
+
// Only a payload that holds something replaces the one the wizard
|
|
2297
|
+
// previews with.
|
|
2298
|
+
if (parsed && isObject(data) && Object.keys(data).length)
|
|
2299
|
+
savePayload(data);
|
|
2300
|
+
const ledger = recordCost(data, { nowMs });
|
|
2301
|
+
process.stdout.write(render(data, { config, nowMs, ledger, columns: columnsOf(process.env.COLUMNS) }) + '\n');
|
|
2302
|
+
});
|
|
2303
|
+
}
|
|
2304
|
+
}
|