sprag-cli 3.56.0 → 3.58.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 +60 -40
- package/README.md +63 -41
- package/bin/cli.js +65 -19
- package/bin/postinstall.js +22 -0
- package/docs/CODEX.ko.md +5 -1
- package/docs/CODEX.md +5 -0
- package/package.json +14 -26
- package/src/agents.js +16 -2
- package/src/brief.js +17 -8
- package/src/claude-price.js +23 -2
- package/src/codex-brief.js +35 -2
- package/src/codex-delegation.js +12 -4
- package/src/codex-harness.js +43 -9
- package/src/codex-hooks.js +24 -3
- package/src/codex-ledger.js +61 -5
- package/src/codex-panel.js +42 -22
- package/src/codex-rule-health.js +24 -1
- package/src/commands/brief.js +14 -3
- package/src/commands/codex-delegate.js +7 -4
- package/src/commands/codex.js +2 -0
- package/src/commands/harness.js +6 -0
- package/src/commands/install.js +24 -13
- package/src/compact-window.js +12 -4
- package/src/config.js +3 -3
- package/src/cost.js +5 -2
- 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 +4 -4
- package/src/formatters/table.js +18 -10
- package/src/gateway-auth.js +31 -3
- package/src/harness.js +43 -12
- package/src/history.js +6 -2
- package/src/hook-manager.js +54 -32
- package/src/installer.js +315 -158
- package/src/korean-lint.cjs +40 -7
- package/src/litellm-budget.js +10 -1
- package/src/model-rules.js +88 -41
- package/src/month-spend.js +17 -11
- package/src/parser.js +232 -56
- package/src/postinstall.js +32 -0
- package/src/prompt.js +28 -0
- package/src/route-caps.js +74 -0
- package/src/route-inject.js +31 -7
- package/src/route-scan.js +78 -26
- package/src/savings-ledger.js +33 -5
- 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/statusline-base.js +37 -0
- package/src/stdin-payload.js +36 -5
package/src/commands/brief.js
CHANGED
|
@@ -25,7 +25,10 @@ export async function run({ hasFlag }) {
|
|
|
25
25
|
const parts = [];
|
|
26
26
|
try {
|
|
27
27
|
const { runBrief } = await import('../brief.js');
|
|
28
|
-
|
|
28
|
+
// `root` is the session's cwd for the same reason routeHint takes it below:
|
|
29
|
+
// the settings that name the model and the compaction window are the
|
|
30
|
+
// project's, not those of whatever directory the hook was spawned in.
|
|
31
|
+
const out = await runBrief({ sessionId: ctx.session_id, transcriptPath: ctx.transcript_path, root: ctx.cwd || process.cwd() });
|
|
29
32
|
if (out) parts.push(out);
|
|
30
33
|
} catch (e) { debug('brief:hook', e); /* briefing is best-effort — never block a prompt */ }
|
|
31
34
|
// State the matching delegation rule as a fact about this request. Registered
|
|
@@ -34,7 +37,7 @@ export async function run({ hasFlag }) {
|
|
|
34
37
|
// the match here removes both steps. Silent unless it is sure — see
|
|
35
38
|
// src/route-inject.js.
|
|
36
39
|
try {
|
|
37
|
-
const { routeHint, sessionModelRank } = await import('../route-inject.js');
|
|
40
|
+
const { routeHint, routeMatch, matchCaps, sessionModelRank } = await import('../route-inject.js');
|
|
38
41
|
// The payload names the session but not its model, so the rank comes from
|
|
39
42
|
// the transcript: without it a Sonnet session is told to delegate to
|
|
40
43
|
// Sonnet, which saves nothing.
|
|
@@ -46,8 +49,16 @@ export async function run({ hasFlag }) {
|
|
|
46
49
|
// differ. Claude Code sends `cwd` for that reason and the PreToolUse hook
|
|
47
50
|
// already reads it (src/delegation-guard.js). A payload without the field
|
|
48
51
|
// leaves root undefined, which is the behaviour this had before.
|
|
49
|
-
const
|
|
52
|
+
const match = routeMatch(ctx.prompt, { sessionRank });
|
|
53
|
+
const hint = routeHint(ctx.prompt, { sessionRank, root: ctx.cwd, match });
|
|
50
54
|
if (hint) parts.push(hint);
|
|
55
|
+
// The delegation guard repeats these caps to the subagent, so the limit it
|
|
56
|
+
// works under is the one this hint states. A prompt with no match clears
|
|
57
|
+
// the record of the turn before it.
|
|
58
|
+
try {
|
|
59
|
+
const { recordRouteCaps } = await import('../route-caps.js');
|
|
60
|
+
recordRouteCaps(ctx.session_id, matchCaps(match));
|
|
61
|
+
} catch (e) { debug('brief:route-caps', e); }
|
|
51
62
|
} catch (e) { debug('brief:route-hint', e); }
|
|
52
63
|
if (parts.length) console.log(parts.join('\n'));
|
|
53
64
|
return;
|
|
@@ -3,7 +3,7 @@ import { configureCodexHooks } from '../codex-installer.js';
|
|
|
3
3
|
import { validateCodexTarget, loadCodexModelRules, addCodexModelRule, removeCodexModelRule } from '../codex-delegation.js';
|
|
4
4
|
import { findCodexCandidate, resolveCodexCandidate } from '../codex-route-scan.js';
|
|
5
5
|
import { codexRoutingSavedTotals, loadCodexLedger } from '../codex-ledger.js';
|
|
6
|
-
import { codexRuleHealth } from '../codex-rule-health.js';
|
|
6
|
+
import { codexRuleHealth, codexRulesInReview } from '../codex-rule-health.js';
|
|
7
7
|
import { signedUsd } from '../money.js';
|
|
8
8
|
import { codexProviderName } from '../agent.js';
|
|
9
9
|
|
|
@@ -15,7 +15,6 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
15
15
|
throw new Error(`${flag} requires a value.`);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
-
const rules = loadCodexModelRules();
|
|
19
18
|
if (sub === 'rules') {
|
|
20
19
|
if (args[2] === 'add') {
|
|
21
20
|
if (hasFlag('--global') === hasFlag('--project')) throw new Error('Choose --global or --project for the rule.');
|
|
@@ -35,10 +34,11 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
35
34
|
if (!rules.length) console.log('No Codex model rules. Claude rules are not imported.');
|
|
36
35
|
let events = [];
|
|
37
36
|
try { events = Object.values(loadCodexLedger().events); } catch { /* No ledger yet: rules list without outcomes. */ }
|
|
37
|
+
const inReview = new Set(codexRulesInReview({ rules, events, root: null }).map((x) => x.index));
|
|
38
38
|
rules.forEach((r, i) => {
|
|
39
39
|
const h = codexRuleHealth(r, { events });
|
|
40
40
|
const outcome = h.runs ? ` | measured x${h.runs}, err ${Math.round(h.rate * 100)}%, saved ${signedUsd(h.saved, 4)}` : '';
|
|
41
|
-
const warn =
|
|
41
|
+
const warn = inReview.has(i + 1) ? ` | rule-health: ${h.errs}/${h.runs} runs failed, narrow the condition or remove (delegate rules rm ${i + 1} --agent codex)` : '';
|
|
42
42
|
console.log(`#${i + 1} ${r.category} | ${r.from} -> ${r.model} | ${r.scope}${r.targetRoot ? ` ${r.targetRoot}` : ''}${outcome}${warn}`);
|
|
43
43
|
});
|
|
44
44
|
return;
|
|
@@ -59,7 +59,10 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
59
59
|
}
|
|
60
60
|
console.log(`Codex delegate: ${cfg.codex?.delegate === true ? 'on' : 'off'} (model routing and guidance)`);
|
|
61
61
|
console.log(`Default target: ${cfg.codex?.delegateTarget?.model || 'inherit (no override)'}`);
|
|
62
|
-
|
|
62
|
+
// Turning delegation off must work even when the rule file is damaged.
|
|
63
|
+
let ruleCount;
|
|
64
|
+
try { ruleCount = String(loadCodexModelRules().length); } catch (e) { ruleCount = `unreadable (${e.message})`; }
|
|
65
|
+
console.log(`Codex rules: ${ruleCount}`);
|
|
63
66
|
console.log('Explicit spawn models and custom roles are preserved. Targets must be available from your Codex provider.');
|
|
64
67
|
const saved = codexRoutingSavedTotals();
|
|
65
68
|
console.log(saved.priced
|
package/src/commands/codex.js
CHANGED
|
@@ -151,6 +151,8 @@ export async function run({ args, version }) {
|
|
|
151
151
|
if (args[1] === 'savings') {
|
|
152
152
|
const { refreshCodexLedger, loadCodexLedger, codexRoutingSavedTotals } = await import('../codex-ledger.js');
|
|
153
153
|
if (hasFlag('--refresh')) await refreshCodexLedger();
|
|
154
|
+
// The prompt hook runs this detached to price finished delegations.
|
|
155
|
+
if (hasFlag('--quiet')) return;
|
|
154
156
|
const events = Object.entries(loadCodexLedger().events).map(([id, e]) => ({ id, ...e })).sort((a, b) => b.ts - a.ts);
|
|
155
157
|
const totals = codexRoutingSavedTotals();
|
|
156
158
|
if (format === 'json') { console.log(JSON.stringify({ agent: 'codex', totals, events }, null, 2)); return; }
|
package/src/commands/harness.js
CHANGED
|
@@ -106,6 +106,12 @@ export async function run({ args, hasFlag }) {
|
|
|
106
106
|
for (const p of r.backedUp) console.log(`Backed up: ${p}`);
|
|
107
107
|
for (const p of r.wrote) console.log(`Wrote: ${p}`);
|
|
108
108
|
for (const p of r.skipped) console.log(`Skipped: ${p}`);
|
|
109
|
+
if (r.brokenBlock) {
|
|
110
|
+
// Nothing was written to CLAUDE.md, so claiming success would hide it.
|
|
111
|
+
console.log('\n🅷 Harness not initialized: repair the block markers in CLAUDE.md, then run this again.');
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
109
115
|
console.log('\n🅷 Harness initialized. Statusline will show 🅷 5/5 on next refresh.');
|
|
110
116
|
return;
|
|
111
117
|
}
|
package/src/commands/install.js
CHANGED
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* registered globally, and Codex is set up too when ~/.codex exists. `--manual`
|
|
12
12
|
* restores the step-by-step questions (they still need a terminal attached).
|
|
13
13
|
* `--yes` and `--no-input` are accepted as the explicit spelling of the default.
|
|
14
|
+
*
|
|
15
|
+
* One question survives the automatic mode: when a statusline that is not ours
|
|
16
|
+
* is already configured and a terminal is attached, the install asks whether to
|
|
17
|
+
* merge (theirs first, sprag below; default), replace, or keep. Without a
|
|
18
|
+
* terminal (postinstall, CI, pipes) it keeps theirs, as before.
|
|
14
19
|
*/
|
|
15
20
|
|
|
16
21
|
import { debug } from '../debug.js';
|
|
@@ -21,7 +26,7 @@ class SkipStep extends Error {}
|
|
|
21
26
|
export async function run({ hasFlag }) {
|
|
22
27
|
const { installAll } = await import('../installer.js');
|
|
23
28
|
const { userLanguage, languageDecided, setUserLanguage } = await import('../config.js');
|
|
24
|
-
const { canPrompt, confirm } = await import('../prompt.js');
|
|
29
|
+
const { canPrompt, confirm, choose } = await import('../prompt.js');
|
|
25
30
|
const force = hasFlag('--force');
|
|
26
31
|
// Automatic unless asked otherwise. A question per step turned the install
|
|
27
32
|
// into a dozen answers before any work, and every answer has a sensible
|
|
@@ -30,6 +35,9 @@ export async function run({ hasFlag }) {
|
|
|
30
35
|
// attached, so postinstall, CI and pipes never wait on input.
|
|
31
36
|
const manual = hasFlag('--manual') && !hasFlag('--yes') && !hasFlag('--no-input');
|
|
32
37
|
const interactive = manual && canPrompt();
|
|
38
|
+
// The foreign-statusline question is asked in the automatic install too: it
|
|
39
|
+
// is the one decision with no safe default that preserves both parties.
|
|
40
|
+
const tty = canPrompt();
|
|
33
41
|
|
|
34
42
|
// Output language, decided first because every line below it — and every
|
|
35
43
|
// briefing the hooks inject from here on — is written in it. Until now it
|
|
@@ -82,25 +90,28 @@ export async function run({ hasFlag }) {
|
|
|
82
90
|
let s = r.statusline;
|
|
83
91
|
// A different statusline is already installed. Replacing it silently
|
|
84
92
|
// would be rude and leaving it silently loses the tool's main surface,
|
|
85
|
-
// so with a human attached the install asks. Default is "
|
|
86
|
-
//
|
|
87
|
-
if (s.action === 'skipped' && s.conflict &&
|
|
93
|
+
// so with a human attached the install asks. Default is "merge": their
|
|
94
|
+
// output stays and ours goes below it, so nobody loses anything.
|
|
95
|
+
if (s.action === 'skipped' && s.conflict && tty) {
|
|
96
|
+
const { installStatusline } = await import('../installer.js');
|
|
88
97
|
console.log('');
|
|
89
98
|
console.log(lang === 'ko'
|
|
90
99
|
? ` statusline: 기존 statusline이 이미 설정되어 있습니다: ${s.existingCommand}`
|
|
91
100
|
: ` statusline: an existing statusline is already configured: ${s.existingCommand}`);
|
|
92
|
-
|
|
93
|
-
? '
|
|
94
|
-
: '
|
|
95
|
-
|
|
96
|
-
? '
|
|
97
|
-
: '
|
|
98
|
-
|
|
99
|
-
|
|
101
|
+
const pick = await choose(lang === 'ko'
|
|
102
|
+
? ' statusline을 어떻게 표시할까요?'
|
|
103
|
+
: ' How should the statusline be shown?', [
|
|
104
|
+
{ key: 'merge', label: lang === 'ko' ? '함께 표시: 기존 출력 아래에 sprag 줄을 붙입니다' : 'merge: your output first, sprag lines below' },
|
|
105
|
+
{ key: 'replace', label: lang === 'ko' ? '교체: sprag statusline만 표시하고 기존 설정은 지웁니다' : 'replace: sprag statusline only, the old one is removed' },
|
|
106
|
+
{ key: 'keep', label: lang === 'ko' ? '유지: 기존 statusline만 두고 sprag은 표시하지 않습니다' : 'keep: leave yours, no sprag statusline' },
|
|
107
|
+
], { defaultIndex: 0, defaultHint: lang === 'ko' ? '(기본값)' : '(default)' });
|
|
108
|
+
if (pick === 'merge') {
|
|
109
|
+
s = installStatusline({ merge: true });
|
|
110
|
+
} else if (pick === 'replace') {
|
|
100
111
|
s = installStatusline({ force: true });
|
|
101
112
|
} else {
|
|
102
113
|
s = { ...s, reason: lang === 'ko'
|
|
103
|
-
? '기존 statusline을 유지했습니다.
|
|
114
|
+
? '기존 statusline을 유지했습니다. 바꾸려면 `sprag install --force`'
|
|
104
115
|
: 'kept your statusline — replace later with `sprag install --force`' };
|
|
105
116
|
}
|
|
106
117
|
}
|
package/src/compact-window.js
CHANGED
|
@@ -111,6 +111,10 @@ export function isOneMillionModel(model) {
|
|
|
111
111
|
return typeof model === 'string' && ONE_M_RE.test(model);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// A window Claude Code itself reported for the session (statusline payload).
|
|
115
|
+
// Anything this large can only be the 1M window.
|
|
116
|
+
export const LIVE_1M_MIN = 900_000;
|
|
117
|
+
|
|
114
118
|
/** Effective autoCompactWindow, with the source that won. */
|
|
115
119
|
export function effectiveWindow(root) {
|
|
116
120
|
const env = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
|
|
@@ -137,10 +141,14 @@ export function effectiveWindow(root) {
|
|
|
137
141
|
*
|
|
138
142
|
* `reason` names why it is not ok: 'unset' (no autoCompactWindow anywhere) or
|
|
139
143
|
* 'too-large' (set, but above 700k — still lets context run away).
|
|
144
|
+
*
|
|
145
|
+
* `liveWindow` is the window Claude Code reported for the running session. The
|
|
146
|
+
* model id alone misses every model that is 1M without a `[1m]` suffix, so a
|
|
147
|
+
* reported size settles it whenever the caller has one.
|
|
140
148
|
*/
|
|
141
|
-
export function compactWindowStatus({ root = process.cwd() } = {}) {
|
|
149
|
+
export function compactWindowStatus({ root = process.cwd(), liveWindow = null } = {}) {
|
|
142
150
|
const { model, source: modelSource } = resolveModelId(root);
|
|
143
|
-
const is1m = isOneMillionModel(model);
|
|
151
|
+
const is1m = isOneMillionModel(model) || (Number.isFinite(liveWindow) && liveWindow >= LIVE_1M_MIN);
|
|
144
152
|
const win = effectiveWindow(root);
|
|
145
153
|
const base = {
|
|
146
154
|
model,
|
|
@@ -163,10 +171,10 @@ export function compactWindowStatus({ root = process.cwd() } = {}) {
|
|
|
163
171
|
* Statusline chip text, or null when there is nothing to say. Kept to the same
|
|
164
172
|
* short shape as the other 🅷⚠ warnings so the line stays single-width.
|
|
165
173
|
*/
|
|
166
|
-
export function compactWindowWarningForStatusline(root = process.cwd(), cfg = null) {
|
|
174
|
+
export function compactWindowWarningForStatusline(root = process.cwd(), cfg = null, { liveWindow = null } = {}) {
|
|
167
175
|
if (cfg && cfg.compactWindow && cfg.compactWindow.enabled === false) return null;
|
|
168
176
|
try {
|
|
169
|
-
const s = compactWindowStatus({ root });
|
|
177
|
+
const s = compactWindowStatus({ root, liveWindow });
|
|
170
178
|
return s.ok ? null : 'compact-window?';
|
|
171
179
|
} catch (e) {
|
|
172
180
|
debug('compact-window:statusline', e);
|
package/src/config.js
CHANGED
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
* Linux: $XDG_CONFIG_HOME/claude-token-saver/config.json or ~/.config/...
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { readFileSync,
|
|
12
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { userDataDir } from './paths.js';
|
|
15
15
|
import { labelPreference } from './statusline-mode.js';
|
|
16
|
+
import { writeStateFile } from './state-file.js';
|
|
16
17
|
|
|
17
18
|
const CONFIG_DIR = userDataDir();
|
|
18
19
|
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
|
@@ -31,8 +32,7 @@ export function loadConfig() {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
export function saveConfig(cfg) {
|
|
34
|
-
|
|
35
|
-
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
|
|
35
|
+
writeStateFile(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// Each keyword maps to a single statusline option toggle.
|
package/src/cost.js
CHANGED
|
@@ -91,8 +91,11 @@ function detectPricingTier(model) {
|
|
|
91
91
|
|
|
92
92
|
if (m.includes('haiku')) {
|
|
93
93
|
if (/haiku[-_.]?4[-_.]?5/.test(m)) return 'claude-haiku-4-5';
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
// The 3.x ids put the version first (`claude-3-5-haiku-20241022`,
|
|
95
|
+
// `claude-3-haiku-20240307`), so both orders are matched. With only the
|
|
96
|
+
// family-first order, 3.5 Haiku fell through to the 4.5 rate.
|
|
97
|
+
if (/haiku[-_.]?3[-_.]?5|3[-_.]?5[-_.]?haiku/.test(m)) return 'claude-haiku-3-5';
|
|
98
|
+
if (/haiku[-_.]?3\b|\b3[-_.]?haiku/.test(m)) return 'claude-haiku-3';
|
|
96
99
|
return 'claude-haiku-4-5';
|
|
97
100
|
}
|
|
98
101
|
|
|
@@ -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,
|
|
@@ -304,9 +304,9 @@ function buildVersionSeg(version, update, c, isIcon, verbose, g) {
|
|
|
304
304
|
* fallback line. Best-effort: never throws into the statusline (corrupted
|
|
305
305
|
* CLAUDE.md, permission issue, etc. → null).
|
|
306
306
|
*/
|
|
307
|
-
function buildHarnessSeg(c, isIcon, g) {
|
|
307
|
+
function buildHarnessSeg(c, isIcon, g, liveWindow = null) {
|
|
308
308
|
try {
|
|
309
|
-
const harnessInfo = harnessStatusForStatusline(loadConfig());
|
|
309
|
+
const harnessInfo = harnessStatusForStatusline(loadConfig(), { liveWindow });
|
|
310
310
|
if (!harnessInfo) return null;
|
|
311
311
|
const icon = isIcon ? g.harness : 'H';
|
|
312
312
|
if (harnessInfo.warning) {
|
|
@@ -491,7 +491,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
491
491
|
|
|
492
492
|
// Delegation savings — a DIFFERENT number from "Cache saved" above, which
|
|
493
493
|
// covers the prompt cache only. This one is what running work on a cheaper
|
|
494
|
-
// tier saved,
|
|
494
|
+
// tier saved, the lifetime total of the delegation ledger. Hidden
|
|
495
495
|
// when zero or absent: a permanent "$0" is noise for direct-API users and
|
|
496
496
|
// for anyone who has not delegated yet.
|
|
497
497
|
// "Routing saved" says what earned the money — work that ran on a cheaper
|
|
@@ -760,7 +760,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
760
760
|
// Silent when the project hasn't opted in (no CLAUDE.md and no .claude/);
|
|
761
761
|
// otherwise renders 🅷 5/5 (green) / 🅷 N/5 (yellow) so the user can spot
|
|
762
762
|
// a missing section at a glance and know to run `harness init`.
|
|
763
|
-
const harnessSeg = buildHarnessSeg(c, isIcon, g);
|
|
763
|
+
const harnessSeg = buildHarnessSeg(c, isIcon, g, ctxLive?.size ?? null);
|
|
764
764
|
|
|
765
765
|
// Version / upgrade chip. Read from a cache written by a detached background
|
|
766
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,17 +248,17 @@ 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));
|
|
256
264
|
if (cost.unpriced > 0) lines.push(` ${cost.unpriced} session(s) not priced by the gateway are left out.`);
|
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) {
|