sprag-cli 3.67.0 → 3.69.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.
Files changed (54) hide show
  1. package/README.ko.md +33 -66
  2. package/README.md +41 -74
  3. package/bin/cli.js +150 -62
  4. package/docs/CODEX.ko.md +32 -0
  5. package/docs/CODEX.md +34 -0
  6. package/package.json +15 -4
  7. package/src/{codex-brief.js → codex/brief.js} +7 -7
  8. package/src/{codex-budget.js → codex/budget.js} +2 -2
  9. package/src/{codex-cache-policy.js → codex/cache-policy.js} +9 -9
  10. package/src/{codex-delegation-diagnostics.js → codex/delegation-diagnostics.js} +2 -2
  11. package/src/{codex-delegation.js → codex/delegation.js} +11 -11
  12. package/src/{codex-doc2md-ledger.js → codex/doc2md-ledger.js} +5 -5
  13. package/src/{codex-doc2md.js → codex/doc2md.js} +4 -4
  14. package/src/{codex-doctor.js → codex/doctor.js} +4 -4
  15. package/src/{codex-harness.js → codex/harness.js} +5 -5
  16. package/src/{codex-hooks.js → codex/hooks.js} +20 -20
  17. package/src/{codex-installer.js → codex/installer.js} +2 -2
  18. package/src/{codex-ledger.js → codex/ledger.js} +6 -6
  19. package/src/{codex-panel-launcher.js → codex/panel-launcher.js} +2 -2
  20. package/src/{codex-panel-runner.js → codex/panel-runner.js} +2 -2
  21. package/src/{codex-panel-session.js → codex/panel-session.js} +1 -1
  22. package/src/{codex-panel-setup.js → codex/panel-setup.js} +1 -1
  23. package/src/{codex-panel-state.js → codex/panel-state.js} +3 -3
  24. package/src/{codex-panel.js → codex/panel.js} +43 -34
  25. package/src/{codex-parser.js → codex/parser.js} +5 -9
  26. package/src/{codex-reply-language.js → codex/reply-language.js} +2 -2
  27. package/src/{codex-route-scan.js → codex/route-scan.js} +10 -10
  28. package/src/{codex-rule-health.js → codex/rule-health.js} +4 -4
  29. package/src/commands/codex-delegate.js +6 -6
  30. package/src/commands/codex.js +44 -44
  31. package/src/commands/install.js +10 -7
  32. package/src/commands/korean.js +1 -1
  33. package/src/commands/litellm-budget.js +56 -0
  34. package/src/commands/saved.js +60 -5
  35. package/src/commands/uninstall.js +3 -3
  36. package/src/config.js +4 -0
  37. package/src/delegation-agents.js +13 -0
  38. package/src/first-run-note.js +16 -0
  39. package/src/formatters/statusline.js +725 -504
  40. package/src/formatters/table.js +33 -2
  41. package/src/glyphs.js +2 -0
  42. package/src/installer.js +77 -1
  43. package/src/model-alias.js +45 -60
  44. package/src/model-rules.js +25 -4
  45. package/src/model-share.js +136 -0
  46. package/src/parser.js +4 -13
  47. package/src/session-records.js +2 -8
  48. package/src/star-note.js +62 -0
  49. package/src/statusline-base.js +9 -3
  50. package/src/stdin-payload.js +1 -1
  51. package/src/transcript-lines.js +40 -0
  52. package/src/changelog.js +0 -70
  53. /package/src/{codex-cache.js → codex/cache.js} +0 -0
  54. /package/src/{codex-panel-shell.js → codex/panel-shell.js} +0 -0
@@ -181,17 +181,48 @@ function renderCapWarnSection(caps) {
181
181
  return lines;
182
182
  }
183
183
 
184
- export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow, caps }, { color } = {}) {
184
+ // Opens the report with what the tool is for, before any warning. A first run
185
+ // (`npx sprag-cli`) has nothing installed yet, so the pitch has to be on screen.
186
+ function renderRoutingSection(routing) {
187
+ if (!routing || typeof routing !== 'object') return [];
188
+ const lines = [];
189
+ lines.push(b(` 🔀 Routing — last ${routing.days}d`));
190
+ lines.push(` ${'─'.repeat(50)}`);
191
+ const total = Number(routing.total) || 0;
192
+ if (total === 0) {
193
+ lines.push(` No Claude Code sessions in the last ${routing.days} days to scan.`);
194
+ lines.push('');
195
+ return lines;
196
+ }
197
+ const easy = Number(routing.easy) || 0;
198
+ const pct = Math.round(easy / total * 100);
199
+ lines.push(` ${easy} of ${total} requests (${pct}%) were lookups, checks or test runs a cheaper model handles.`);
200
+ const cands = Array.isArray(routing.candidates) ? routing.candidates : [];
201
+ if (cands.length === 0) {
202
+ lines.push(' No recurring delegable work yet (a pattern needs 3 repeats). Approved rules apply from the next session.');
203
+ } else {
204
+ for (const c of cands) {
205
+ lines.push(` R${c.id} "${c.labelEn || c.label}" ×${c.count} → delegate to ${c.agent}`);
206
+ }
207
+ lines.push(` Approve one: ${b(`${CLI_NAME} harness promote R${cands[0].id} --global`)} All candidates: ${b(`${CLI_NAME} route-scan`)}`);
208
+ }
209
+ lines.push('');
210
+ return lines;
211
+ }
212
+
213
+ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow, caps, routing }, { color } = {}) {
185
214
  colorOk = color ?? !process.env.NO_COLOR;
186
215
  const lines = [];
187
216
 
188
217
  // Header
189
218
  lines.push('');
190
- lines.push(` Claude Token Saver — Last ${options.days} day${options.days === 1 ? '' : 's'}`);
219
+ lines.push(` Sprag — Last ${options.days} day${options.days === 1 ? '' : 's'}`);
191
220
  lines.push(` (${CLI_NAME} v${options.version || ''})`.trimEnd());
192
221
  lines.push(` ${'═'.repeat(50)}`);
193
222
  lines.push('');
194
223
 
224
+ lines.push(...renderRoutingSection(routing));
225
+
195
226
  // Cap warning leads — it's the most time-sensitive signal we can show.
196
227
  if (caps) {
197
228
  lines.push(...renderCapWarnSection(caps));
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;
@@ -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, createReadStream, statSync } from 'node:fs';
39
+ import { readFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
40
40
  import { readFile, readdir } from 'node:fs/promises';
41
- import { createInterface } from 'node:readline';
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 rl = createInterface({
420
- input: createReadStream(path, { encoding: 'utf8' }),
421
- crlfDelay: Infinity,
422
- });
423
- try {
424
- for await (const line of rl) {
425
- if (!line.includes('arn:aws:bedrock:') && !line.includes('"Task"') && !line.includes('"Agent"')
426
- && !line.includes('"identity"')) {
427
- continue;
428
- }
429
- let entry;
430
- try {
431
- entry = JSON.parse(line);
432
- } catch {
433
- continue;
434
- }
435
- if (!declared && entry.isSidechain !== true) declared = declaredModelOf(entry);
436
- const msg = entry.message;
437
- if (!msg) continue;
438
-
439
- // The session's own model: the parent side of the transcript.
440
- if (msg.model && entry.isSidechain !== true) {
441
- const pid = profileIdFrom(msg.model);
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
- if (!Array.isArray(msg.content)) continue;
451
- for (const block of msg.content) {
452
- if (!block || block.type !== 'tool_use') continue;
453
- if (block.name !== 'Task' && block.name !== 'Agent') continue;
454
- const input = block.input || {};
455
- const role = roleFromModelParam(input.model)
456
- || roleFromAgentType(input.subagent_type, agentTypeCache);
457
- if (block.id && role) requestedByToolUse.set(block.id, role);
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
- const rl = createInterface({
473
- input: createReadStream(path, { encoding: 'utf8' }),
474
- crlfDelay: Infinity,
475
- });
476
- try {
477
- for await (const line of rl) {
478
- if (!line.includes('arn:aws:bedrock:') && !line.includes('"identity"')) continue;
479
- let entry;
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
- } finally {
490
- rl.close();
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
  }
@@ -272,13 +272,23 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
272
272
  ' 알려 주는 작업은 하위 티어가 안전하다. 검증 게이트가 품질 하한이 된다.',
273
273
  ' 틀려도 조용한 작업(진단, 근본 원인 판정, 보안, 설계)은 티어를 내리지',
274
274
  ' 않는다. 싼 모델은 결정을 옮겨 적을 뿐, 결정을 내리지 못한다.',
275
+ '- **effort**: 모델 티어와 다른 축이다. 설치된 서브에이전트는 검증 형태에 따라',
276
+ ' effort가 고정되어 있다. 명령 출력이나 파일 대조로 검증되는 위임(조회·명령',
277
+ ' 실행·정형 변환을 맡는 haiku 에이전트)은 low, 확정된 설계를 테스트에 맞춰',
278
+ ' 구현하는 위임(sonnet 워커)은 medium이다. 메인 세션의 effort는 바꾸지 않고,',
279
+ ' CLAUDE_CODE_EFFORT_LEVEL은 서브에이전트의 effort를 덮어쓰므로 설정하지 않는다.',
275
280
  '- **위임 배치**: 서브에이전트 비용의 지배 항은 생성이 아니라 컨텍스트',
276
281
  ' 적재다. 같은 파일들을 읽어야 하는 작업 여러 개는 에이전트 하나에 묶어',
277
282
  ' 보내고, 읽는 영역이 다른 작업만 나눈다.',
278
283
  implementationDelegationRule('claude', lang),
279
284
  '- **에스컬레이션**: 같은 에러 2회 반복, 답이 복수 후보로 갈림, 서브에이전트가',
280
- ' 질문을 멈추고 헛도는 징후가 보이면 즉시 인계한다. 대응 순서는',
281
- ' ① 재지시 후 재시도 → ② 한 티어 상향 → ③ 메인 모델이 직접 수습.',
285
+ ' 질문을 멈추고 헛도는 징후가 보이면 즉시 인계한다. 어느 축을 올릴지는 실패',
286
+ ' 종류로 정한다. 파일을 읽지 않았거나 테스트를 돌리지 않았거나 상한에 걸려',
287
+ ' 멈춘 누락형 실패는 같은 모델에서 effort를 한 단계 올려 한 번만 재시도하고,',
288
+ ' 컨텍스트를 다 보고도 틀린 오판형 실패는 모델을 한 티어 올린다. 순서는',
289
+ ' ① 재지시 후 재시도(누락형이면 effort 한 단계 위) → ② 한 티어 상향(오판형)',
290
+ ' → ③ 메인 모델이 직접 수습. effort를 올릴 때는 에이전트 파일을 고치지 않고',
291
+ ' Agent 도구 호출의 effort 인자에 한 단계 위 값(low → medium → high)을 넘긴다.',
282
292
  '',
283
293
  '## Rules',
284
294
  '',
@@ -323,14 +333,25 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
323
333
  ' the verification gate is the quality floor. Never drop the tier on work',
324
334
  ' that fails silently (diagnosis, root-cause calls, security posture,',
325
335
  ' design). Cheap models transcribe decisions; they do not make them.',
336
+ '- **Effort**: a separate axis from the tier. The installed subagents pin it',
337
+ ' by how their work is verified: low where the check is command output or a',
338
+ ' file comparison (the haiku agents for lookups, command runs and mechanical',
339
+ ' conversion), medium where a fixed design is implemented against tests (the',
340
+ ' sonnet worker). Leave the main session\'s effort alone, and never set',
341
+ ' CLAUDE_CODE_EFFORT_LEVEL: it overrides every subagent\'s own value.',
326
342
  '- **Batching**: a subagent\'s dominant cost is context ingestion, not',
327
343
  ' generation. Bundle tasks that read the same files into one agent; split',
328
344
  ' only when the areas they read differ.',
329
345
  implementationDelegationRule('claude', lang),
330
346
  '- **Escalation**: hand off immediately on the same error twice, an answer',
331
347
  ' splitting into multiple candidates, or a subagent spinning without asking',
332
- ' questions. Order: ① re-instruct and retry → ② move up one tier → ③ the',
333
- ' main model takes over directly.',
348
+ ' questions. Which axis moves depends on the failure: an omission (a file',
349
+ ' not read, tests not run, a cap hit mid-task) retries once on the same',
350
+ ' model one effort step up; a wrong answer with the full context in hand',
351
+ ' moves up one tier. Order: ① re-instruct and retry (effort +1 on an',
352
+ ' omission) → ② move up one tier (on a wrong answer) → ③ the main model',
353
+ ' takes over directly. To raise effort, pass the next level (low → medium →',
354
+ ' high) in the Agent call\'s effort argument instead of editing the agent file.',
334
355
  '',
335
356
  '## Rules',
336
357
  '',
@@ -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 { createReadStream, openSync, readSync, closeSync, fstatSync } from 'node:fs';
1
+ import { openSync, readSync, closeSync, fstatSync } from 'node:fs';
2
2
  import { readdir, stat } from 'node:fs/promises';
3
- import { createInterface } from 'node:readline';
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 rl = createInterface({
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 rl = createInterface({
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);
@@ -7,8 +7,7 @@
7
7
  * requestId (last-write-wins, matching parser.js).
8
8
  */
9
9
 
10
- import { createReadStream } from 'node:fs';
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 rl = createInterface({
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);
@@ -0,0 +1,62 @@
1
+ /**
2
+ * star-note — a one-time "star the repo" ask in the statusline.
3
+ *
4
+ * The package is downloaded thousands of times a month and starred a handful
5
+ * of times: all of the value lands in the statusline, so users never see the
6
+ * repository. This asks once, at the moment the value is visible — when the
7
+ * savings sprag itself produced (routing + doc2md) first reach $10.
8
+ *
9
+ * Once, because a chip that keeps asking is an advertisement. For 24 hours,
10
+ * which is long enough to be seen across a day of sessions and short enough
11
+ * not to linger. CTS_NO_NOTE=1 suppresses it, the same opt-out as every
12
+ * other one-time note.
13
+ */
14
+
15
+ import { createRequire } from 'node:module';
16
+ import { delegationSavedTotals } from './savings-ledger.js';
17
+ import { noteShownAt, markNoteShown } from './first-run-note.js';
18
+ import { userDataDir } from './paths.js';
19
+ import { debug } from './debug.js';
20
+
21
+ const require = createRequire(import.meta.url);
22
+
23
+ export const STAR_THRESHOLD_USD = 10;
24
+ export const STAR_WINDOW_MS = 24 * 60 * 60 * 1000;
25
+ export const STAR_URL = 'github.com/rootstudioyaml/sprag';
26
+
27
+ /** Lifetime dollars saved by routing and doc2md; an unreadable ledger counts as 0. */
28
+ export function toolSavedUsd(now = Date.now(), dir = userDataDir()) {
29
+ let total = 0;
30
+ try {
31
+ total += Number(delegationSavedTotals(now, dir).total) || 0;
32
+ } catch (e) {
33
+ debug('star-note:routing', e);
34
+ }
35
+ try {
36
+ total += Number(require('./doc2md-ledger.cjs').doc2mdSavedTotals(dir).total) || 0;
37
+ } catch (e) {
38
+ debug('star-note:doc2md', e);
39
+ }
40
+ return total;
41
+ }
42
+
43
+ /** `{ usd, shownAt }` while the chip should show, else null. Never throws. */
44
+ export function readStarChip({ now = Date.now(), dir = userDataDir(), savedUsd } = {}) {
45
+ try {
46
+ if (process.env.CTS_NO_NOTE === '1') return null;
47
+ const shownAt = noteShownAt('star');
48
+ if (shownAt === null) {
49
+ const usd = savedUsd ?? toolSavedUsd(now, dir);
50
+ if (!(usd >= STAR_THRESHOLD_USD)) return null;
51
+ markNoteShown('star', now);
52
+ return { usd, shownAt: now };
53
+ }
54
+ if (now - shownAt < STAR_WINDOW_MS) {
55
+ return { usd: savedUsd ?? toolSavedUsd(now, dir), shownAt };
56
+ }
57
+ return null;
58
+ } catch (e) {
59
+ debug('star-note', e);
60
+ return null;
61
+ }
62
+ }
@@ -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
- export function withBaseStatusline(output) {
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: readStdinRaw(), encoding: 'utf8', timeout: 1500, env: { ...process.env, [NESTED]: '1' } };
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);
@@ -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]}`;