sprag-cli 3.55.0 → 3.57.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 +5 -1
- package/README.md +5 -1
- package/bin/cli.js +60 -19
- package/bin/postinstall.js +22 -0
- package/docs/CODEX.ko.md +44 -5
- package/docs/CODEX.md +74 -12
- package/package.json +2 -2
- package/src/agents.js +16 -2
- package/src/brief.js +17 -8
- package/src/claude-price.js +49 -0
- package/src/codex-brief.js +55 -2
- package/src/codex-cache-policy.js +26 -41
- package/src/codex-delegation.js +56 -8
- package/src/codex-doctor.js +1 -1
- package/src/codex-harness.js +63 -18
- package/src/codex-hooks.js +129 -20
- package/src/codex-ledger.js +150 -16
- package/src/codex-panel.js +51 -26
- package/src/codex-parser.js +41 -4
- package/src/codex-route-scan.js +14 -7
- package/src/codex-rule-health.js +41 -0
- package/src/commands/brief.js +14 -3
- package/src/commands/codex-delegate.js +17 -5
- package/src/commands/codex.js +20 -10
- package/src/commands/harness.js +6 -0
- package/src/commands/route-scan.js +4 -3
- package/src/compact-window.js +12 -4
- package/src/config.js +3 -3
- package/src/cost.js +62 -5
- package/src/ctx-window-cache.js +61 -0
- package/src/delegation-guard.js +15 -9
- package/src/doc2md.cjs +22 -8
- package/src/fig2md.cjs +7 -1
- package/src/formatters/statusline.js +22 -11
- package/src/formatters/table.js +19 -10
- package/src/gateway-auth.js +31 -3
- package/src/gateway-prices.js +39 -0
- package/src/harness.js +43 -12
- package/src/history.js +6 -2
- package/src/hook-manager.js +54 -32
- package/src/installer.js +286 -153
- package/src/korean-lint.cjs +40 -7
- package/src/litellm-budget.js +10 -1
- package/src/litellm-models.js +72 -10
- package/src/model-rules.js +90 -42
- package/src/money.js +9 -0
- package/src/month-spend.js +23 -12
- package/src/openai-prices.js +33 -0
- package/src/parser.js +232 -56
- package/src/postinstall.js +32 -0
- package/src/route-caps.js +74 -0
- package/src/route-inject.js +31 -7
- package/src/route-scan.js +93 -33
- package/src/savings-ledger.js +43 -11
- package/src/seed-rules.js +3 -4
- package/src/session-cache.js +35 -1
- package/src/state-file.js +43 -0
- package/src/stats.js +20 -5
- package/src/stdin-payload.js +18 -4
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The context window Claude Code reports for each session.
|
|
3
|
+
*
|
|
4
|
+
* Only the statusline payload carries it (`context_window.context_window_size`).
|
|
5
|
+
* The UserPromptSubmit hook gets a session id and a transcript path, so the
|
|
6
|
+
* briefing used to guess the window from the configured model id, and a model
|
|
7
|
+
* that is 1M without a `[1m]` suffix was judged against 200k: the 80% warning
|
|
8
|
+
* fired at 160k while the statusline of the same session said "Ctx 1M". The
|
|
9
|
+
* statusline records the size it was given here, and the briefing reads it back
|
|
10
|
+
* by session id before it falls back to guessing.
|
|
11
|
+
*
|
|
12
|
+
* State: <stateDir>/ctx-window.json
|
|
13
|
+
* { sessions: { [session_id]: { size, ts } } }
|
|
14
|
+
* Entries untouched for 7 days are pruned on write.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { userDataDir } from './paths.js';
|
|
20
|
+
import { writeStateFile } from './state-file.js';
|
|
21
|
+
|
|
22
|
+
const PRUNE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
23
|
+
// The statusline renders every few seconds. An unchanged size is rewritten only
|
|
24
|
+
// this often, which is enough to keep a long session's entry from being pruned.
|
|
25
|
+
const REFRESH_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
export function ctxWindowCachePath() {
|
|
28
|
+
return join(userDataDir(), 'ctx-window.json');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function load() {
|
|
32
|
+
try {
|
|
33
|
+
const s = JSON.parse(readFileSync(ctxWindowCachePath(), 'utf8'));
|
|
34
|
+
return s && s.sessions && typeof s.sessions === 'object' && !Array.isArray(s.sessions) ? s : { sessions: {} };
|
|
35
|
+
} catch {
|
|
36
|
+
return { sessions: {} };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const validSize = (size) => Number.isFinite(size) && size > 0;
|
|
41
|
+
|
|
42
|
+
/** Record the window the statusline payload reported. Returns true when the file was written. */
|
|
43
|
+
export function recordLiveWindow(sessionId, size, now = Date.now()) {
|
|
44
|
+
if (typeof sessionId !== 'string' || sessionId === '' || !validSize(size)) return false;
|
|
45
|
+
const state = load();
|
|
46
|
+
const prev = state.sessions[sessionId];
|
|
47
|
+
if (prev && prev.size === size && now - prev.ts < REFRESH_MS) return false;
|
|
48
|
+
state.sessions[sessionId] = { size, ts: now };
|
|
49
|
+
for (const [id, s] of Object.entries(state.sessions)) {
|
|
50
|
+
if (!s || !Number.isFinite(s.ts) || now - s.ts > PRUNE_MS) delete state.sessions[id];
|
|
51
|
+
}
|
|
52
|
+
writeStateFile(ctxWindowCachePath(), JSON.stringify(state) + '\n');
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The recorded window for a session, or null when the statusline never reported one. */
|
|
57
|
+
export function liveWindowFor(sessionId) {
|
|
58
|
+
if (typeof sessionId !== 'string' || sessionId === '') return null;
|
|
59
|
+
const s = load().sessions[sessionId];
|
|
60
|
+
return s && validSize(s.size) ? s.size : null;
|
|
61
|
+
}
|
package/src/delegation-guard.js
CHANGED
|
@@ -26,6 +26,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
26
26
|
import { homedir } from 'node:os';
|
|
27
27
|
import { loadConfig, saveConfig } from './config.js';
|
|
28
28
|
import { recentToolPaths } from './session-paths.js';
|
|
29
|
+
import { routeCapsFor } from './route-caps.js';
|
|
29
30
|
|
|
30
31
|
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
32
|
|
|
@@ -114,7 +115,7 @@ function ratchetImportMissing(home = homedir()) {
|
|
|
114
115
|
* English-only prompt, an already-imported ratchet.md, or a session with
|
|
115
116
|
* nothing read yet each drop their own section without blocking the others.
|
|
116
117
|
*/
|
|
117
|
-
export async function buildAppendix(payload, { cfg = loadConfig(), home = homedir() } = {}) {
|
|
118
|
+
export async function buildAppendix(payload, { cfg = loadConfig(), home = homedir(), routeCaps = routeCapsFor(payload?.session_id) } = {}) {
|
|
118
119
|
const sections = [];
|
|
119
120
|
|
|
120
121
|
// Read from `model` first — it is the more specific field when both are set
|
|
@@ -125,12 +126,17 @@ export async function buildAppendix(payload, { cfg = loadConfig(), home = homedi
|
|
|
125
126
|
|
|
126
127
|
const bounds = boundsText();
|
|
127
128
|
if (bounds) {
|
|
128
|
-
//
|
|
129
|
-
// (
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
// Haiku delegations get a tighter leash (8 tool calls / 1,500 output
|
|
130
|
+
// tokens) than everything else (20 / 8,000). Those are the defaults of the
|
|
131
|
+
// model-fitting rules. When this turn's prompt matched a rule, the rule's
|
|
132
|
+
// own calibrated budget for the tier replaces them, so the subagent is told
|
|
133
|
+
// the same limit the main model was (see route-caps.js). A T1 rule sets no
|
|
134
|
+
// call count, and the default one stays in that case.
|
|
135
|
+
const fallback = isHaiku ? { calls: 8, out: 1500 } : { calls: 20, out: 8000 };
|
|
136
|
+
const matched = routeCaps?.[isHaiku ? 'T2' : 'T1'];
|
|
137
|
+
const calls = Number.isFinite(matched?.calls) && matched.calls > 0 ? matched.calls : fallback.calls;
|
|
138
|
+
const out = Number.isFinite(matched?.out) && matched.out > 0 ? matched.out : fallback.out;
|
|
139
|
+
const capLine = `Cap for this delegation: ${calls} tool calls, ${out.toLocaleString('en-US')} output tokens.`;
|
|
134
140
|
sections.push(['## Bounds and output shape', bounds, capLine].join('\n\n'));
|
|
135
141
|
}
|
|
136
142
|
|
|
@@ -234,7 +240,7 @@ export async function buildAppendix(payload, { cfg = loadConfig(), home = homedi
|
|
|
234
240
|
* call, so the caller's contract stays "print nothing, let the tool run
|
|
235
241
|
* exactly as given" for every one of them.
|
|
236
242
|
*/
|
|
237
|
-
export async function decideForDelegation(payload, { cfg = loadConfig(), home = homedir() } = {}) {
|
|
243
|
+
export async function decideForDelegation(payload, { cfg = loadConfig(), home = homedir(), routeCaps = routeCapsFor(payload?.session_id) } = {}) {
|
|
238
244
|
if (!delegateEnabled(cfg)) return null;
|
|
239
245
|
if (payload?.tool_name !== 'Task' && payload?.tool_name !== 'Agent') return null;
|
|
240
246
|
|
|
@@ -246,7 +252,7 @@ export async function decideForDelegation(payload, { cfg = loadConfig(), home =
|
|
|
246
252
|
// same tool_name, must not stack the appendix onto itself.
|
|
247
253
|
if (prompt.includes(DELEGATION_MARKER)) return null;
|
|
248
254
|
|
|
249
|
-
const appendix = await buildAppendix(payload, { cfg, home });
|
|
255
|
+
const appendix = await buildAppendix(payload, { cfg, home, routeCaps });
|
|
250
256
|
if (!appendix) return null;
|
|
251
257
|
|
|
252
258
|
return { updatedInput: { ...toolInput, prompt: `${prompt}\n\n${appendix}` } };
|
package/src/doc2md.cjs
CHANGED
|
@@ -307,13 +307,19 @@ function managedPython() {
|
|
|
307
307
|
: path.join(dir, 'bin', 'python');
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
-
|
|
310
|
+
// Each spec carries an upper bound at the next major line. An open spec takes
|
|
311
|
+
// whatever is newest on the day someone first opens a document, so a breaking
|
|
312
|
+
// release (or a bad one) would reach every new machine with nothing in this
|
|
313
|
+
// repository having changed. The bounds are the lines verified here:
|
|
314
|
+
// markitdown 0.1.7, python-pptx 1.0.2, python-docx 1.2.0, openpyxl 3.1.5.
|
|
315
|
+
// pip gets them as plain argv, with no shell in between, so `<` is safe.
|
|
316
|
+
const MARKITDOWN_SPEC = 'markitdown[pptx,pdf,xlsx,docx]>=0.1,<0.2';
|
|
311
317
|
|
|
312
318
|
// Editing libraries, installed alongside the converter. Reading is doc2md's
|
|
313
319
|
// own job; editing is the agent's, done per-request with a short script
|
|
314
320
|
// against a COPY of the document. These are the libraries those scripts need,
|
|
315
321
|
// pre-installed so "swap the chart on slide 23" does not stall on pip.
|
|
316
|
-
const EDIT_LIBS = ['python-pptx', 'python-docx', 'openpyxl'];
|
|
322
|
+
const EDIT_LIBS = ['python-pptx>=1.0,<2', 'python-docx>=1.1,<2', 'openpyxl>=3.1,<4'];
|
|
317
323
|
|
|
318
324
|
/**
|
|
319
325
|
* Build the managed venv and install markitdown into it.
|
|
@@ -999,15 +1005,22 @@ function decideForWrite(context, opts = {}) {
|
|
|
999
1005
|
return null;
|
|
1000
1006
|
}
|
|
1001
1007
|
|
|
1002
|
-
/**
|
|
1008
|
+
/**
|
|
1009
|
+
* The decision rendered as the JSON Claude Code expects on stdout.
|
|
1010
|
+
*
|
|
1011
|
+
* Only a refusal answers the permission question. A note about a document
|
|
1012
|
+
* that could not be converted says nothing about whether the read should be
|
|
1013
|
+
* allowed, and answering `allow` for it approved the call without the prompt
|
|
1014
|
+
* the user would otherwise have seen, sensitive-pattern files included. The
|
|
1015
|
+
* note goes out as context instead, which is also the only form that reaches
|
|
1016
|
+
* the model: an `allow` reason is shown to the user alone.
|
|
1017
|
+
*/
|
|
1003
1018
|
function formatHookOutput(decision) {
|
|
1004
1019
|
if (!decision) return null;
|
|
1005
1020
|
return JSON.stringify({
|
|
1006
|
-
hookSpecificOutput:
|
|
1007
|
-
hookEventName: 'PreToolUse',
|
|
1008
|
-
|
|
1009
|
-
permissionDecisionReason: decision.reason,
|
|
1010
|
-
},
|
|
1021
|
+
hookSpecificOutput: decision.deny
|
|
1022
|
+
? { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: decision.reason }
|
|
1023
|
+
: { hookEventName: 'PreToolUse', additionalContext: decision.reason },
|
|
1011
1024
|
});
|
|
1012
1025
|
}
|
|
1013
1026
|
|
|
@@ -1016,6 +1029,7 @@ module.exports = {
|
|
|
1016
1029
|
MAX_SOURCE_BYTES,
|
|
1017
1030
|
INSTALL_HINT,
|
|
1018
1031
|
MARKITDOWN_SPEC,
|
|
1032
|
+
EDIT_LIBS,
|
|
1019
1033
|
managedPython,
|
|
1020
1034
|
installConverter,
|
|
1021
1035
|
ensureConverterInstalled,
|
package/src/fig2md.cjs
CHANGED
|
@@ -32,6 +32,11 @@ const { spawnSync } = require('node:child_process');
|
|
|
32
32
|
// cmd.exe, where `^` is the escape character and would be eaten before npm
|
|
33
33
|
// ever saw it. The range is the same one caret means for a 0.x package.
|
|
34
34
|
const FIG_PARSER_SPEC = 'openfig-core@0.4.x';
|
|
35
|
+
// `--ignore-scripts`: the parser and its three dependencies are plain
|
|
36
|
+
// JavaScript and ship no install script, so nothing is lost, and a release of
|
|
37
|
+
// any of them that adds one cannot run code on the machine of someone who only
|
|
38
|
+
// opened a .fig file.
|
|
39
|
+
const FIG_INSTALL_ARGS = ['install', '--no-audit', '--no-fund', '--silent', '--ignore-scripts', FIG_PARSER_SPEC];
|
|
35
40
|
|
|
36
41
|
/** Where the on-demand parser install lives, under the tool's state dir. */
|
|
37
42
|
function managedFigDir(userDataDir) {
|
|
@@ -63,7 +68,7 @@ function installFigParser(userDataDir, { onProgress = () => {} } = {}) {
|
|
|
63
68
|
// needs the shell. Elsewhere the shell is avoided, since the package spec
|
|
64
69
|
// would then go through shell quoting for no benefit.
|
|
65
70
|
const isWindows = process.platform === 'win32';
|
|
66
|
-
const r = spawnSync(isWindows ? 'npm.cmd' : 'npm',
|
|
71
|
+
const r = spawnSync(isWindows ? 'npm.cmd' : 'npm', FIG_INSTALL_ARGS, {
|
|
67
72
|
cwd: dir,
|
|
68
73
|
encoding: 'utf8',
|
|
69
74
|
timeout: 300_000,
|
|
@@ -184,6 +189,7 @@ async function convertFig(filePath, userDataDir) {
|
|
|
184
189
|
|
|
185
190
|
module.exports = {
|
|
186
191
|
FIG_PARSER_SPEC,
|
|
192
|
+
FIG_INSTALL_ARGS,
|
|
187
193
|
managedFigDir,
|
|
188
194
|
installFigParser,
|
|
189
195
|
convertFig,
|
|
@@ -54,6 +54,12 @@ const GRAY = fg(100, 116, 139, '\x1b[90m');
|
|
|
54
54
|
const BOLD = '\x1b[1m';
|
|
55
55
|
|
|
56
56
|
export function formatMoney(usd) {
|
|
57
|
+
// The sign leads the currency symbol ("-$0.12"); a loss that rounds to zero
|
|
58
|
+
// prints as plain "$0.00" rather than "-$0.00".
|
|
59
|
+
if (usd < 0) {
|
|
60
|
+
const abs = formatMoney(-usd);
|
|
61
|
+
return abs === '$0.00' ? abs : `-${abs}`;
|
|
62
|
+
}
|
|
57
63
|
// Boundaries sit at 999.5/999500 so a value that would round up into the
|
|
58
64
|
// next band's width ($999.9 → "$1000") jumps to that band's unit instead —
|
|
59
65
|
// otherwise "$1000" renders visually larger than "$1.0K".
|
|
@@ -298,9 +304,9 @@ function buildVersionSeg(version, update, c, isIcon, verbose, g) {
|
|
|
298
304
|
* fallback line. Best-effort: never throws into the statusline (corrupted
|
|
299
305
|
* CLAUDE.md, permission issue, etc. → null).
|
|
300
306
|
*/
|
|
301
|
-
function buildHarnessSeg(c, isIcon, g) {
|
|
307
|
+
function buildHarnessSeg(c, isIcon, g, liveWindow = null) {
|
|
302
308
|
try {
|
|
303
|
-
const harnessInfo = harnessStatusForStatusline(loadConfig());
|
|
309
|
+
const harnessInfo = harnessStatusForStatusline(loadConfig(), { liveWindow });
|
|
304
310
|
if (!harnessInfo) return null;
|
|
305
311
|
const icon = isIcon ? g.harness : 'H';
|
|
306
312
|
if (harnessInfo.warning) {
|
|
@@ -485,7 +491,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
485
491
|
|
|
486
492
|
// Delegation savings — a DIFFERENT number from "Cache saved" above, which
|
|
487
493
|
// covers the prompt cache only. This one is what running work on a cheaper
|
|
488
|
-
// tier saved,
|
|
494
|
+
// tier saved, the lifetime total of the delegation ledger. Hidden
|
|
489
495
|
// when zero or absent: a permanent "$0" is noise for direct-API users and
|
|
490
496
|
// for anyone who has not delegated yet.
|
|
491
497
|
// "Routing saved" says what earned the money — work that ran on a cheaper
|
|
@@ -512,8 +518,8 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
512
518
|
// Tied to MIN_VOTES rather than a literal so the two cannot drift: the chip
|
|
513
519
|
// appears exactly when learning has had its chance and still came up short.
|
|
514
520
|
const unresolvedRuns = Number(data.unresolvedRuns) || 0;
|
|
515
|
-
const delegateSeg = delegationSaved
|
|
516
|
-
? `${c(GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
|
|
521
|
+
const delegateSeg = delegationSaved !== 0
|
|
522
|
+
? `${c(delegationSaved < 0 ? RED : GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
|
|
517
523
|
: (unresolvedRuns >= MIN_VOTES
|
|
518
524
|
? `${c(YELLOW)}${g.routing} ${unresolvedRuns} unresolved${c(RESET)}`
|
|
519
525
|
: null);
|
|
@@ -552,8 +558,12 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
552
558
|
// verbose: "Sep spend $42.1 (since Sep 1)"
|
|
553
559
|
const month = data.monthSpend;
|
|
554
560
|
let monthSeg = null;
|
|
555
|
-
|
|
556
|
-
|
|
561
|
+
// Gateway sessions whose model the gateway does not price are left out of the
|
|
562
|
+
// sum and counted, so a partial total says so and an all-unpriced month is n/a.
|
|
563
|
+
const monthUnpriced = Number(month?.unpriced) || 0;
|
|
564
|
+
if (month && (Number(month.usd) > 0 || monthUnpriced > 0)) {
|
|
565
|
+
const amt = (Number(month.usd) > 0 ? formatMoney(Number(month.usd)) : 'n/a') +
|
|
566
|
+
(monthUnpriced > 0 ? ` +${monthUnpriced} unpriced` : '');
|
|
557
567
|
if (isIcon) {
|
|
558
568
|
monthSeg = `${c(GRAY)}${g.month} ${month.label} ${amt}${c(RESET)}`;
|
|
559
569
|
} else if (verbose) {
|
|
@@ -576,7 +586,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
576
586
|
// text: "Routing saved $9.8 | opus→haiku 2× $6.4 · fable→sonnet 1× $3.4"
|
|
577
587
|
const totals = data.delegationTotals;
|
|
578
588
|
let totalsLine = null;
|
|
579
|
-
if (!singleLine && totals && Number(totals.total)
|
|
589
|
+
if (!singleLine && totals && Number(totals.total) !== 0 && Number.isFinite(Number(totals.total))) {
|
|
580
590
|
const head = isIcon ? `${g.routing} Routing saved` : 'Routing saved';
|
|
581
591
|
// Model changes behind the total, family-level and version-free: `opus →
|
|
582
592
|
// haiku 2× $0.6`. Versions bump constantly and add nothing here — the
|
|
@@ -595,9 +605,10 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
595
605
|
const pairText = pairs
|
|
596
606
|
.map((p) => `${c(GRAY)}${p.from}→${p.to} ${p.runs}× ${formatMoney(p.usd)}${c(RESET)}`)
|
|
597
607
|
.join(` ${c(GRAY)}·${c(RESET)} `);
|
|
608
|
+
const tone = Number(totals.total) < 0 ? RED : GREEN; // a net loss must not read as a win
|
|
598
609
|
totalsLine =
|
|
599
|
-
`${c(
|
|
600
|
-
`${c(
|
|
610
|
+
`${c(tone)}${c(BOLD)}${head}${c(RESET)} ` +
|
|
611
|
+
`${c(tone)}${formatMoney(Number(totals.total) || 0)}${c(RESET)}` +
|
|
601
612
|
(pairText ? ` ${c(GRAY)}|${c(RESET)} ${pairText}` : '');
|
|
602
613
|
}
|
|
603
614
|
|
|
@@ -749,7 +760,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
749
760
|
// Silent when the project hasn't opted in (no CLAUDE.md and no .claude/);
|
|
750
761
|
// otherwise renders 🅷 5/5 (green) / 🅷 N/5 (yellow) so the user can spot
|
|
751
762
|
// a missing section at a glance and know to run `harness init`.
|
|
752
|
-
const harnessSeg = buildHarnessSeg(c, isIcon, g);
|
|
763
|
+
const harnessSeg = buildHarnessSeg(c, isIcon, g, ctxLive?.size ?? null);
|
|
753
764
|
|
|
754
765
|
// Version / upgrade chip. Read from a cache written by a detached background
|
|
755
766
|
// check — this render path never touches the network.
|
package/src/formatters/table.js
CHANGED
|
@@ -14,8 +14,10 @@ const C = {
|
|
|
14
14
|
yellow: '\x1b[33m',
|
|
15
15
|
cyan: '\x1b[36m',
|
|
16
16
|
};
|
|
17
|
-
// Wrap text with ANSI codes
|
|
18
|
-
|
|
17
|
+
// Wrap text with ANSI codes. Set per report by formatReport: the caller knows
|
|
18
|
+
// about `--no-color` and whether stdout is a terminal, which this module cannot
|
|
19
|
+
// see. NO_COLOR alone is the answer for a caller that passes nothing.
|
|
20
|
+
let colorOk = !process.env.NO_COLOR;
|
|
19
21
|
const r = (s) => colorOk ? `${C.red}${s}${C.reset}` : s;
|
|
20
22
|
const rb = (s) => colorOk ? `${C.bold}${C.red}${s}${C.reset}` : s;
|
|
21
23
|
const rbl = (s) => colorOk ? `${C.blink}${C.bold}${C.red}${s}${C.reset}` : s;
|
|
@@ -28,6 +30,11 @@ function pad(str, len, align = 'left') {
|
|
|
28
30
|
return s.padEnd(len);
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
// A loss reads as "-$3", not "$-3". The digits are left as cost.js rounded them.
|
|
34
|
+
function usd(n) {
|
|
35
|
+
return n < 0 ? `-$${Math.abs(n)}` : `$${n}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
function pct(n) {
|
|
32
39
|
return (n * 100).toFixed(1) + '%';
|
|
33
40
|
}
|
|
@@ -93,7 +100,7 @@ function renderSpikeSection(spikes, contextWindow) {
|
|
|
93
100
|
}
|
|
94
101
|
for (const spike of spikes) {
|
|
95
102
|
const m = spike.metrics;
|
|
96
|
-
const ratioLabel = spike.ratio ? `${spike.ratio.toFixed(1)}× p95` : 'single-request > 250k';
|
|
103
|
+
const ratioLabel = spike.ratio && spike.byRatio !== false ? `${spike.ratio.toFixed(1)}× p95` : 'single-request > 250k';
|
|
97
104
|
lines.push(
|
|
98
105
|
r(` • ${shortSessionId(m.sessionId)} [${m.projectDir || 'unknown'}] `) +
|
|
99
106
|
rb(`total input ${formatContextSize(m.totalInput)}`) +
|
|
@@ -140,7 +147,7 @@ function renderSpikeSection(spikes, contextWindow) {
|
|
|
140
147
|
lines.push('');
|
|
141
148
|
}
|
|
142
149
|
}
|
|
143
|
-
lines.push(rbl(' ▶
|
|
150
|
+
lines.push(rbl(' ▶ How to handle it: ') + rb(`${CLI_NAME} last`));
|
|
144
151
|
lines.push('');
|
|
145
152
|
return lines;
|
|
146
153
|
}
|
|
@@ -169,12 +176,13 @@ function renderCapWarnSection(caps) {
|
|
|
169
176
|
lines.push(rb(` ${CLI_NAME} handoff`));
|
|
170
177
|
lines.push(r(' (writes a HANDOFF-*.md so a fresh session can pick up.)'));
|
|
171
178
|
lines.push('');
|
|
172
|
-
lines.push(rbl(' ▶
|
|
179
|
+
lines.push(rbl(' ▶ How to handle it: ') + rb(`${CLI_NAME} last`));
|
|
173
180
|
lines.push('');
|
|
174
181
|
return lines;
|
|
175
182
|
}
|
|
176
183
|
|
|
177
|
-
export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow, caps }) {
|
|
184
|
+
export function formatReport({ summary: sum, trend, ttl, anomalies, cost, options, spikeReport, contextWindow, caps }, { color } = {}) {
|
|
185
|
+
colorOk = color ?? !process.env.NO_COLOR;
|
|
178
186
|
const lines = [];
|
|
179
187
|
|
|
180
188
|
// Header
|
|
@@ -240,19 +248,20 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
|
|
|
240
248
|
const costW = [24, 12];
|
|
241
249
|
const costA = ['left', 'right'];
|
|
242
250
|
lines.push(' ' + tableTop(costW));
|
|
243
|
-
lines.push(' ' + tableRow(['Actual cost',
|
|
244
|
-
lines.push(' ' + tableRow(['Without cache',
|
|
251
|
+
lines.push(' ' + tableRow(['Actual cost', usd(cost.actual)], costW, costA));
|
|
252
|
+
lines.push(' ' + tableRow(['Without cache', usd(cost.noCacheCost)], costW, costA));
|
|
245
253
|
lines.push(' ' + tableSep(costW));
|
|
246
|
-
lines.push(' ' + tableRow(['Savings',
|
|
254
|
+
lines.push(' ' + tableRow(['Savings', `${usd(cost.savings)} (${pct(cost.savingsRate)})`], costW, costA));
|
|
247
255
|
// Only worth asking of someone who has 1h writes to lose. Everyone else got
|
|
248
256
|
// a `+$0` that read as an endorsement of the 5m bucket they were already
|
|
249
257
|
// stuck in.
|
|
250
258
|
if (cost.extraCostIf5mApplicable === false) {
|
|
251
259
|
lines.push(' ' + tableRow(['Already 5m-only', ttl.gatewayObserved ? 'gateway' : 'yes'], costW, costA));
|
|
252
260
|
} else {
|
|
253
|
-
lines.push(' ' + tableRow(['Extra cost if 5m-only',
|
|
261
|
+
lines.push(' ' + tableRow(['Extra cost if 5m-only', cost.extraCostIf5m < 0 ? usd(cost.extraCostIf5m) : `+${usd(cost.extraCostIf5m)}`], costW, costA));
|
|
254
262
|
}
|
|
255
263
|
lines.push(' ' + tableBot(costW));
|
|
264
|
+
if (cost.unpriced > 0) lines.push(` ${cost.unpriced} session(s) not priced by the gateway are left out.`);
|
|
256
265
|
lines.push('');
|
|
257
266
|
|
|
258
267
|
// Daily trend
|
package/src/gateway-auth.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { readFileSync } from 'node:fs';
|
|
23
23
|
import { execSync } from 'node:child_process';
|
|
24
|
-
import { join } from 'node:path';
|
|
24
|
+
import { join, resolve, dirname } from 'node:path';
|
|
25
25
|
import { homedir } from 'node:os';
|
|
26
26
|
import { debug } from './debug.js';
|
|
27
27
|
|
|
@@ -40,6 +40,29 @@ export function gatewayBase(env = process.env) {
|
|
|
40
40
|
return base;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Claude Code 가 신뢰 확인을 받아 둔 폴더인지 판정합니다. 기록은
|
|
45
|
+
* ~/.claude.json 의 projects[<절대 경로>].hasTrustDialogAccepted 에 있고, 상위
|
|
46
|
+
* 폴더를 신뢰했으면 하위 폴더에도 적용됩니다. 기록을 읽지 못하면 신뢰하지 않은
|
|
47
|
+
* 것으로 봅니다.
|
|
48
|
+
*/
|
|
49
|
+
export function isTrustedProject(cwd = process.cwd(), { home = homedir() } = {}) {
|
|
50
|
+
let projects;
|
|
51
|
+
try {
|
|
52
|
+
projects = JSON.parse(readFileSync(join(home, '.claude.json'), 'utf8'))?.projects;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
if (!projects || typeof projects !== 'object') return false;
|
|
57
|
+
let dir = resolve(cwd);
|
|
58
|
+
for (;;) {
|
|
59
|
+
if (projects[dir]?.hasTrustDialogAccepted === true) return true;
|
|
60
|
+
const parent = dirname(dir);
|
|
61
|
+
if (parent === dir) return false;
|
|
62
|
+
dir = parent;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
43
66
|
/**
|
|
44
67
|
* Claude Code 와 같은 방식으로 apiKeyHelper 를 실행해 토큰을 얻습니다.
|
|
45
68
|
* 환경변수에 토큰을 두지 않고 헬퍼 스크립트로 매번 발급받는 구성이 공식 인증
|
|
@@ -50,9 +73,14 @@ export function gatewayBase(env = process.env) {
|
|
|
50
73
|
* 부릅니다. 수 초 간격으로 도는 렌더 경로에서 부르면 통계선이 그만큼 느려집니다.
|
|
51
74
|
*/
|
|
52
75
|
export function keyFromApiKeyHelper(cwd = process.cwd()) {
|
|
76
|
+
// 프로젝트 설정의 헬퍼는 그 저장소가 넣어 둔 셸 명령입니다. Claude Code 는 신뢰
|
|
77
|
+
// 확인을 거친 폴더에서만 그것을 실행하므로, 같은 기록을 보고 같은 조건으로만
|
|
78
|
+
// 실행합니다. 그러지 않으면 내려받은 저장소 안에서 이 CLI 를 돌리는 것만으로
|
|
79
|
+
// 그 저장소의 명령이 실행됩니다.
|
|
53
80
|
const candidates = [
|
|
54
|
-
|
|
55
|
-
|
|
81
|
+
...(isTrustedProject(cwd)
|
|
82
|
+
? [join(cwd, '.claude', 'settings.local.json'), join(cwd, '.claude', 'settings.json')]
|
|
83
|
+
: []),
|
|
56
84
|
join(homedir(), '.claude', 'settings.json'),
|
|
57
85
|
];
|
|
58
86
|
for (const p of candidates) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// LiteLLM /model/info prices, shared by the Codex and Claude sides. Per-token USD; both sides
|
|
2
|
+
// price a call by the path it took, so these are only ever the gateway's own numbers.
|
|
3
|
+
export const PRICE_MAX_AGE_MS = 7 * 86400000;
|
|
4
|
+
export const rate = (value) => Number.isFinite(value) && value >= 0 && value < 1 ? value : null;
|
|
5
|
+
|
|
6
|
+
export function deploymentPrice(row) {
|
|
7
|
+
const info = row?.model_info || {};
|
|
8
|
+
const mode = typeof info.mode === 'string' ? info.mode : null;
|
|
9
|
+
if (mode && !['chat', 'responses', 'completion'].includes(mode)) return null;
|
|
10
|
+
const input = rate(info.input_cost_per_token), output = rate(info.output_cost_per_token);
|
|
11
|
+
if (input === null || output === null) return null;
|
|
12
|
+
return { input, output, cacheRead: rate(info.cache_read_input_token_cost), cacheWrite: rate(info.cache_creation_input_token_cost),
|
|
13
|
+
cacheWrite1h: rate(info.cache_creation_input_token_cost_above_1hr) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Per-token prices by alias. Load-balanced deployments must agree, like cache policy. */
|
|
17
|
+
export function gatewayPrices(payload) {
|
|
18
|
+
if (!Array.isArray(payload?.data)) throw new Error('LiteLLM model/info returned no model list');
|
|
19
|
+
const groups = new Map();
|
|
20
|
+
for (const row of payload.data) {
|
|
21
|
+
if (typeof row?.model_name !== 'string' || row.model_name.length > 256) continue;
|
|
22
|
+
groups.set(row.model_name, [...(groups.get(row.model_name) || []), deploymentPrice(row)]);
|
|
23
|
+
}
|
|
24
|
+
return Object.fromEntries([...groups].map(([name, prices]) => [name,
|
|
25
|
+
prices[0] && prices.every((p) => JSON.stringify(p) === JSON.stringify(prices[0])) ? prices[0] : null]));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* USD for a token mix at one model's prices, or null when a needed rate is
|
|
30
|
+
* missing. Cached input without a cache-read rate is unknown, not free.
|
|
31
|
+
*/
|
|
32
|
+
export function codexRunCost({ input = 0, cached = 0, cacheWrite = 0, output = 0 }, price) {
|
|
33
|
+
if (!price || price.input === null || price.output === null) return null;
|
|
34
|
+
if (cached > 0 && price.cacheRead === null) return null;
|
|
35
|
+
// cache_write_input_tokens is billed at the creation rate when one is published, else as input.
|
|
36
|
+
const uncached = Math.max(0, input - cached - cacheWrite);
|
|
37
|
+
return uncached * price.input + cached * (price.cacheRead ?? 0) + cacheWrite * (price.cacheWrite ?? price.input) + output * price.output;
|
|
38
|
+
}
|
|
39
|
+
|
package/src/harness.js
CHANGED
|
@@ -214,16 +214,38 @@ export function harnessInit({ root = findProjectRoot(), force = false, scope = '
|
|
|
214
214
|
const block = harnessClaudeMdBlock(scope);
|
|
215
215
|
if (existsSync(cmPath)) {
|
|
216
216
|
const existing = readFileSync(cmPath, 'utf8');
|
|
217
|
-
|
|
217
|
+
const hasBegin = existing.includes(HARNESS_BLOCK_BEGIN);
|
|
218
|
+
const blockRe = new RegExp(
|
|
219
|
+
`${escapeRe(HARNESS_BLOCK_BEGIN)}[\\s\\S]*?${escapeRe(HARNESS_BLOCK_END)}\\n?`,
|
|
220
|
+
'm',
|
|
221
|
+
);
|
|
222
|
+
const hasCompleteBlock = blockRe.test(existing);
|
|
223
|
+
if (hasBegin && !hasCompleteBlock) {
|
|
224
|
+
// A begin marker with no end marker after it: the extent of our block is
|
|
225
|
+
// unknowable, so replacing or appending would either eat the user's text
|
|
226
|
+
// or leave a second block beside the broken one. Touch nothing.
|
|
227
|
+
result.skipped.push(`${cmPath} (harness block has a begin marker but no end marker — fix it manually)`);
|
|
228
|
+
result.brokenBlock = true;
|
|
229
|
+
} else if (hasCompleteBlock) {
|
|
218
230
|
// Already has a harness block — replace it in-place, preserving the
|
|
219
|
-
// user's other content above/below.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const next = existing.replace(
|
|
225
|
-
|
|
226
|
-
|
|
231
|
+
// user's other content above/below. This holds with or without --force:
|
|
232
|
+
// --force used to fall through to the append branch, which added a second
|
|
233
|
+
// block on every run.
|
|
234
|
+
// A replacer function, not the string: `$&` or `$1` in a block would be
|
|
235
|
+
// read as a replacement pattern.
|
|
236
|
+
const next = existing.replace(blockRe, () => block);
|
|
237
|
+
if (next === existing) {
|
|
238
|
+
result.skipped.push(`${cmPath} (harness block already up to date)`);
|
|
239
|
+
} else {
|
|
240
|
+
if (force) {
|
|
241
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
|
|
242
|
+
const bak = `${cmPath}.bak-${stamp}`;
|
|
243
|
+
writeFileSync(bak, existing);
|
|
244
|
+
result.backedUp.push(bak);
|
|
245
|
+
}
|
|
246
|
+
writeFileSync(cmPath, next);
|
|
247
|
+
result.wrote.push(cmPath + ' (block updated in place)');
|
|
248
|
+
}
|
|
227
249
|
} else {
|
|
228
250
|
// Backup as safety net, then APPEND the harness block to existing
|
|
229
251
|
// content (do not clobber). Users keep all their prior CLAUDE.md content;
|
|
@@ -295,7 +317,16 @@ export function harnessUninit({ root = findProjectRoot(), purgeRatchet = false,
|
|
|
295
317
|
|
|
296
318
|
if (existsSync(cmPath)) {
|
|
297
319
|
const existing = readFileSync(cmPath, 'utf8');
|
|
298
|
-
|
|
320
|
+
const beginIdx = existing.indexOf(HARNESS_BLOCK_BEGIN);
|
|
321
|
+
const uninitRe = new RegExp(
|
|
322
|
+
`\\n*${escapeRe(HARNESS_BLOCK_BEGIN)}[\\s\\S]*?${escapeRe(HARNESS_BLOCK_END)}\\n?`,
|
|
323
|
+
'm',
|
|
324
|
+
);
|
|
325
|
+
if (beginIdx !== -1 && !uninitRe.test(existing)) {
|
|
326
|
+
// Begin marker without an end marker: nothing to cut safely, and writing
|
|
327
|
+
// the file back unchanged would still report a removal.
|
|
328
|
+
result.skipped.push(`${cmPath} (harness block has a begin marker but no end marker — fix it manually)`);
|
|
329
|
+
} else if (beginIdx !== -1) {
|
|
299
330
|
const stamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
|
|
300
331
|
const bak = `${cmPath}.bak-${stamp}`;
|
|
301
332
|
writeFileSync(bak, existing);
|
|
@@ -575,7 +606,7 @@ export function harnessRmRule(n, { root = findProjectRoot(), scope = 'project',
|
|
|
575
606
|
* has explicitly disabled harness display, or when there's no CLAUDE.md and
|
|
576
607
|
* no .claude/ at all (silent in non-init'd projects so we don't nag).
|
|
577
608
|
*/
|
|
578
|
-
export function harnessStatusForStatusline(cfg, { root } = {}) {
|
|
609
|
+
export function harnessStatusForStatusline(cfg, { root, liveWindow = null } = {}) {
|
|
579
610
|
if (cfg && cfg.harness && cfg.harness.enabled === false) return null;
|
|
580
611
|
const projectRoot = root || findProjectRoot();
|
|
581
612
|
const status = harnessStatus(projectRoot);
|
|
@@ -620,7 +651,7 @@ export function harnessStatusForStatusline(cfg, { root } = {}) {
|
|
|
620
651
|
// exempt — the setting cannot change anything for them.
|
|
621
652
|
if (!warning) {
|
|
622
653
|
try {
|
|
623
|
-
warning = compactWindowWarningForStatusline(projectRoot, cfg);
|
|
654
|
+
warning = compactWindowWarningForStatusline(projectRoot, cfg, { liveWindow });
|
|
624
655
|
} catch { /* settings unreadable — stay silent */ }
|
|
625
656
|
}
|
|
626
657
|
// Below session-quality warnings: a promoted delegation rule whose
|
package/src/history.js
CHANGED
|
@@ -50,7 +50,8 @@ function hms(d = new Date()) {
|
|
|
50
50
|
|
|
51
51
|
function loadState() {
|
|
52
52
|
try {
|
|
53
|
-
|
|
53
|
+
const s = JSON.parse(readFileSync(STATE_PATH, 'utf8'));
|
|
54
|
+
return s && typeof s === 'object' && !Array.isArray(s) ? s : { chip: null, ts: null };
|
|
54
55
|
} catch {
|
|
55
56
|
return { chip: null, ts: null };
|
|
56
57
|
}
|
|
@@ -218,7 +219,10 @@ export function recordChip(chip, contextHints = {}) {
|
|
|
218
219
|
ko = `✓ 해소됨 (이전: ${chipKo(last)})`;
|
|
219
220
|
}
|
|
220
221
|
appendDayLine(en, ko, now, tips);
|
|
221
|
-
|
|
222
|
+
// The state file also holds one `cap_<window>` slot per rate-limit window
|
|
223
|
+
// (recordCapTransition). Saving the chip alone wiped them, so the next render
|
|
224
|
+
// saw a window at 90% as newly crossed and logged the cap warning again.
|
|
225
|
+
saveState({ ...state, chip: current, ts: now.toISOString() });
|
|
222
226
|
return true;
|
|
223
227
|
}
|
|
224
228
|
|