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.
- package/README.ko.md +33 -66
- package/README.md +41 -74
- package/bin/cli.js +150 -62
- package/docs/CODEX.ko.md +32 -0
- package/docs/CODEX.md +34 -0
- package/package.json +15 -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/delegation-agents.js +13 -0
- package/src/first-run-note.js +16 -0
- package/src/formatters/statusline.js +725 -504
- package/src/formatters/table.js +33 -2
- package/src/glyphs.js +2 -0
- package/src/installer.js +77 -1
- package/src/model-alias.js +45 -60
- package/src/model-rules.js +25 -4
- package/src/model-share.js +136 -0
- package/src/parser.js +4 -13
- package/src/session-records.js +2 -8
- package/src/star-note.js +62 -0
- package/src/statusline-base.js +9 -3
- package/src/stdin-payload.js +1 -1
- package/src/transcript-lines.js +40 -0
- package/src/changelog.js +0 -70
- /package/src/{codex-cache.js → codex/cache.js} +0 -0
- /package/src/{codex-panel-shell.js → codex/panel-shell.js} +0 -0
package/src/formatters/table.js
CHANGED
|
@@ -181,17 +181,48 @@ function renderCapWarnSection(caps) {
|
|
|
181
181
|
return lines;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
|
|
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(`
|
|
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;
|
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
|
}
|
package/src/model-rules.js
CHANGED
|
@@ -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.
|
|
333
|
-
'
|
|
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 {
|
|
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/star-note.js
ADDED
|
@@ -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
|
+
}
|
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]}`;
|