sprag-cli 3.67.0 → 3.68.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.ko.md +29 -62
- package/README.md +26 -61
- package/bin/cli.js +122 -61
- package/docs/CODEX.ko.md +32 -0
- package/docs/CODEX.md +34 -0
- package/package.json +11 -4
- package/src/{codex-brief.js → codex/brief.js} +7 -7
- package/src/{codex-budget.js → codex/budget.js} +2 -2
- package/src/{codex-cache-policy.js → codex/cache-policy.js} +9 -9
- package/src/{codex-delegation-diagnostics.js → codex/delegation-diagnostics.js} +2 -2
- package/src/{codex-delegation.js → codex/delegation.js} +11 -11
- package/src/{codex-doc2md-ledger.js → codex/doc2md-ledger.js} +5 -5
- package/src/{codex-doc2md.js → codex/doc2md.js} +4 -4
- package/src/{codex-doctor.js → codex/doctor.js} +4 -4
- package/src/{codex-harness.js → codex/harness.js} +5 -5
- package/src/{codex-hooks.js → codex/hooks.js} +20 -20
- package/src/{codex-installer.js → codex/installer.js} +2 -2
- package/src/{codex-ledger.js → codex/ledger.js} +6 -6
- package/src/{codex-panel-launcher.js → codex/panel-launcher.js} +2 -2
- package/src/{codex-panel-runner.js → codex/panel-runner.js} +2 -2
- package/src/{codex-panel-session.js → codex/panel-session.js} +1 -1
- package/src/{codex-panel-setup.js → codex/panel-setup.js} +1 -1
- package/src/{codex-panel-state.js → codex/panel-state.js} +3 -3
- package/src/{codex-panel.js → codex/panel.js} +43 -34
- package/src/{codex-parser.js → codex/parser.js} +5 -9
- package/src/{codex-reply-language.js → codex/reply-language.js} +2 -2
- package/src/{codex-route-scan.js → codex/route-scan.js} +10 -10
- package/src/{codex-rule-health.js → codex/rule-health.js} +4 -4
- package/src/commands/codex-delegate.js +6 -6
- package/src/commands/codex.js +44 -44
- package/src/commands/install.js +10 -7
- package/src/commands/korean.js +1 -1
- package/src/commands/litellm-budget.js +56 -0
- package/src/commands/saved.js +60 -5
- package/src/commands/uninstall.js +3 -3
- package/src/config.js +4 -0
- package/src/formatters/statusline.js +713 -504
- package/src/glyphs.js +2 -0
- package/src/installer.js +77 -1
- package/src/model-alias.js +45 -60
- package/src/model-share.js +136 -0
- package/src/parser.js +4 -13
- package/src/session-records.js +2 -8
- package/src/statusline-base.js +9 -3
- package/src/stdin-payload.js +1 -1
- package/src/transcript-lines.js +40 -0
- /package/src/{codex-cache.js → codex/cache.js} +0 -0
- /package/src/{codex-panel-shell.js → codex/panel-shell.js} +0 -0
package/src/glyphs.js
CHANGED
|
@@ -53,6 +53,7 @@ const ICON = {
|
|
|
53
53
|
month: '💵',
|
|
54
54
|
ctx: '📦',
|
|
55
55
|
saved: '💰',
|
|
56
|
+
used: '📊',
|
|
56
57
|
reset: '🔄',
|
|
57
58
|
// Gauge cells. Separate ticks rather than one continuous block: the bar is a
|
|
58
59
|
// count of how much of a budget is gone, and ticks are countable where a solid
|
|
@@ -82,6 +83,7 @@ const NARROW = {
|
|
|
82
83
|
month: '∑', // a running total
|
|
83
84
|
ctx: '◧', // a box filling up
|
|
84
85
|
saved: '✓',
|
|
86
|
+
used: '◩', // a split square: what the spend is made of
|
|
85
87
|
reset: '↩', // the window turning over
|
|
86
88
|
// Same segmented shape as the icon set, in the two glyphs this font has.
|
|
87
89
|
// ▰▱ (U+25B0/25B1) are absent from JetBrains Mono; ■□ (U+25A0/25A1) are there.
|
package/src/installer.js
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
import { writeFileSync, mkdirSync, existsSync, unlinkSync, readFileSync, rmSync, copyFileSync, renameSync, realpathSync, statSync } from 'node:fs';
|
|
17
17
|
import { loadConfig, saveConfig } from './config.js';
|
|
18
18
|
import { CLI_NAME, LEGACY_CLI_NAME } from './cli-name.js';
|
|
19
|
-
import { join, dirname } from 'node:path';
|
|
19
|
+
import { join, dirname, resolve } from 'node:path';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
20
21
|
import { fileURLToPath } from 'node:url';
|
|
21
22
|
import { claudeUserDir, userDataDir } from './paths.js';
|
|
22
23
|
|
|
@@ -354,6 +355,65 @@ export function removeLegacyCommand() {
|
|
|
354
355
|
return { path: file, action: 'removed' };
|
|
355
356
|
}
|
|
356
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Whether a foreign statusLine command is a script that calls this tool, as
|
|
360
|
+
* opposed to someone else's statusline.
|
|
361
|
+
*
|
|
362
|
+
* Both look the same to isOurCommand(): the command is `bash ~/.claude/x.sh`,
|
|
363
|
+
* not `sprag ...`. The difference matters to the person reading the install
|
|
364
|
+
* output. "Existing statusLine command (bash ~/.claude/x.sh)" reads as another
|
|
365
|
+
* tool's slot, when the script is their own wrapper around `sprag --statusline`
|
|
366
|
+
* and replacing it with the direct command loses nothing. The usual reason such a
|
|
367
|
+
* wrapper exists is an `npx` fallback written before the package was renamed, and
|
|
368
|
+
* `npx claude-token-saver@latest` now resolves to the deprecated package, which
|
|
369
|
+
* stopped at 3.55.0: the line it draws is frozen there, quietly, while the
|
|
370
|
+
* statusline appears to work.
|
|
371
|
+
*
|
|
372
|
+
* Recognised commands are `bash <path>`, `sh <path>` and a bare path ending in
|
|
373
|
+
* `.sh`. The script must exist and its text must name this tool next to
|
|
374
|
+
* `--statusline`; a script that only mentions the tool (a comment, an unrelated
|
|
375
|
+
* `sprag` directory) is not a wrapper. Best-effort throughout: an unreadable or
|
|
376
|
+
* oversized file is simply "not a wrapper", and the caller falls back to the
|
|
377
|
+
* generic conflict message.
|
|
378
|
+
*
|
|
379
|
+
* @returns {{ script: string, name: string, deprecatedFallback: boolean } | null}
|
|
380
|
+
* `script` is the path as written in the command, for display.
|
|
381
|
+
*/
|
|
382
|
+
function detectStatuslineWrapper(command) {
|
|
383
|
+
if (typeof command !== 'string') return null;
|
|
384
|
+
const m = command.trim().match(/^(?:(?:bash|sh)\s+)?("[^"]+"|'[^']+'|\S+)(?:\s.*)?$/);
|
|
385
|
+
if (!m) return null;
|
|
386
|
+
const hasShell = /^(?:bash|sh)\s/.test(command.trim());
|
|
387
|
+
const script = m[1].replace(/^["']|["']$/g, '');
|
|
388
|
+
if (!hasShell && !script.endsWith('.sh')) return null;
|
|
389
|
+
// Settings commands spell the home directory three ways; the shell Claude Code
|
|
390
|
+
// runs them in would expand all of them.
|
|
391
|
+
const home = homedir();
|
|
392
|
+
const onDisk = resolve(script
|
|
393
|
+
.replace(/^~(?=$|[/\\])/, home)
|
|
394
|
+
.replace(/^\$\{?HOME\}?(?=$|[/\\])/, home));
|
|
395
|
+
let body;
|
|
396
|
+
try {
|
|
397
|
+
const st = statSync(onDisk);
|
|
398
|
+
// A statusline wrapper is a few lines; anything this large is not one.
|
|
399
|
+
if (!st.isFile() || st.size > 256 * 1024) return null;
|
|
400
|
+
body = readFileSync(onDisk, 'utf8');
|
|
401
|
+
} catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
if (!body.includes('--statusline')) return null;
|
|
405
|
+
const nameAt = (text) => {
|
|
406
|
+
const k = text.match(new RegExp(`${LEGACY_CLI}|${CLI}`));
|
|
407
|
+
return k ? k[0] : null;
|
|
408
|
+
};
|
|
409
|
+
// The name on the `--statusline` line is the one being called; failing that,
|
|
410
|
+
// whichever comes first (the command may sit in a variable set elsewhere).
|
|
411
|
+
const callLine = body.split('\n').find((l) => l.includes('--statusline') && nameAt(l));
|
|
412
|
+
const name = nameAt(callLine ?? body);
|
|
413
|
+
if (!name) return null;
|
|
414
|
+
return { script, name, deprecatedFallback: body.includes(`${LEGACY_CLI}@latest`) };
|
|
415
|
+
}
|
|
416
|
+
|
|
357
417
|
// Registers/repairs the Claude Code statusLine entry in ~/.claude/settings.json.
|
|
358
418
|
// - No statusLine yet: insert ours with refreshInterval:STATUSLINE_REFRESH_INTERVAL.
|
|
359
419
|
// - statusLine already points at sprag: ensure that refreshInterval
|
|
@@ -402,6 +462,22 @@ export function installStatusline({ force = false, merge = false } = {}) {
|
|
|
402
462
|
// `conflict` lets the interactive install distinguish "someone else's
|
|
403
463
|
// statusline is here" (worth asking about) from other skip reasons
|
|
404
464
|
// (unreadable JSON), which a prompt cannot fix.
|
|
465
|
+
const wrapper = detectStatuslineWrapper(cur.command);
|
|
466
|
+
if (wrapper) {
|
|
467
|
+
// Still a conflict, for the same reason: the entry is not ours to rewrite
|
|
468
|
+
// unasked. Only the wording changes, to say what the entry is.
|
|
469
|
+
const fallback = wrapper.deprecatedFallback
|
|
470
|
+
? ` (its npx fallback resolves to the deprecated ${LEGACY_CLI} package, last published as 3.55.0)`
|
|
471
|
+
: '';
|
|
472
|
+
return {
|
|
473
|
+
path: file,
|
|
474
|
+
action: 'skipped',
|
|
475
|
+
conflict: true,
|
|
476
|
+
wrapper: true,
|
|
477
|
+
existingCommand: cur.command,
|
|
478
|
+
reason: `existing statusLine wrapper script (${wrapper.script}) calls ${wrapper.name}; re-run with --force to replace it with \`${STATUSLINE_COMMAND}\`${fallback}`,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
405
481
|
return { path: file, action: 'skipped', conflict: true, existingCommand: cur.command, reason: `existing statusLine command (${cur.command}) — re-run with --force to overwrite` };
|
|
406
482
|
}
|
|
407
483
|
const base = cur;
|
package/src/model-alias.js
CHANGED
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
* package is published to npm, so neither may live in the source.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
import { readFileSync, mkdirSync, existsSync,
|
|
39
|
+
import { readFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
|
|
40
40
|
import { readFile, readdir } from 'node:fs/promises';
|
|
41
|
-
import {
|
|
41
|
+
import { transcriptLines } from './transcript-lines.js';
|
|
42
42
|
import { join, dirname, basename } from 'node:path';
|
|
43
43
|
import { userDataDir, claudeUserDir } from './paths.js';
|
|
44
44
|
import { gatewayBase } from './gateway-auth.js';
|
|
@@ -416,49 +416,41 @@ export function voteRole(requested, declared, parentDeclared) {
|
|
|
416
416
|
async function scanMainTranscript(path, votes, requestedByToolUse, agentTypeCache) {
|
|
417
417
|
let sawGateway = false;
|
|
418
418
|
let declared = null;
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
if (
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
if (pid) {
|
|
443
|
-
sawGateway = true;
|
|
444
|
-
// Circumstantial: a subagent's records also land in the parent file
|
|
445
|
-
// without the flag often enough to outvote real evidence.
|
|
446
|
-
addVote(votes, pid, 'main', 'inferred');
|
|
447
|
-
}
|
|
419
|
+
for await (const line of transcriptLines(path)) {
|
|
420
|
+
if (!line.includes('arn:aws:bedrock:') && !line.includes('"Task"') && !line.includes('"Agent"')
|
|
421
|
+
&& !line.includes('"identity"')) {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
let entry;
|
|
425
|
+
try {
|
|
426
|
+
entry = JSON.parse(line);
|
|
427
|
+
} catch {
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (!declared && entry.isSidechain !== true) declared = declaredModelOf(entry);
|
|
431
|
+
const msg = entry.message;
|
|
432
|
+
if (!msg) continue;
|
|
433
|
+
|
|
434
|
+
// The session's own model: the parent side of the transcript.
|
|
435
|
+
if (msg.model && entry.isSidechain !== true) {
|
|
436
|
+
const pid = profileIdFrom(msg.model);
|
|
437
|
+
if (pid) {
|
|
438
|
+
sawGateway = true;
|
|
439
|
+
// Circumstantial: a subagent's records also land in the parent file
|
|
440
|
+
// without the flag often enough to outvote real evidence.
|
|
441
|
+
addVote(votes, pid, 'main', 'inferred');
|
|
448
442
|
}
|
|
443
|
+
}
|
|
449
444
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
}
|
|
445
|
+
if (!Array.isArray(msg.content)) continue;
|
|
446
|
+
for (const block of msg.content) {
|
|
447
|
+
if (!block || block.type !== 'tool_use') continue;
|
|
448
|
+
if (block.name !== 'Task' && block.name !== 'Agent') continue;
|
|
449
|
+
const input = block.input || {};
|
|
450
|
+
const role = roleFromModelParam(input.model)
|
|
451
|
+
|| roleFromAgentType(input.subagent_type, agentTypeCache);
|
|
452
|
+
if (block.id && role) requestedByToolUse.set(block.id, role);
|
|
459
453
|
}
|
|
460
|
-
} finally {
|
|
461
|
-
rl.close();
|
|
462
454
|
}
|
|
463
455
|
return { sawGateway, declared };
|
|
464
456
|
}
|
|
@@ -469,25 +461,18 @@ async function scanMainTranscript(path, votes, requestedByToolUse, agentTypeCach
|
|
|
469
461
|
*/
|
|
470
462
|
async function subagentModelInfo(path) {
|
|
471
463
|
let declared = null;
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
try {
|
|
481
|
-
entry = JSON.parse(line);
|
|
482
|
-
} catch {
|
|
483
|
-
continue;
|
|
484
|
-
}
|
|
485
|
-
declared ??= declaredModelOf(entry);
|
|
486
|
-
const pid = profileIdFrom(entry.message?.model);
|
|
487
|
-
if (pid) return { pid, declared };
|
|
464
|
+
// transcriptLines() closes the file on the early return below; see its header.
|
|
465
|
+
for await (const line of transcriptLines(path)) {
|
|
466
|
+
if (!line.includes('arn:aws:bedrock:') && !line.includes('"identity"')) continue;
|
|
467
|
+
let entry;
|
|
468
|
+
try {
|
|
469
|
+
entry = JSON.parse(line);
|
|
470
|
+
} catch {
|
|
471
|
+
continue;
|
|
488
472
|
}
|
|
489
|
-
|
|
490
|
-
|
|
473
|
+
declared ??= declaredModelOf(entry);
|
|
474
|
+
const pid = profileIdFrom(entry.message?.model);
|
|
475
|
+
if (pid) return { pid, declared };
|
|
491
476
|
}
|
|
492
477
|
return { pid: null, declared };
|
|
493
478
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model share: how the spend since the routing counter's mark splits across
|
|
3
|
+
* model families, by cost. Feeds the statusline's bar and `sprag saved`'s table.
|
|
4
|
+
*
|
|
5
|
+
* "Same period" as `sprag saved`: the routing reset mark, or the start of the
|
|
6
|
+
* calendar month when nothing was ever reset, capped at 90 days of parsing.
|
|
7
|
+
*/
|
|
8
|
+
import { pricedParts, sessionCost } from './claude-price.js';
|
|
9
|
+
import { modelFamily } from './savings-ledger.js';
|
|
10
|
+
import { readResetMarks } from './saved-reset.cjs';
|
|
11
|
+
import { RESET, RED, GREEN, YELLOW, CYAN, MAGENTA, GRAY } from './formatters/statusline.js';
|
|
12
|
+
|
|
13
|
+
const DAY = 86400000;
|
|
14
|
+
export const MAX_DAYS = 90;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Split `slots` whole units across `values` in proportion, largest remainder
|
|
18
|
+
* first, so the parts always add up to exactly `slots`. Ties keep input order.
|
|
19
|
+
*/
|
|
20
|
+
export function roundShares(values, slots) {
|
|
21
|
+
const vs = (values || []).map((v) => (Number.isFinite(v) && v > 0 ? v : 0));
|
|
22
|
+
const total = vs.reduce((a, b) => a + b, 0);
|
|
23
|
+
if (total <= 0 || !(slots > 0)) return vs.map(() => 0);
|
|
24
|
+
const exact = vs.map((v) => (v / total) * slots);
|
|
25
|
+
const out = exact.map((x) => Math.floor(x + 1e-9));
|
|
26
|
+
let left = slots - out.reduce((a, b) => a + b, 0);
|
|
27
|
+
const order = exact
|
|
28
|
+
.map((x, i) => ({ i, frac: x - Math.floor(x + 1e-9) }))
|
|
29
|
+
.sort((a, b) => b.frac - a.frac || a.i - b.i);
|
|
30
|
+
for (let k = 0; left > 0 && order.length; k = (k + 1) % order.length, left--) out[order[k].i] += 1;
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function rank(families) {
|
|
35
|
+
return families
|
|
36
|
+
.map((f, i) => ({ ...f, i }))
|
|
37
|
+
.sort((a, b) => b.usd - a.usd || a.i - b.i);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toneOf(family) {
|
|
41
|
+
switch (family) {
|
|
42
|
+
case 'opus': return MAGENTA;
|
|
43
|
+
case 'sonnet': return CYAN;
|
|
44
|
+
case 'haiku': return GREEN;
|
|
45
|
+
case 'fable': return YELLOW;
|
|
46
|
+
case 'mythos': return RED;
|
|
47
|
+
default: return GRAY;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const FAMILY_NAMES = { opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku', fable: 'Fable', mythos: 'Mythos' };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Statusline legend, `● Opus 90% ● Sonnet 10%`: a dot in the family's colour,
|
|
55
|
+
* the name, and a gray whole percent. Reads without a key, which the bar and
|
|
56
|
+
* letters did not. Without colour the dot carries nothing, so it is dropped.
|
|
57
|
+
* A family that spent money but rounds to 0% shows as `<1%`: dropping it made
|
|
58
|
+
* a model that had just been delegated to look unused.
|
|
59
|
+
*/
|
|
60
|
+
export function shareLegend(families, { color = true } = {}) {
|
|
61
|
+
const ranked = rank(families || []);
|
|
62
|
+
const pcts = roundShares(ranked.map((f) => f.usd), 100);
|
|
63
|
+
return ranked
|
|
64
|
+
.map((f, idx) => {
|
|
65
|
+
if (!(f.usd > 0)) return null;
|
|
66
|
+
const name = FAMILY_NAMES[f.family] || f.family;
|
|
67
|
+
const pct = pcts[idx] > 0 ? `${pcts[idx]}%` : '<1%';
|
|
68
|
+
return color
|
|
69
|
+
? `${toneOf(f.family)}●${RESET} ${name} ${GRAY}${pct}${RESET}`
|
|
70
|
+
: `${name} ${pct}`;
|
|
71
|
+
})
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.join(' ');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Cost per family over the given sessions, subagent runs split out. Pure. */
|
|
77
|
+
export function computeModelShare(sessions, { env = process.env } = {}) {
|
|
78
|
+
const all = new Map();
|
|
79
|
+
const sub = new Map();
|
|
80
|
+
let unpriced = 0;
|
|
81
|
+
let totalUsd = 0;
|
|
82
|
+
let subUsd = 0;
|
|
83
|
+
for (const s of sessions || []) {
|
|
84
|
+
for (const part of pricedParts(s)) {
|
|
85
|
+
if (!part || !part.totals) continue;
|
|
86
|
+
let cost = null;
|
|
87
|
+
try { cost = sessionCost(part.totals, part.model, { env }); } catch { cost = null; }
|
|
88
|
+
if (!cost || !Number.isFinite(cost.actual)) { unpriced++; continue; }
|
|
89
|
+
const family = modelFamily(part.model);
|
|
90
|
+
all.set(family, (all.get(family) || 0) + cost.actual);
|
|
91
|
+
totalUsd += cost.actual;
|
|
92
|
+
if (!part.own) {
|
|
93
|
+
sub.set(family, (sub.get(family) || 0) + cost.actual);
|
|
94
|
+
subUsd += cost.actual;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const list = (m) => [...m.entries()]
|
|
99
|
+
.map(([family, usd]) => ({ family, usd, pct: totalUsd > 0 ? (usd / totalUsd) * 100 : 0 }))
|
|
100
|
+
.sort((a, b) => b.usd - a.usd);
|
|
101
|
+
if (!(totalUsd > 0)) {
|
|
102
|
+
return { totalUsd: 0, unpriced, families: [], subagent: { usd: 0, pct: 0, families: [] } };
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
totalUsd,
|
|
106
|
+
unpriced,
|
|
107
|
+
families: list(all),
|
|
108
|
+
subagent: { usd: subUsd, pct: (subUsd / totalUsd) * 100, families: list(sub) },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Collect the sessions for the routing counter's period and compute the share.
|
|
114
|
+
* `monthSessions` (already parsed by the statusline) is reused when the period
|
|
115
|
+
* starts inside the month; otherwise, or when absent, the transcripts are parsed.
|
|
116
|
+
*/
|
|
117
|
+
export async function loadModelShare({ dir, env = process.env, projectFilter, now = Date.now(), monthSessions } = {}) {
|
|
118
|
+
const { parseAllSessions, sessionsSince } = await import('./parser.js');
|
|
119
|
+
const { monthStartMs } = await import('./month-spend.js');
|
|
120
|
+
const reset = readResetMarks(dir).routing;
|
|
121
|
+
const monthStart = monthStartMs(new Date(now));
|
|
122
|
+
let since = Number.isFinite(reset) && reset > 0 ? reset : monthStart;
|
|
123
|
+
let sinceKind = since === monthStart && !(Number.isFinite(reset) && reset > 0) ? 'month' : 'reset';
|
|
124
|
+
if (now - since > MAX_DAYS * DAY) {
|
|
125
|
+
since = now - MAX_DAYS * DAY;
|
|
126
|
+
sinceKind = 'capped';
|
|
127
|
+
}
|
|
128
|
+
let sessions;
|
|
129
|
+
if (monthSessions && since >= monthStart) {
|
|
130
|
+
sessions = await sessionsSince(monthSessions, since);
|
|
131
|
+
} else {
|
|
132
|
+
const days = Math.max(1, Math.ceil((now - since) / DAY));
|
|
133
|
+
sessions = await sessionsSince(await parseAllSessions({ days, projectFilter, sinceMs: since }), since);
|
|
134
|
+
}
|
|
135
|
+
return { ...computeModelShare(sessions, { env }), since, sinceKind };
|
|
136
|
+
}
|
package/src/parser.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { openSync, readSync, closeSync, fstatSync } from 'node:fs';
|
|
2
2
|
import { readdir, stat } from 'node:fs/promises';
|
|
3
|
-
import {
|
|
3
|
+
import { transcriptLines } from './transcript-lines.js';
|
|
4
4
|
import { join, isAbsolute, basename } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { loadCache, getCached, putCached, saveCache, getCachedSince, putCachedSince } from './session-cache.js';
|
|
@@ -33,12 +33,7 @@ export async function parseSessionFile(filePath) {
|
|
|
33
33
|
let lastTimestamp = null;
|
|
34
34
|
let gatewayObserved = false;
|
|
35
35
|
|
|
36
|
-
const
|
|
37
|
-
input: createReadStream(filePath, { encoding: 'utf8' }),
|
|
38
|
-
crlfDelay: Infinity,
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
for await (const line of rl) {
|
|
36
|
+
for await (const line of transcriptLines(filePath)) {
|
|
42
37
|
let entry;
|
|
43
38
|
try {
|
|
44
39
|
entry = JSON.parse(line);
|
|
@@ -277,11 +272,7 @@ export async function getLastUserMessageTime(filePath) {
|
|
|
277
272
|
const tail = lastUserTsFromTail(filePath);
|
|
278
273
|
if (tail.found) return tail.ts ? new Date(tail.ts) : null;
|
|
279
274
|
try {
|
|
280
|
-
const
|
|
281
|
-
input: createReadStream(filePath, { encoding: 'utf8' }),
|
|
282
|
-
crlfDelay: Infinity,
|
|
283
|
-
});
|
|
284
|
-
for await (const line of rl) {
|
|
275
|
+
for await (const line of transcriptLines(filePath)) {
|
|
285
276
|
if (!line) continue;
|
|
286
277
|
try {
|
|
287
278
|
const entry = JSON.parse(line);
|
package/src/session-records.js
CHANGED
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
* requestId (last-write-wins, matching parser.js).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
import { createInterface } from 'node:readline';
|
|
10
|
+
import { transcriptLines } from './transcript-lines.js';
|
|
12
11
|
import { resolveModelAlias } from './model-alias.js';
|
|
13
12
|
|
|
14
13
|
/**
|
|
@@ -94,12 +93,7 @@ export async function collectSessionRecords(filePath, { includeContent = true }
|
|
|
94
93
|
let lastCwd = '';
|
|
95
94
|
let lastRecord = null;
|
|
96
95
|
|
|
97
|
-
const
|
|
98
|
-
input: createReadStream(filePath, { encoding: 'utf8' }),
|
|
99
|
-
crlfDelay: Infinity,
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
for await (const line of rl) {
|
|
96
|
+
for await (const line of transcriptLines(filePath)) {
|
|
103
97
|
let entry;
|
|
104
98
|
try {
|
|
105
99
|
entry = JSON.parse(line);
|
package/src/statusline-base.js
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
|
|
10
10
|
import { spawnSync } from 'node:child_process';
|
|
11
11
|
import { loadConfig } from './config.js';
|
|
12
|
-
import { readStdinRaw } from './stdin-payload.js';
|
|
13
12
|
|
|
14
13
|
// A light name check instead of importing installer.js (heavy for a hot path).
|
|
15
14
|
// If the saved base were ourselves, running it would recurse without end.
|
|
@@ -19,12 +18,19 @@ const OURS = /(^|[\s/\\])(sprag|claude-token-saver)(-cli)?(\.cmd|\.exe|\.ps1)?(\
|
|
|
19
18
|
// that inner run must not start the base again, so the child carries a marker.
|
|
20
19
|
const NESTED = 'SPRAG_STATUSLINE_BASE';
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* `input` is the payload Claude Code wrote on stdin, forwarded to the base
|
|
23
|
+
* verbatim; the caller passes the text it already read. This function must not
|
|
24
|
+
* read fd 0 itself: the statusline has drained it by now, and a process whose
|
|
25
|
+
* stdin is still open (the test runner on Windows keeps the pipe to each test
|
|
26
|
+
* file open) blocks in readFileSync(0) with no timeout at all.
|
|
27
|
+
*/
|
|
28
|
+
export function withBaseStatusline(output, { input = '' } = {}) {
|
|
23
29
|
if (process.env[NESTED] === '1') return output;
|
|
24
30
|
try {
|
|
25
31
|
const cmd = loadConfig().statuslineBase?.command;
|
|
26
32
|
if (typeof cmd !== 'string' || !cmd.trim() || OURS.test(cmd)) return output;
|
|
27
|
-
const opts = { input
|
|
33
|
+
const opts = { input, encoding: 'utf8', timeout: 1500, env: { ...process.env, [NESTED]: '1' } };
|
|
28
34
|
const r = process.platform === 'win32'
|
|
29
35
|
? spawnSync(cmd, { ...opts, shell: true })
|
|
30
36
|
: spawnSync('/bin/sh', ['-c', cmd], opts);
|
package/src/stdin-payload.js
CHANGED
|
@@ -109,7 +109,7 @@ export function bedrockDisplayFromId(id) {
|
|
|
109
109
|
// The minor version is one or two digits. An id with no minor goes straight
|
|
110
110
|
// to its date stamp (`claude-opus-4-20250514`), and reading that stamp as the
|
|
111
111
|
// minor printed "Opus 4.20250514".
|
|
112
|
-
const m = id.match(/claude[-_](opus|sonnet|haiku)[-_](\d+)(?:[-_](\d{1,2})(?!\d))?/i);
|
|
112
|
+
const m = id.match(/claude[-_](opus|sonnet|haiku|fable|mythos)[-_](\d+)(?:[-_](\d{1,2})(?!\d))?/i);
|
|
113
113
|
if (!m) return null;
|
|
114
114
|
const family = m[1].charAt(0).toUpperCase() + m[1].slice(1).toLowerCase();
|
|
115
115
|
return m[3] === undefined ? `${family} ${m[2]}` : `${family} ${m[2]}.${m[3]}`;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lines of a transcript file, with the file closed before the loop is over.
|
|
3
|
+
*
|
|
4
|
+
* Every transcript reader used to build its own readline over createReadStream.
|
|
5
|
+
* That works until a loop leaves early: `return` inside `for await` closes the
|
|
6
|
+
* readline interface, but on Node 20 the ReadStream underneath keeps its
|
|
7
|
+
* descriptor until garbage collection. And even a loop that runs to the end is
|
|
8
|
+
* not done when it ends: 'end' fires before the asynchronous fs.close() has
|
|
9
|
+
* run, so a caller that deletes the directory right away still finds the file
|
|
10
|
+
* open. POSIX does not mind; Windows answers ENOTEMPTY, which is how the
|
|
11
|
+
* Windows CI job failed on Node 20 alone.
|
|
12
|
+
*
|
|
13
|
+
* A generator's finally block runs on `break`, `return` and throw alike, so
|
|
14
|
+
* the stream is destroyed on every exit, and the 'close' event is awaited so
|
|
15
|
+
* the descriptor is gone when the loop is.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createReadStream } from 'node:fs';
|
|
19
|
+
import { createInterface } from 'node:readline';
|
|
20
|
+
import { once } from 'node:events';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} filePath
|
|
24
|
+
* @returns {AsyncGenerator<string>}
|
|
25
|
+
*/
|
|
26
|
+
export async function* transcriptLines(filePath) {
|
|
27
|
+
const input = createReadStream(filePath, { encoding: 'utf8' });
|
|
28
|
+
const rl = createInterface({ input, crlfDelay: Infinity });
|
|
29
|
+
try {
|
|
30
|
+
for await (const line of rl) yield line;
|
|
31
|
+
} finally {
|
|
32
|
+
rl.close();
|
|
33
|
+
input.destroy();
|
|
34
|
+
// A stream that failed to open has already closed itself; one that was
|
|
35
|
+
// still open closes asynchronously, and the wait is what makes the file
|
|
36
|
+
// deletable the moment the caller gets control back. 'error' after
|
|
37
|
+
// destroy() is not something to surface here: the loop already threw.
|
|
38
|
+
if (!input.closed) await once(input, 'close').catch(() => {});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
File without changes
|
|
File without changes
|