sprag-cli 3.40.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/LICENSE +21 -0
- package/README.ko.md +637 -0
- package/README.md +758 -0
- package/bin/cli.js +801 -0
- package/examples/statusline-command.ps1 +43 -0
- package/examples/statusline-command.sh +36 -0
- package/package.json +62 -0
- package/presets/cohesion/cohesion-en.md +26 -0
- package/presets/doc2md/convert.py +363 -0
- package/presets/korean-style/LICENSE-fluent-korean +21 -0
- package/presets/korean-style/fluent-korean.md +52 -0
- package/presets/korean-style/supplement.md +93 -0
- package/presets/model-rules.json +115 -0
- package/presets/ratchet-rules.json +38 -0
- package/src/advice.js +564 -0
- package/src/agents.js +52 -0
- package/src/brief.js +264 -0
- package/src/caps-cache.js +84 -0
- package/src/cli-args.js +51 -0
- package/src/cohesion.js +70 -0
- package/src/commands/brief.js +31 -0
- package/src/commands/cohesion.js +59 -0
- package/src/commands/compact-window.js +93 -0
- package/src/commands/doc2md.js +166 -0
- package/src/commands/feedback.js +132 -0
- package/src/commands/handoff.js +33 -0
- package/src/commands/harness.js +459 -0
- package/src/commands/history.js +46 -0
- package/src/commands/install.js +358 -0
- package/src/commands/korean.js +220 -0
- package/src/commands/last.js +151 -0
- package/src/commands/mode.js +46 -0
- package/src/commands/route-scan.js +454 -0
- package/src/commands/seed.js +105 -0
- package/src/commands/uninstall.js +42 -0
- package/src/commands/update-check.js +77 -0
- package/src/commands/upgrade.js +68 -0
- package/src/compact-window.js +205 -0
- package/src/config.js +232 -0
- package/src/cost.js +253 -0
- package/src/debug.js +29 -0
- package/src/demo.js +331 -0
- package/src/doc2md-ledger.cjs +227 -0
- package/src/doc2md.cjs +997 -0
- package/src/fig2md-runner.cjs +21 -0
- package/src/fig2md.cjs +191 -0
- package/src/first-run-note.js +63 -0
- package/src/format-time.js +44 -0
- package/src/formatters/csv.js +8 -0
- package/src/formatters/json.js +3 -0
- package/src/formatters/statusline.js +750 -0
- package/src/formatters/table.js +299 -0
- package/src/handoff.js +161 -0
- package/src/harness-analyzer.cjs +264 -0
- package/src/harness-templates.js +153 -0
- package/src/harness.js +613 -0
- package/src/history.js +383 -0
- package/src/hook-manager.js +96 -0
- package/src/hook.cjs +196 -0
- package/src/installer.js +614 -0
- package/src/korean-lint.cjs +303 -0
- package/src/korean-style.js +187 -0
- package/src/litellm-budget.js +223 -0
- package/src/model-alias.js +484 -0
- package/src/model-rules.js +527 -0
- package/src/month-spend.js +47 -0
- package/src/parser.js +330 -0
- package/src/paths.js +41 -0
- package/src/prompt.js +52 -0
- package/src/route-scan.js +832 -0
- package/src/savings-ledger.js +137 -0
- package/src/seed-rules.js +280 -0
- package/src/session-cache.js +160 -0
- package/src/session-records.js +188 -0
- package/src/stats.js +380 -0
- package/src/stdin-payload.js +122 -0
- package/src/subagent-records.js +214 -0
- package/src/update-check.js +201 -0
- package/src/window-labels.js +64 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent-records — read the transcripts of subagent runs a session spawned.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: rule-health used to be a PROXY. It measured the error rate
|
|
5
|
+
* of episodes the expensive model handled DIRECTLY that merely *looked*
|
|
6
|
+
* delegable by shape — never the outcome of an actual delegation. So a rule
|
|
7
|
+
* could be quietly failing every time it fired and the signal would not move.
|
|
8
|
+
* Claude Code writes each subagent run to its own transcript, which makes the
|
|
9
|
+
* real outcome measurable:
|
|
10
|
+
*
|
|
11
|
+
* ~/.claude/projects/<munged>/<sessionId>/subagents/agent-<id>.jsonl
|
|
12
|
+
* ~/.claude/projects/<munged>/<sessionId>/subagents/agent-<id>.meta.json
|
|
13
|
+
*
|
|
14
|
+
* The .jsonl is byte-identical in shape to a main transcript (`isSidechain:
|
|
15
|
+
* true`, assistant entries carrying `message.model` + `message.usage`,
|
|
16
|
+
* tool_result blocks carrying `is_error`), so collectSessionRecords parses it
|
|
17
|
+
* unchanged — including the rejection / self-corrected error filters, which
|
|
18
|
+
* must apply here for the same reason they apply to main sessions.
|
|
19
|
+
*
|
|
20
|
+
* The .meta.json carries `{ agentType, description, toolUseId, spawnDepth }`.
|
|
21
|
+
* `toolUseId` is the join key back to the Task/Agent tool_use block in the
|
|
22
|
+
* parent transcript, which is how a run is attributed to the episode (and
|
|
23
|
+
* therefore the category) that caused it.
|
|
24
|
+
*
|
|
25
|
+
* Everything is best-effort: this layout is a Claude Code internal, so a
|
|
26
|
+
* missing directory, an absent meta file, or an unparseable line degrades to
|
|
27
|
+
* less data, never to a throw. route-scan keeps its proxy signal as fallback.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
31
|
+
import { join, dirname, basename } from 'node:path';
|
|
32
|
+
import { collectSessionRecords, normalizeModelId } from './session-records.js';
|
|
33
|
+
|
|
34
|
+
/** Directory holding a session's subagent transcripts (may not exist). */
|
|
35
|
+
export function subagentDirFor(sessionPath) {
|
|
36
|
+
return join(dirname(sessionPath), basename(sessionPath, '.jsonl'), 'subagents');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function readMeta(metaPath) {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(await readFile(metaPath, 'utf8'));
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A run's model when several appear in one transcript (a harness retry can
|
|
49
|
+
* switch tiers mid-run): the one that produced the most output, since that is
|
|
50
|
+
* what dominates both the bill and the counterfactual.
|
|
51
|
+
*/
|
|
52
|
+
function primaryModel(byModelOut) {
|
|
53
|
+
let best = null;
|
|
54
|
+
let bestOut = -1;
|
|
55
|
+
for (const [model, out] of byModelOut) {
|
|
56
|
+
if (out > bestOut) { best = model; bestOut = out; }
|
|
57
|
+
}
|
|
58
|
+
return best;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Aggregate one subagent transcript into a single run record.
|
|
63
|
+
* Returns null when the file carries no billable API call.
|
|
64
|
+
*/
|
|
65
|
+
export async function collectSubagentRun(jsonlPath) {
|
|
66
|
+
let records;
|
|
67
|
+
try {
|
|
68
|
+
records = await collectSessionRecords(jsonlPath, { includeContent: false });
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (records.length === 0) return null;
|
|
73
|
+
|
|
74
|
+
const run = {
|
|
75
|
+
path: jsonlPath,
|
|
76
|
+
agentId: basename(jsonlPath, '.jsonl').replace(/^agent-/, ''),
|
|
77
|
+
agentType: null,
|
|
78
|
+
toolUseId: null,
|
|
79
|
+
spawnDepth: null,
|
|
80
|
+
model: null,
|
|
81
|
+
calls: records.length,
|
|
82
|
+
out: 0,
|
|
83
|
+
input: 0,
|
|
84
|
+
cacheCreation: 0,
|
|
85
|
+
cacheRead: 0,
|
|
86
|
+
ephemeral5m: 0,
|
|
87
|
+
ephemeral1h: 0,
|
|
88
|
+
toolErrors: 0,
|
|
89
|
+
startedAt: null,
|
|
90
|
+
endedAt: null,
|
|
91
|
+
bytes: 0,
|
|
92
|
+
};
|
|
93
|
+
const byModelOut = new Map();
|
|
94
|
+
for (const r of records) {
|
|
95
|
+
run.out += r.completion_tokens || 0;
|
|
96
|
+
run.input += r.input_tokens || 0;
|
|
97
|
+
run.cacheCreation += r.cache_creation_tokens || 0;
|
|
98
|
+
run.cacheRead += r.cache_read_tokens || 0;
|
|
99
|
+
run.ephemeral5m += r.ephemeral5m || 0;
|
|
100
|
+
run.ephemeral1h += r.ephemeral1h || 0;
|
|
101
|
+
run.toolErrors += r.toolErrors || 0;
|
|
102
|
+
const model = normalizeModelId(r.model);
|
|
103
|
+
byModelOut.set(model, (byModelOut.get(model) || 0) + (r.completion_tokens || 0));
|
|
104
|
+
if (r.timestamp) {
|
|
105
|
+
const t = Date.parse(r.timestamp);
|
|
106
|
+
if (Number.isFinite(t)) {
|
|
107
|
+
if (run.startedAt === null || t < run.startedAt) run.startedAt = t;
|
|
108
|
+
if (run.endedAt === null || t > run.endedAt) run.endedAt = t;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
run.model = primaryModel(byModelOut);
|
|
113
|
+
|
|
114
|
+
const meta = await readMeta(jsonlPath.replace(/\.jsonl$/, '.meta.json'));
|
|
115
|
+
if (meta) {
|
|
116
|
+
run.agentType = meta.agentType ?? null;
|
|
117
|
+
run.toolUseId = meta.toolUseId ?? null;
|
|
118
|
+
run.spawnDepth = meta.spawnDepth ?? null;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
run.bytes = (await stat(jsonlPath)).size;
|
|
122
|
+
} catch { /* size only feeds the rescan gate — 0 is a safe under-estimate */ }
|
|
123
|
+
|
|
124
|
+
return run;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* All subagent runs a session spawned. Empty array when the session never
|
|
129
|
+
* delegated (the common case) or the directory is unreadable.
|
|
130
|
+
*/
|
|
131
|
+
export async function collectSubagentRuns(sessionPath) {
|
|
132
|
+
const dir = subagentDirFor(sessionPath);
|
|
133
|
+
let entries;
|
|
134
|
+
try {
|
|
135
|
+
entries = await readdir(dir);
|
|
136
|
+
} catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
const runs = [];
|
|
140
|
+
for (const e of entries) {
|
|
141
|
+
if (!e.endsWith('.jsonl')) continue;
|
|
142
|
+
const run = await collectSubagentRun(join(dir, e));
|
|
143
|
+
if (run) runs.push(run);
|
|
144
|
+
}
|
|
145
|
+
return runs;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Index a session's runs for attribution: exact join by tool_use id first,
|
|
150
|
+
* with the un-joinable ones kept aside for the timestamp fallback (a run
|
|
151
|
+
* whose .meta.json is missing or predates toolUseId still happened, and
|
|
152
|
+
* dropping it would silently under-count a rule's real error rate).
|
|
153
|
+
*
|
|
154
|
+
* `unjoined` is kept for callers that want only the no-id runs; the fallback
|
|
155
|
+
* itself works off `all` minus whatever the exact pass claimed, because a run
|
|
156
|
+
* CAN carry an id that no parent episode holds (see fallbackRunsForEpisode).
|
|
157
|
+
*/
|
|
158
|
+
export function indexRuns(runs) {
|
|
159
|
+
const byToolUse = new Map();
|
|
160
|
+
const unjoined = [];
|
|
161
|
+
for (const r of runs) {
|
|
162
|
+
if (r.toolUseId) byToolUse.set(r.toolUseId, r);
|
|
163
|
+
else unjoined.push(r);
|
|
164
|
+
}
|
|
165
|
+
return { byToolUse, unjoined, all: runs };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Runs attributable to one episode. Exact tool_use ids win; anything left
|
|
170
|
+
* unjoined is matched by start time falling inside the episode's span. `used`
|
|
171
|
+
* is a shared Set of run paths across the session so no run is counted twice
|
|
172
|
+
* (an episode's span can overlap a neighbouring episode's runs).
|
|
173
|
+
*/
|
|
174
|
+
export function runsForEpisode(index, ep, used) {
|
|
175
|
+
return [...exactRunsForEpisode(index, ep, used), ...fallbackRunsForEpisode(index, ep, used)];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Runs this episode's own Task calls spawned. No guessing. */
|
|
179
|
+
export function exactRunsForEpisode(index, ep, used) {
|
|
180
|
+
const out = [];
|
|
181
|
+
for (const id of ep.delegationToolUseIds || []) {
|
|
182
|
+
const r = index.byToolUse.get(id);
|
|
183
|
+
if (r && !used.has(r.path)) { used.add(r.path); out.push(r); }
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Runs that no episode's tool_use ids claimed, matched by start time falling
|
|
190
|
+
* inside this episode's span.
|
|
191
|
+
*
|
|
192
|
+
* The pool is every unclaimed run, not just the ones without a toolUseId. A
|
|
193
|
+
* nested delegation — a subagent spawning its own subagent — records the
|
|
194
|
+
* SIBLING's tool_use id, which appears in no parent transcript and therefore
|
|
195
|
+
* matches nothing. Gating the fallback on "has no toolUseId" made having one
|
|
196
|
+
* disqualify the run from the only path that could still attribute it, so
|
|
197
|
+
* every spawnDepth >= 2 run was dropped forever (measured: 3 of 3 over 14
|
|
198
|
+
* days, ~$3.42 of savings and three runs of rule-health evidence).
|
|
199
|
+
*
|
|
200
|
+
* Run session-wide AFTER every episode's exact join, so a timestamp guess
|
|
201
|
+
* cannot take a run that another episode can prove is its own.
|
|
202
|
+
*/
|
|
203
|
+
export function fallbackRunsForEpisode(index, ep, used) {
|
|
204
|
+
const out = [];
|
|
205
|
+
if (ep.startedAt === null || ep.endedAt === null) return out;
|
|
206
|
+
for (const r of index.all) {
|
|
207
|
+
if (used.has(r.path) || r.startedAt === null) continue;
|
|
208
|
+
if (r.startedAt >= ep.startedAt && r.startedAt <= ep.endedAt) {
|
|
209
|
+
used.add(r.path);
|
|
210
|
+
out.push(r);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* update-check — "is there a newer claude-token-saver?", answered without ever
|
|
3
|
+
* blocking a render.
|
|
4
|
+
*
|
|
5
|
+
* The statusline command runs every ~300ms, so a network call on that path is
|
|
6
|
+
* out of the question. We use the same shape sindresorhus/update-notifier
|
|
7
|
+
* settled on: the foreground only ever READS a cached answer, and when that
|
|
8
|
+
* answer is older than the check interval it spawns a detached, unref'd child
|
|
9
|
+
* that refreshes the cache for the *next* render. Nothing awaits the network.
|
|
10
|
+
*
|
|
11
|
+
* State lives next to the other user-data files:
|
|
12
|
+
* { checkedAt: <ms>, latest: "3.25.0", current: "3.24.0",
|
|
13
|
+
* dismissedVersion: "3.25.0"|undefined }
|
|
14
|
+
*
|
|
15
|
+
* Opt out with CTS_NO_UPDATE_CHECK=1 or NO_UPDATE_NOTIFIER (the de-facto
|
|
16
|
+
* standard env var — anyone who set it for other CLIs meant us too).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
import { join, dirname } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { userDataDir } from './paths.js';
|
|
24
|
+
import { debug } from './debug.js';
|
|
25
|
+
|
|
26
|
+
const PKG_NAME = 'claude-token-saver';
|
|
27
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h — the registry is not a health endpoint
|
|
28
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
29
|
+
|
|
30
|
+
export function updateStatePath() {
|
|
31
|
+
return join(userDataDir(), 'update-check.json');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function updateCheckDisabled() {
|
|
35
|
+
return process.env.CTS_NO_UPDATE_CHECK === '1' || !!process.env.NO_UPDATE_NOTIFIER;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function readUpdateState() {
|
|
39
|
+
try {
|
|
40
|
+
const s = JSON.parse(readFileSync(updateStatePath(), 'utf8'));
|
|
41
|
+
return s && typeof s === 'object' ? s : {};
|
|
42
|
+
} catch {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writeUpdateState(next) {
|
|
48
|
+
const dir = userDataDir();
|
|
49
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
50
|
+
writeFileSync(updateStatePath(), JSON.stringify(next, null, 2) + '\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compare two semver-ish strings. Returns true when `a` is strictly newer than
|
|
55
|
+
* `b`. Pre-release tags (`3.25.0-beta.1`) are treated as older than the plain
|
|
56
|
+
* release, which is what we want: we never nudge anyone onto a pre-release.
|
|
57
|
+
*/
|
|
58
|
+
export function isNewer(a, b) {
|
|
59
|
+
const parse = (v) => {
|
|
60
|
+
const m = String(v || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
|
|
61
|
+
if (!m) return null;
|
|
62
|
+
return { nums: [+m[1], +m[2], +m[3]], pre: m[4] || null };
|
|
63
|
+
};
|
|
64
|
+
const pa = parse(a);
|
|
65
|
+
const pb = parse(b);
|
|
66
|
+
if (!pa || !pb) return false;
|
|
67
|
+
for (let i = 0; i < 3; i++) {
|
|
68
|
+
if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] > pb.nums[i];
|
|
69
|
+
}
|
|
70
|
+
if (pa.pre && !pb.pre) return false; // 3.25.0-beta < 3.25.0
|
|
71
|
+
if (!pa.pre && pb.pre) return true;
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The read-only accessor every render path uses.
|
|
77
|
+
*
|
|
78
|
+
* @returns {{current: string, latest: string|null, available: boolean, dismissed: boolean, stale: boolean}}
|
|
79
|
+
*/
|
|
80
|
+
export function updateStatus(currentVersion) {
|
|
81
|
+
if (updateCheckDisabled()) {
|
|
82
|
+
return { current: currentVersion, latest: null, available: false, dismissed: false, stale: false };
|
|
83
|
+
}
|
|
84
|
+
const s = readUpdateState();
|
|
85
|
+
const latest = typeof s.latest === 'string' ? s.latest : null;
|
|
86
|
+
const available = !!latest && isNewer(latest, currentVersion);
|
|
87
|
+
const age = Date.now() - (Number(s.checkedAt) || 0);
|
|
88
|
+
return {
|
|
89
|
+
current: currentVersion,
|
|
90
|
+
latest,
|
|
91
|
+
available,
|
|
92
|
+
// A version the user already declined stays out of the statusline and out
|
|
93
|
+
// of the session briefing until a newer one ships — otherwise "no thanks"
|
|
94
|
+
// means "ask me again in five minutes", forever.
|
|
95
|
+
dismissed: available && s.dismissedVersion === latest,
|
|
96
|
+
stale: age >= CHECK_INTERVAL_MS,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Fire the background refresh when the cached answer has aged out. Returns
|
|
102
|
+
* immediately in every case; the child is detached and unref'd so it cannot
|
|
103
|
+
* hold the statusline process open.
|
|
104
|
+
*/
|
|
105
|
+
export function maybeSpawnUpdateCheck(currentVersion) {
|
|
106
|
+
if (updateCheckDisabled()) return false;
|
|
107
|
+
const { stale } = updateStatus(currentVersion);
|
|
108
|
+
if (!stale) return false;
|
|
109
|
+
// Stamp the attempt before spawning. Without this, an offline machine
|
|
110
|
+
// re-spawns a doomed child on every single statusline render — several per
|
|
111
|
+
// second — because the cache never gets a fresh timestamp.
|
|
112
|
+
try {
|
|
113
|
+
writeUpdateState({ ...readUpdateState(), checkedAt: Date.now(), current: currentVersion });
|
|
114
|
+
} catch (e) {
|
|
115
|
+
debug('update-check:stamp', e);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
spawn(process.execPath, [cliEntryPath(), 'update-check', '--refresh', '--quiet'], {
|
|
120
|
+
detached: true,
|
|
121
|
+
stdio: 'ignore',
|
|
122
|
+
// Without this Windows flashes a console window, and this one
|
|
123
|
+
// re-spawns from the statusline — several times a minute.
|
|
124
|
+
windowsHide: true,
|
|
125
|
+
}).unref();
|
|
126
|
+
return true;
|
|
127
|
+
} catch (e) {
|
|
128
|
+
debug('update-check:spawn', e);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Path to this package's CLI entry point (bin/cli.js). */
|
|
134
|
+
export function cliEntryPath() {
|
|
135
|
+
return join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'cli.js');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Actually hit the registry and persist the answer. Only the detached child
|
|
140
|
+
* and the explicit `update-check --refresh` command call this.
|
|
141
|
+
*/
|
|
142
|
+
export async function refreshUpdateState(currentVersion) {
|
|
143
|
+
const controller = new AbortController();
|
|
144
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
145
|
+
try {
|
|
146
|
+
// The `latest` dist-tag endpoint returns a few hundred bytes, unlike the
|
|
147
|
+
// full packument which is megabytes for a package with this many releases.
|
|
148
|
+
const res = await fetch(`https://registry.npmjs.org/${PKG_NAME}/latest`, {
|
|
149
|
+
signal: controller.signal,
|
|
150
|
+
headers: { accept: 'application/json' },
|
|
151
|
+
});
|
|
152
|
+
if (!res.ok) throw new Error(`registry responded ${res.status}`);
|
|
153
|
+
const body = await res.json();
|
|
154
|
+
const latest = typeof body.version === 'string' ? body.version : null;
|
|
155
|
+
if (!latest) throw new Error('registry response carried no version');
|
|
156
|
+
const prev = readUpdateState();
|
|
157
|
+
const next = { ...prev, checkedAt: Date.now(), latest, current: currentVersion };
|
|
158
|
+
// A newly published version clears an older dismissal: the user declined
|
|
159
|
+
// 3.25.0, not "all future upgrades".
|
|
160
|
+
if (prev.dismissedVersion && isNewer(latest, prev.dismissedVersion)) {
|
|
161
|
+
delete next.dismissedVersion;
|
|
162
|
+
}
|
|
163
|
+
writeUpdateState(next);
|
|
164
|
+
return { ok: true, latest };
|
|
165
|
+
} catch (e) {
|
|
166
|
+
debug('update-check:refresh', e);
|
|
167
|
+
// Keep the timestamp fresh even on failure so an offline machine backs off
|
|
168
|
+
// for the full interval instead of retrying on every render.
|
|
169
|
+
try {
|
|
170
|
+
writeUpdateState({ ...readUpdateState(), checkedAt: Date.now(), current: currentVersion });
|
|
171
|
+
} catch (e2) {
|
|
172
|
+
debug('update-check:refresh-stamp', e2);
|
|
173
|
+
}
|
|
174
|
+
return { ok: false, error: String(e && e.message ? e.message : e) };
|
|
175
|
+
} finally {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Record that the user said "not now" for this exact version. */
|
|
181
|
+
export function dismissUpdate(version) {
|
|
182
|
+
const s = readUpdateState();
|
|
183
|
+
writeUpdateState({ ...s, dismissedVersion: version });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* How this copy was installed, and therefore what command upgrades it.
|
|
188
|
+
* Best-effort: the install root is the only reliable signal we have, and when
|
|
189
|
+
* it tells us nothing we fall back to the npm global install, which is how the
|
|
190
|
+
* overwhelming majority of copies got here.
|
|
191
|
+
*/
|
|
192
|
+
export function upgradeCommand() {
|
|
193
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
194
|
+
if (here.includes('/pnpm/')) return `pnpm add -g ${PKG_NAME}@latest`;
|
|
195
|
+
if (here.includes('/.bun/')) return `bun add -g ${PKG_NAME}@latest`;
|
|
196
|
+
if (here.includes('/.yarn/')) return `yarn global add ${PKG_NAME}@latest`;
|
|
197
|
+
return `npm install -g ${PKG_NAME}@latest`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export const UPDATE_CHECK_INTERVAL_MS = CHECK_INTERVAL_MS;
|
|
201
|
+
export const PACKAGE_NAME = PKG_NAME;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friendly labels + icons for `rate_limits.*` keys from Claude Code's stdin
|
|
3
|
+
* payload. Known keys (`five_hour`, `seven_day`) get curated short/long labels
|
|
4
|
+
* and dedicated icons. Unknown keys are passed through with a derived label so
|
|
5
|
+
* any future window Anthropic adds (e.g. `seven_day_sonnet`) renders without
|
|
6
|
+
* a code change.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// `short` is used by the cap-warn chip and history records (stable shape so
|
|
10
|
+
// parsers/dedup keep working). `usageLabel` is the friendlier word that the
|
|
11
|
+
// always-on usage segment renders next to the icon — empty string means the
|
|
12
|
+
// icon alone carries the meaning (5H 'session/now' is the implicit default).
|
|
13
|
+
const KNOWN = {
|
|
14
|
+
// ✦ reads as "AI token unit" (Anthropic/OpenAI/Gemini sparkle motif), more
|
|
15
|
+
// on-theme than the 🪙 coin which suggested in-app currency. Same width as
|
|
16
|
+
// 📅 in monospace terminals so the chip alignment stays stable.
|
|
17
|
+
// 'current' mirrors how `weekly` reads next to 7D — names the window in
|
|
18
|
+
// plain English so a glance at `✦ current ████▒░ 72%` tells the eye what's
|
|
19
|
+
// being measured without parsing the icon's meaning.
|
|
20
|
+
five_hour: { short: '5H', long: 'Current session', icon: '✦', usageLabel: 'current' },
|
|
21
|
+
seven_day: { short: '7D', long: 'Current week', icon: '📅', usageLabel: 'weekly' },
|
|
22
|
+
// Speculative — `/usage` shows a Sonnet-only weekly bucket, so if Anthropic
|
|
23
|
+
// ever surfaces it on stdin we render with a sensible default already.
|
|
24
|
+
seven_day_sonnet: { short: '7D-S', long: 'Current week (Sonnet)', icon: '🅂', usageLabel: 'weekly (Sonnet)' },
|
|
25
|
+
seven_day_opus: { short: '7D-O', long: 'Current week (Opus)', icon: '🅾', usageLabel: 'weekly (Opus)' },
|
|
26
|
+
// LiteLLM 게이트웨이 키 예산. rate_limits 가 없는 환경에서 cap 게이지를
|
|
27
|
+
// 대신하는 합성 윈도우라서, 여기 라벨만 있으면 나머지 렌더는 공용 경로를 탄다.
|
|
28
|
+
litellm_budget: { short: 'BUDGET', long: 'LiteLLM key budget', icon: '🔑', usageLabel: 'budget' },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function deriveShort(key) {
|
|
32
|
+
// "five_hour" → "5H"; "seven_day_sonnet" → "7DS"; arbitrary key → uppercase initials
|
|
33
|
+
const m = key.match(/^(\d+)_?([a-z]+)/);
|
|
34
|
+
if (m) {
|
|
35
|
+
const num = m[1];
|
|
36
|
+
const word = m[2];
|
|
37
|
+
const letter = word.charAt(0).toUpperCase();
|
|
38
|
+
const tail = key.slice(m[0].length);
|
|
39
|
+
const suffix = tail
|
|
40
|
+
.split('_')
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.map((s) => s.charAt(0).toUpperCase())
|
|
43
|
+
.join('');
|
|
44
|
+
return `${num}${letter}${suffix}`;
|
|
45
|
+
}
|
|
46
|
+
return key
|
|
47
|
+
.split('_')
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.map((s) => s.charAt(0).toUpperCase())
|
|
50
|
+
.join('') || key;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function deriveLong(key) {
|
|
54
|
+
return key
|
|
55
|
+
.split('_')
|
|
56
|
+
.map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
|
|
57
|
+
.join(' ');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function labelForKey(key) {
|
|
61
|
+
if (KNOWN[key]) return KNOWN[key];
|
|
62
|
+
const short = deriveShort(key);
|
|
63
|
+
return { short, long: deriveLong(key), icon: '⏱', usageLabel: short };
|
|
64
|
+
}
|