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.
Files changed (59) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +5 -1
  3. package/bin/cli.js +60 -19
  4. package/bin/postinstall.js +22 -0
  5. package/docs/CODEX.ko.md +44 -5
  6. package/docs/CODEX.md +74 -12
  7. package/package.json +2 -2
  8. package/src/agents.js +16 -2
  9. package/src/brief.js +17 -8
  10. package/src/claude-price.js +49 -0
  11. package/src/codex-brief.js +55 -2
  12. package/src/codex-cache-policy.js +26 -41
  13. package/src/codex-delegation.js +56 -8
  14. package/src/codex-doctor.js +1 -1
  15. package/src/codex-harness.js +63 -18
  16. package/src/codex-hooks.js +129 -20
  17. package/src/codex-ledger.js +150 -16
  18. package/src/codex-panel.js +51 -26
  19. package/src/codex-parser.js +41 -4
  20. package/src/codex-route-scan.js +14 -7
  21. package/src/codex-rule-health.js +41 -0
  22. package/src/commands/brief.js +14 -3
  23. package/src/commands/codex-delegate.js +17 -5
  24. package/src/commands/codex.js +20 -10
  25. package/src/commands/harness.js +6 -0
  26. package/src/commands/route-scan.js +4 -3
  27. package/src/compact-window.js +12 -4
  28. package/src/config.js +3 -3
  29. package/src/cost.js +62 -5
  30. package/src/ctx-window-cache.js +61 -0
  31. package/src/delegation-guard.js +15 -9
  32. package/src/doc2md.cjs +22 -8
  33. package/src/fig2md.cjs +7 -1
  34. package/src/formatters/statusline.js +22 -11
  35. package/src/formatters/table.js +19 -10
  36. package/src/gateway-auth.js +31 -3
  37. package/src/gateway-prices.js +39 -0
  38. package/src/harness.js +43 -12
  39. package/src/history.js +6 -2
  40. package/src/hook-manager.js +54 -32
  41. package/src/installer.js +286 -153
  42. package/src/korean-lint.cjs +40 -7
  43. package/src/litellm-budget.js +10 -1
  44. package/src/litellm-models.js +72 -10
  45. package/src/model-rules.js +90 -42
  46. package/src/money.js +9 -0
  47. package/src/month-spend.js +23 -12
  48. package/src/openai-prices.js +33 -0
  49. package/src/parser.js +232 -56
  50. package/src/postinstall.js +32 -0
  51. package/src/route-caps.js +74 -0
  52. package/src/route-inject.js +31 -7
  53. package/src/route-scan.js +93 -33
  54. package/src/savings-ledger.js +43 -11
  55. package/src/seed-rules.js +3 -4
  56. package/src/session-cache.js +35 -1
  57. package/src/state-file.js +43 -0
  58. package/src/stats.js +20 -5
  59. 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
+ }
@@ -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
- // Hardcoded per the user's model-fitting ratchet rule
129
- // (~/.claude/ratchet-model.md): haiku delegations get a tighter leash
130
- // (8 tool calls / 1,500 output tokens) than everything else (20 / 8,000).
131
- const capLine = isHaiku
132
- ? 'Cap for this delegation: 8 tool calls, 1,500 output tokens.'
133
- : 'Cap for this delegation: 20 tool calls, 8,000 output tokens.';
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
- const MARKITDOWN_SPEC = 'markitdown[pptx,pdf,xlsx,docx]';
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
- /** The decision rendered as the JSON Claude Code expects on stdout. */
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
- permissionDecision: decision.deny ? 'deny' : 'allow',
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', ['install', '--no-audit', '--no-fund', '--silent', FIG_PARSER_SPEC], {
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, summed from the rule registry route-scan maintains. Hidden
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 > 0
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
- if (month && Number(month.usd) > 0) {
556
- const amt = formatMoney(Number(month.usd));
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) > 0) {
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(GREEN)}${c(BOLD)}${head}${c(RESET)} ` +
600
- `${c(GREEN)}${formatMoney(Number(totals.total) || 0)}${c(RESET)}` +
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.
@@ -14,8 +14,10 @@ const C = {
14
14
  yellow: '\x1b[33m',
15
15
  cyan: '\x1b[36m',
16
16
  };
17
- // Wrap text with ANSI codes — gracefully strips to plain when NO_COLOR set.
18
- const colorOk = !process.env.NO_COLOR;
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(' ▶ 상세 처치법: ') + rb(`${CLI_NAME} last`));
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(' ▶ 상세 처치법: ') + rb(`${CLI_NAME} last`));
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', `$${cost.actual}`], costW, costA));
244
- lines.push(' ' + tableRow(['Without cache', `$${cost.noCacheCost}`], costW, costA));
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', `$${cost.savings} (${pct(cost.savingsRate)})`], costW, costA));
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', `+$${cost.extraCostIf5m}`], costW, costA));
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
@@ -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
- join(cwd, '.claude', 'settings.local.json'),
55
- join(cwd, '.claude', 'settings.json'),
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
- if (existing.includes(HARNESS_BLOCK_BEGIN) && !force) {
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
- const re = new RegExp(
221
- `${escapeRe(HARNESS_BLOCK_BEGIN)}[\\s\\S]*?${escapeRe(HARNESS_BLOCK_END)}\\n?`,
222
- 'm',
223
- );
224
- const next = existing.replace(re, block);
225
- writeFileSync(cmPath, next);
226
- result.wrote.push(cmPath + ' (block updated in place)');
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
- if (existing.includes(HARNESS_BLOCK_BEGIN)) {
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
- return JSON.parse(readFileSync(STATE_PATH, 'utf8'));
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
- saveState({ chip: current, ts: now.toISOString() });
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