sprag-cli 3.66.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.
Files changed (59) hide show
  1. package/README.ko.md +29 -62
  2. package/README.md +26 -61
  3. package/bin/cli.js +130 -62
  4. package/docs/CODEX.ko.md +32 -0
  5. package/docs/CODEX.md +34 -0
  6. package/package.json +11 -4
  7. package/presets/ratchet-rules.json +7 -0
  8. package/src/agents.js +33 -11
  9. package/src/{codex-brief.js → codex/brief.js} +7 -7
  10. package/src/{codex-budget.js → codex/budget.js} +2 -2
  11. package/src/{codex-cache-policy.js → codex/cache-policy.js} +9 -9
  12. package/src/{codex-delegation-diagnostics.js → codex/delegation-diagnostics.js} +2 -2
  13. package/src/{codex-delegation.js → codex/delegation.js} +11 -11
  14. package/src/{codex-doc2md-ledger.js → codex/doc2md-ledger.js} +5 -5
  15. package/src/{codex-doc2md.js → codex/doc2md.js} +4 -4
  16. package/src/{codex-doctor.js → codex/doctor.js} +4 -4
  17. package/src/{codex-harness.js → codex/harness.js} +5 -5
  18. package/src/{codex-hooks.js → codex/hooks.js} +20 -20
  19. package/src/{codex-installer.js → codex/installer.js} +2 -2
  20. package/src/{codex-ledger.js → codex/ledger.js} +6 -6
  21. package/src/{codex-panel-launcher.js → codex/panel-launcher.js} +2 -2
  22. package/src/{codex-panel-runner.js → codex/panel-runner.js} +2 -2
  23. package/src/{codex-panel-session.js → codex/panel-session.js} +1 -1
  24. package/src/{codex-panel-setup.js → codex/panel-setup.js} +1 -1
  25. package/src/{codex-panel-state.js → codex/panel-state.js} +3 -3
  26. package/src/{codex-panel.js → codex/panel.js} +43 -34
  27. package/src/{codex-parser.js → codex/parser.js} +5 -9
  28. package/src/{codex-reply-language.js → codex/reply-language.js} +2 -2
  29. package/src/{codex-route-scan.js → codex/route-scan.js} +10 -10
  30. package/src/{codex-rule-health.js → codex/rule-health.js} +4 -4
  31. package/src/commands/codex-delegate.js +6 -6
  32. package/src/commands/codex.js +44 -44
  33. package/src/commands/cowork.js +107 -0
  34. package/src/commands/delegate.js +43 -0
  35. package/src/commands/install.js +21 -7
  36. package/src/commands/korean.js +1 -1
  37. package/src/commands/litellm-budget.js +56 -0
  38. package/src/commands/route-scan.js +6 -0
  39. package/src/commands/saved.js +60 -5
  40. package/src/commands/uninstall.js +7 -3
  41. package/src/config.js +4 -0
  42. package/src/cowork-sync.js +361 -0
  43. package/src/delegation-agents.js +133 -0
  44. package/src/formatters/statusline.js +713 -504
  45. package/src/glyphs.js +2 -0
  46. package/src/harness.js +1 -1
  47. package/src/implementation-delegation.js +5 -2
  48. package/src/installer.js +77 -1
  49. package/src/model-alias.js +45 -60
  50. package/src/model-rules.js +12 -5
  51. package/src/model-share.js +136 -0
  52. package/src/parser.js +4 -13
  53. package/src/route-inject.js +8 -3
  54. package/src/session-records.js +2 -8
  55. package/src/statusline-base.js +9 -3
  56. package/src/stdin-payload.js +1 -1
  57. package/src/transcript-lines.js +40 -0
  58. /package/src/{codex-cache.js → codex/cache.js} +0 -0
  59. /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/harness.js CHANGED
@@ -70,7 +70,7 @@ function ratchetMdPath(root) {
70
70
  return join(root, '.claude', 'ratchet.md');
71
71
  }
72
72
 
73
- function globalRatchetMdPath() {
73
+ export function globalRatchetMdPath() {
74
74
  return join(homedir(), '.claude', 'ratchet.md');
75
75
  }
76
76
 
@@ -1,11 +1,14 @@
1
+ import { agentPhrase, agentPhraseEn, resolveAgentName } from './agents.js';
2
+
1
3
  /** Shared policy, rendered with each client's native model selector. */
2
4
  export function implementationDelegationRule(agent = 'claude', lang = 'en') {
3
5
  const target = agent === 'codex'
4
6
  ? 'the resolved T1 model for the current parent model and provider (explicit mapping first, automatic resolution otherwise)'
5
- : 'a model: sonnet subagent';
7
+ : (resolveAgentName('sonnet', null) ? agentPhraseEn('sonnet', { root: null }) : 'a model: sonnet subagent');
8
+ const targetKo = resolveAgentName('sonnet', null) ? agentPhrase('sonnet', { root: null }) : 'model: sonnet 서브에이전트';
6
9
  if (lang === 'ko') return [
7
10
  '- **구현 분업**: 여러 파일을 고칠 때 설계와 스펙 확정은 메인 모델이 맡습니다.',
8
- ` 구현은 ${agent === 'codex' ? '현재 부모 모델과 프로바이더의 T1 모델' : 'model: sonnet 서브에이전트'}에 위임합니다.`,
11
+ ` 구현은 ${agent === 'codex' ? '현재 부모 모델과 프로바이더의 T1 모델' : targetKo}에 위임합니다.`,
9
12
  ' Claude는 sonnet을 쓰고, Codex는 명시적 T1 매핑을 우선하며 없으면 자동 해석한 T1을 씁니다.',
10
13
  ' 두 모델의 성능이 같다고 가정하지 않으며 haiku나 T2에 구현을 맡기지 않습니다.',
11
14
  ' 위임 프롬프트에는 설계, 수정할 파일, 제약, 완료 기준, 테스트 명령, 예산과',
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
  }
@@ -33,7 +33,7 @@ import { join, dirname } from 'node:path';
33
33
  import { homedir } from 'node:os';
34
34
  import { userDataDir } from './paths.js';
35
35
  import { userLanguage } from './config.js';
36
- import { agentPhrase, agentPhraseEn } from './agents.js';
36
+ import { agentPhrase, agentPhraseEn, resolveAgentName } from './agents.js';
37
37
  import { writeStateFile } from './state-file.js';
38
38
  import { signedUsd } from './money.js';
39
39
  import { implementationDelegationRule } from './implementation-delegation.js';
@@ -132,16 +132,23 @@ function shortExample(text) {
132
132
  return one.length <= EXAMPLE_MAX ? one : `${one.slice(0, EXAMPLE_MAX - 1)}…`;
133
133
  }
134
134
 
135
+ // T1 is named through its delegation agent only when that file exists; without
136
+ // it the literal `model: sonnet` stays so output on such machines is unchanged.
137
+ function t1Target(root, ko) {
138
+ if (resolveAgentName('sonnet', root) === null) return null;
139
+ return (ko ? agentPhrase : agentPhraseEn)('sonnet', { root });
140
+ }
141
+
135
142
  export function modelRuleBaseText(g, lang = userLanguage(), { root } = {}) {
136
143
  if (lang === 'ko') {
137
144
  return g.tier === 'T2'
138
145
  ? `"${g.label}" 단순 요청(예: "${shortExample(g.example)}")은 ${agentPhrase(g.agent, { root })}에 위임`
139
- : `"${g.label}" 중간 난도 요청(예: "${shortExample(g.example)}")은 model: sonnet에 위임`;
146
+ : `"${g.label}" 중간 난도 요청(예: "${shortExample(g.example)}")은 ${t1Target(root, true) ?? 'model: sonnet'}에 위임`;
140
147
  }
141
148
  const example = g.exampleEn || g.example;
142
149
  return g.tier === 'T2'
143
150
  ? `Simple "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${agentPhraseEn(g.agent, { root })}`
144
- : `Moderate "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to a model: sonnet subagent`;
151
+ : `Moderate "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${t1Target(root, false) ?? 'a model: sonnet subagent'}`;
145
152
  }
146
153
 
147
154
  // See the note in route-scan.js — paths.js is the only place that resolves
@@ -401,10 +408,10 @@ export function renderModelRatchet(rules, lang = userLanguage(), { root } = {})
401
408
  // match at a glance.
402
409
  const rule = ko
403
410
  ? `"${t2.label}": 기본 ${agentPhrase(t2.agent, { root })}(예: "${ex(t2)}"), ` +
404
- `여러 단계·여러 파일이 얽히면 model: sonnet(예: "${ex(t1)}"). ` +
411
+ `여러 단계·여러 파일이 얽히면 ${t1Target(root, true) ?? 'model: sonnet'}(예: "${ex(t1)}"). ` +
405
412
  mergedBudget(t2, t1)
406
413
  : `"${t2.labelEn || t2.label}": ${agentPhraseEn(t2.agent, { root })} by default (e.g. "${ex(t2)}"), ` +
407
- `model: sonnet when it spans multiple steps or file edits (e.g. "${ex(t1)}"). ` +
414
+ `${t1Target(root, false) ?? 'model: sonnet'} when it spans multiple steps or file edits (e.g. "${ex(t1)}"). ` +
408
415
  mergedBudget(t2, t1);
409
416
  lines.push(`- ${rule}${healthOf(t2)}${healthOf(t1)}${extraHealth} <!-- T2 ${statsOf(t2)} / T1 ${statsOf(t1)}${extraStats} -->`);
410
417
  } else if (t2 || t1) {
@@ -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);
@@ -34,7 +34,7 @@
34
34
  import { categorizeScored, ESCALATE_RE, EDIT_RE, worthDelegating } from './route-scan.js';
35
35
  import { loadModelRules, budgetCapPhrase, ruleBudget } from './model-rules.js';
36
36
  import { sharedProjectRoots, realProjectRoot, scopeRank } from './shared-model-rules.js';
37
- import { agentPhrase, agentPhraseEn } from './agents.js';
37
+ import { agentPhrase, agentPhraseEn, resolveAgentName } from './agents.js';
38
38
  import { userLanguage } from './config.js';
39
39
  import { aliasForRole, resolveModelAlias } from './model-alias.js';
40
40
  import { isRecognizedModelId, modelRank } from './cost.js';
@@ -259,11 +259,16 @@ export function routeHint(text, { rules, lang = userLanguage(), root, sessionRan
259
259
  const raw = ko ? agentPhrase : agentPhraseEn;
260
260
  const phrase = (name) => raw(name, root === undefined ? undefined : { root });
261
261
 
262
+ // T1 is named through its delegation agent only when that file exists here;
263
+ // otherwise the literal tier string stays, so output without agents is unchanged.
264
+ const t1Root = root === undefined ? undefined : root;
265
+ const t1Named = resolveAgentName('sonnet', t1Root) !== null;
266
+ const t1Phrase = t1Named ? phrase('sonnet') : 'model: sonnet';
262
267
  let target;
263
268
  if (t2 && t1) {
264
269
  target = ko
265
- ? `기본 ${phrase(t2.agent)}, 여러 단계·여러 파일이 얽히면 model: sonnet`
266
- : `${phrase(t2.agent)} by default, model: sonnet when it spans multiple steps or file edits`;
270
+ ? `기본 ${phrase(t2.agent)}, 여러 단계·여러 파일이 얽히면 ${t1Phrase}`
271
+ : `${phrase(t2.agent)} by default, ${t1Phrase} when it spans multiple steps or file edits`;
267
272
  } else {
268
273
  const only = t2 || t1;
269
274
  target = ko ? phrase(only.agent) : phrase(only.agent);
@@ -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);
@@ -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]}`;
@@ -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