sprag-cli 3.59.0 → 3.61.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 +17 -0
- package/README.md +23 -0
- package/bin/cli.js +51 -3
- package/docs/CODEX.ko.md +69 -6
- package/docs/CODEX.md +84 -12
- package/package.json +1 -1
- package/src/agent.js +9 -0
- package/src/caps-cache.js +3 -3
- package/src/codex-brief.js +4 -5
- package/src/codex-cache-policy.js +3 -3
- package/src/codex-delegation.js +114 -15
- package/src/codex-doc2md-ledger.js +3 -3
- package/src/codex-doctor.js +2 -2
- package/src/codex-hooks.js +4 -5
- package/src/codex-installer.js +3 -3
- package/src/codex-ledger.js +25 -10
- package/src/codex-panel-session.js +3 -3
- package/src/codex-panel-state.js +51 -5
- package/src/codex-route-scan.js +12 -7
- package/src/commands/brief.js +1 -1
- package/src/commands/codex-delegate.js +81 -6
- package/src/commands/codex.js +8 -2
- package/src/commands/compact-window.js +6 -2
- package/src/commands/install.js +4 -1
- package/src/commands/korean.js +69 -3
- package/src/commands/route-scan.js +6 -4
- package/src/commands/saved.js +40 -1
- package/src/ctx-window-cache.js +14 -0
- package/src/formatters/statusline.js +167 -18
- package/src/harness-analyzer.cjs +8 -2
- package/src/harness.js +32 -23
- package/src/installer.js +52 -0
- package/src/model-alias.js +3 -3
- package/src/model-rules.js +14 -9
- package/src/preset-ratchet.js +3 -3
- package/src/reply-language.cjs +167 -0
- package/src/route-inject.js +57 -8
- package/src/route-scan.js +51 -11
- package/src/savings-ledger.js +3 -2
- package/src/seed-rules.js +32 -23
- package/src/session-cache.js +12 -4
- package/src/shared-model-rules.js +73 -0
- package/src/state-file.js +99 -2
- package/src/statusline-data.js +97 -0
- package/src/update-check.js +46 -6
package/src/state-file.js
CHANGED
|
@@ -11,8 +11,11 @@
|
|
|
11
11
|
* that does not parse is copied aside before it is replaced.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
|
|
14
|
+
import {
|
|
15
|
+
writeFileSync, renameSync, readFileSync, existsSync, mkdirSync, copyFileSync, rmSync,
|
|
16
|
+
readdirSync, lstatSync, unlinkSync,
|
|
17
|
+
} from 'node:fs';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
16
19
|
|
|
17
20
|
/** Where an unreadable state file is kept. One slot: the first copy holds the data worth recovering. */
|
|
18
21
|
export const corruptCopyPath = (file) => `${file}.corrupt`;
|
|
@@ -41,3 +44,97 @@ export function writeStateFile(file, text) {
|
|
|
41
44
|
writeFileSync(file, text);
|
|
42
45
|
}
|
|
43
46
|
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Write `data` to `tmp`, then rename it over `file`. A failure at either step
|
|
50
|
+
* removes `tmp` before the error goes on, so a full disk or a rename refused
|
|
51
|
+
* by the OS does not leave a stray `<file>.<pid>.tmp` behind. The caller picks
|
|
52
|
+
* `tmp` (pid, uuid, ...) and decides what a failure means; this only makes
|
|
53
|
+
* sure the failure cleans up after itself. `unlink` errors are ignored: the
|
|
54
|
+
* original error is the one worth reporting.
|
|
55
|
+
*/
|
|
56
|
+
export function writeViaTmp(tmp, file, data, options) {
|
|
57
|
+
try {
|
|
58
|
+
writeFileSync(tmp, data, options);
|
|
59
|
+
renameSync(tmp, file);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
try { rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A temp file older than this was left by a process that died mid-write. */
|
|
67
|
+
export const STALE_TMP_AGE_MS = 60 * 60 * 1000;
|
|
68
|
+
/** `session-cache.json.52038.tmp`: the `<state file>.<pid>.tmp` shape the writers above use. */
|
|
69
|
+
const PID_TMP_NAME = /^.+\.json\.\d+\.tmp$/;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Remove temp files that a killed process left in `dir` (top level only).
|
|
73
|
+
*
|
|
74
|
+
* Only `<name>.json.<digits>.tmp` older than an hour goes: a younger one may
|
|
75
|
+
* belong to a writer that is between its write and its rename right now, and
|
|
76
|
+
* anything with another shape is not ours to judge. Symlinks are skipped
|
|
77
|
+
* rather than followed.
|
|
78
|
+
*
|
|
79
|
+
* @returns {number} how many files were removed
|
|
80
|
+
*/
|
|
81
|
+
export function sweepStaleTmp(dir, { now = Date.now(), maxAgeMs = STALE_TMP_AGE_MS } = {}) {
|
|
82
|
+
let names;
|
|
83
|
+
try { names = readdirSync(dir); } catch { return 0; }
|
|
84
|
+
let removed = 0;
|
|
85
|
+
for (const name of names) {
|
|
86
|
+
if (!PID_TMP_NAME.test(name)) continue;
|
|
87
|
+
const path = join(dir, name);
|
|
88
|
+
try {
|
|
89
|
+
const st = lstatSync(path);
|
|
90
|
+
if (!st.isFile() || now - st.mtimeMs <= maxAgeMs) continue;
|
|
91
|
+
unlinkSync(path);
|
|
92
|
+
removed += 1;
|
|
93
|
+
} catch { /* already gone, or not ours to remove */ }
|
|
94
|
+
}
|
|
95
|
+
return removed;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const HOUSEKEEPING_FILE = 'housekeeping.json';
|
|
99
|
+
export const HOUSEKEEPING_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Run directory clean-up jobs at most once per `intervalMs` each, however
|
|
103
|
+
* often this is called. Callers sit on hot paths (the statusline saves its
|
|
104
|
+
* cache on nearly every render; the Codex panel writes state every couple of
|
|
105
|
+
* seconds), and a `readdir` there would cost far more than the files it
|
|
106
|
+
* removes. The last run of each job is kept in `housekeeping.json` beside the
|
|
107
|
+
* data: one small read per call, and the directory is listed only when a job
|
|
108
|
+
* is actually due.
|
|
109
|
+
*
|
|
110
|
+
* The run is stamped BEFORE the job starts. A job that throws, or a stamp that
|
|
111
|
+
* cannot be written, then costs one skipped day rather than a retry on every
|
|
112
|
+
* call. A stamp that cannot be written skips the jobs entirely for the same
|
|
113
|
+
* reason.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} dir
|
|
116
|
+
* @param {Record<string, () => void>} jobs - name -> job; names key the stamps
|
|
117
|
+
* @returns {string[]} names of the jobs that ran
|
|
118
|
+
*/
|
|
119
|
+
export function runHousekeeping(dir, jobs, { now = Date.now(), intervalMs = HOUSEKEEPING_INTERVAL_MS } = {}) {
|
|
120
|
+
const file = join(dir, HOUSEKEEPING_FILE);
|
|
121
|
+
let marks = {};
|
|
122
|
+
try {
|
|
123
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
124
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) marks = parsed;
|
|
125
|
+
} catch { /* first run, or an unreadable stamp file: everything is due */ }
|
|
126
|
+
// A stamp in the future (clock moved back) would otherwise silence the job
|
|
127
|
+
// until the clock catches up; treat it as due.
|
|
128
|
+
const due = Object.keys(jobs).filter((name) => {
|
|
129
|
+
const age = now - Number(marks[name]);
|
|
130
|
+
return !(Number.isFinite(age) && age >= 0 && age < intervalMs);
|
|
131
|
+
});
|
|
132
|
+
if (!due.length) return [];
|
|
133
|
+
const next = { ...marks };
|
|
134
|
+
for (const name of due) next[name] = now;
|
|
135
|
+
try { writeStateFile(file, JSON.stringify(next) + '\n'); } catch { return []; }
|
|
136
|
+
for (const name of due) {
|
|
137
|
+
try { jobs[name](); } catch { /* clean-up is best effort */ }
|
|
138
|
+
}
|
|
139
|
+
return due;
|
|
140
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statusline data helpers that need more than a number from the payload.
|
|
3
|
+
*
|
|
4
|
+
* bin/cli.js assembles the report data inline and cannot be imported (it runs
|
|
5
|
+
* on load), so the two derivations below live here where a test can reach them.
|
|
6
|
+
* Both read only what the render has already loaded or one small JSON file the
|
|
7
|
+
* statusline reads anyway; neither scans a transcript.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { resolve } from 'node:path';
|
|
11
|
+
import { modelFamily } from './savings-ledger.js';
|
|
12
|
+
import { loadModelRules } from './model-rules.js';
|
|
13
|
+
|
|
14
|
+
/** Longest family name shown before it is cut. Keeps an opaque gateway id from taking the line. */
|
|
15
|
+
const FAMILY_MAX = 14;
|
|
16
|
+
|
|
17
|
+
const shortFamily = (name) => (name.length > FAMILY_MAX ? `${name.slice(0, FAMILY_MAX - 1)}…` : name);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The subagent runs of the session the statusline is drawing for.
|
|
21
|
+
*
|
|
22
|
+
* The parser already attaches each session's subagent runs (`subagentRuns`,
|
|
23
|
+
* one entry per run with its own model), so this only finds the right session
|
|
24
|
+
* among those loaded and tallies them by model family. The session is matched
|
|
25
|
+
* by transcript path first, then by session id, because Claude Code reports
|
|
26
|
+
* both and either can be absent or spelled differently (a symlinked home).
|
|
27
|
+
*
|
|
28
|
+
* The runs are those the analysis window kept, so a session older than the
|
|
29
|
+
* window (`sprag mode` default is one day) reports only its recent runs.
|
|
30
|
+
*
|
|
31
|
+
* Families come from `modelFamily`, which returns an unmapped model id as it
|
|
32
|
+
* is rather than folding it into a neighbour; the name is shortened here so an
|
|
33
|
+
* ARN cannot flood the line.
|
|
34
|
+
*
|
|
35
|
+
* @param {Array} sessions parseAllSessions result
|
|
36
|
+
* @param {{ transcriptPath?: string|null, sessionId?: string|null }} [ids]
|
|
37
|
+
* @returns {{ total: number, families: Array<{ family: string, runs: number }> }|null}
|
|
38
|
+
* null when the session cannot be found or ran no subagents
|
|
39
|
+
*/
|
|
40
|
+
export function sessionSubagentSummary(sessions, { transcriptPath = null, sessionId = null } = {}) {
|
|
41
|
+
if (!Array.isArray(sessions) || (!transcriptPath && !sessionId)) return null;
|
|
42
|
+
let hit = null;
|
|
43
|
+
if (transcriptPath) {
|
|
44
|
+
const want = resolve(transcriptPath);
|
|
45
|
+
hit = sessions.find((s) => s && s.filePath && resolve(s.filePath) === want) || null;
|
|
46
|
+
}
|
|
47
|
+
if (!hit && sessionId) {
|
|
48
|
+
// A resumed conversation can leave more than one file under one id; the
|
|
49
|
+
// most recently active is the one still being written.
|
|
50
|
+
const end = (s) => (s.endTime ? new Date(s.endTime).getTime() || 0 : 0);
|
|
51
|
+
hit = sessions
|
|
52
|
+
.filter((s) => s && s.sessionId === sessionId)
|
|
53
|
+
.sort((a, b) => end(b) - end(a))[0] || null;
|
|
54
|
+
}
|
|
55
|
+
const runs = hit && Array.isArray(hit.subagentRuns) ? hit.subagentRuns : [];
|
|
56
|
+
if (runs.length === 0) return null;
|
|
57
|
+
const byFamily = new Map();
|
|
58
|
+
for (const r of runs) {
|
|
59
|
+
const name = shortFamily(modelFamily(r && r.model));
|
|
60
|
+
byFamily.set(name, (byFamily.get(name) || 0) + 1);
|
|
61
|
+
}
|
|
62
|
+
const families = [...byFamily.entries()]
|
|
63
|
+
.map(([family, n]) => ({ family, runs: n }))
|
|
64
|
+
.sort((a, b) => b.runs - a.runs || a.family.localeCompare(b.family));
|
|
65
|
+
return { total: runs.length, families };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* How many delegation rules apply in this project, and how many of those are
|
|
70
|
+
* flagged for review.
|
|
71
|
+
*
|
|
72
|
+
* "Apply" is the same test the `rule-health` warning uses: a global rule, or a
|
|
73
|
+
* project rule whose target is this root. A rule switched `off` is not counted.
|
|
74
|
+
* Review is `status === 'review'`, which is what model-rules.js renders as its
|
|
75
|
+
* `⚠ rule-health` marker (the marker itself is a local closure there, so the
|
|
76
|
+
* condition is restated rather than imported).
|
|
77
|
+
*
|
|
78
|
+
* @param {string} projectRoot
|
|
79
|
+
* @returns {{ total: number, review: number }}
|
|
80
|
+
*/
|
|
81
|
+
export function delegationRuleStats(projectRoot) {
|
|
82
|
+
let rules = [];
|
|
83
|
+
try {
|
|
84
|
+
rules = loadModelRules().rules;
|
|
85
|
+
} catch {
|
|
86
|
+
return { total: 0, review: 0 };
|
|
87
|
+
}
|
|
88
|
+
let total = 0;
|
|
89
|
+
let review = 0;
|
|
90
|
+
for (const r of rules) {
|
|
91
|
+
if (!r || r.status === 'off') continue;
|
|
92
|
+
if (r.scope !== 'global' && r.targetRoot !== projectRoot) continue;
|
|
93
|
+
total += 1;
|
|
94
|
+
if (r.status === 'review') review += 1;
|
|
95
|
+
}
|
|
96
|
+
return { total, review };
|
|
97
|
+
}
|
package/src/update-check.js
CHANGED
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
* answer is older than the check interval it spawns a detached, unref'd child
|
|
9
9
|
* that refreshes the cache for the *next* render. Nothing awaits the network.
|
|
10
10
|
*
|
|
11
|
+
* The cached answer is also treated as stale when the running version moved
|
|
12
|
+
* past the `latest` it holds: installing 3.59 proves a cache that says 3.57 is
|
|
13
|
+
* behind the registry, and waiting out the interval left a fresh install
|
|
14
|
+
* unaware of releases that shipped in the meantime. Only that case counts. A
|
|
15
|
+
* downgrade, or a dev checkout rendering next to the global install, changes
|
|
16
|
+
* the running version without telling us anything about the registry, and
|
|
17
|
+
* reading it as stale made two copies re-check on every alternation.
|
|
18
|
+
*
|
|
11
19
|
* State lives next to the other user-data files:
|
|
12
20
|
* { checkedAt: <ms>, latest: "3.25.0", current: "3.24.0",
|
|
13
21
|
* dismissedVersion: "3.25.0"|undefined,
|
|
@@ -42,6 +50,12 @@ const CANONICAL_NAME = 'sprag-cli';
|
|
|
42
50
|
const LEGACY_NAME = 'claude-token-saver';
|
|
43
51
|
const IS_LEGACY = PKG_NAME === LEGACY_NAME;
|
|
44
52
|
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h — the registry is not a health endpoint
|
|
53
|
+
/**
|
|
54
|
+
* Interval for the SessionStart caller. Session start happens a few times a day
|
|
55
|
+
* rather than several times a second, so it can afford to look more often than
|
|
56
|
+
* the statusline's 24h default and notice a release the same afternoon.
|
|
57
|
+
*/
|
|
58
|
+
export const SESSION_START_CHECK_INTERVAL_MS = 3 * 60 * 60 * 1000;
|
|
45
59
|
const FETCH_TIMEOUT_MS = 5000;
|
|
46
60
|
|
|
47
61
|
export function updateStatePath() {
|
|
@@ -89,12 +103,25 @@ export function isNewer(a, b) {
|
|
|
89
103
|
return false;
|
|
90
104
|
}
|
|
91
105
|
|
|
106
|
+
/** `opts.intervalMs` when it is a usable duration, else the 24h default. */
|
|
107
|
+
function checkInterval(opts) {
|
|
108
|
+
const ms = opts && opts.intervalMs;
|
|
109
|
+
return typeof ms === 'number' && Number.isFinite(ms) && ms >= 0 ? ms : CHECK_INTERVAL_MS;
|
|
110
|
+
}
|
|
111
|
+
|
|
92
112
|
/**
|
|
93
113
|
* The read-only accessor every render path uses.
|
|
94
114
|
*
|
|
115
|
+
* `stale` means "worth asking the registry again": the cached answer is older
|
|
116
|
+
* than the interval, or this copy was installed after the answer was recorded
|
|
117
|
+
* and is already newer than the `latest` it names. A cache that never recorded
|
|
118
|
+
* a version (written by an older release) is judged by age alone.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} currentVersion
|
|
121
|
+
* @param {{intervalMs?: number}} [opts] - how old a cached answer may get; defaults to 24h
|
|
95
122
|
* @returns {{current: string, latest: string|null, available: boolean, dismissed: boolean, stale: boolean}}
|
|
96
123
|
*/
|
|
97
|
-
export function updateStatus(currentVersion) {
|
|
124
|
+
export function updateStatus(currentVersion, opts) {
|
|
98
125
|
if (updateCheckDisabled()) {
|
|
99
126
|
return { current: currentVersion, latest: null, available: false, dismissed: false, stale: false };
|
|
100
127
|
}
|
|
@@ -102,6 +129,12 @@ export function updateStatus(currentVersion) {
|
|
|
102
129
|
const latest = typeof s.latest === 'string' ? s.latest : null;
|
|
103
130
|
const available = !!latest && isNewer(latest, currentVersion);
|
|
104
131
|
const age = Date.now() - (Number(s.checkedAt) || 0);
|
|
132
|
+
// Both halves are needed. The version test alone would stay true while the
|
|
133
|
+
// registry is unreachable, since the stamp below refreshes `current` but not
|
|
134
|
+
// `latest`; requiring a change since the stamp stops that from re-spawning
|
|
135
|
+
// on every render.
|
|
136
|
+
const versionChanged = typeof s.current === 'string' && s.current !== currentVersion;
|
|
137
|
+
const outrunCache = versionChanged && (!latest || isNewer(currentVersion, latest));
|
|
105
138
|
return {
|
|
106
139
|
current: currentVersion,
|
|
107
140
|
latest,
|
|
@@ -110,7 +143,7 @@ export function updateStatus(currentVersion) {
|
|
|
110
143
|
// of the session briefing until a newer one ships — otherwise "no thanks"
|
|
111
144
|
// means "ask me again in five minutes", forever.
|
|
112
145
|
dismissed: available && s.dismissedVersion === latest,
|
|
113
|
-
stale: age >=
|
|
146
|
+
stale: outrunCache || age >= checkInterval(opts),
|
|
114
147
|
// Only when they describe THIS version. A cache written for an earlier
|
|
115
148
|
// release would otherwise sell the wrong upgrade. Language is not checked
|
|
116
149
|
// here: a mismatch is corrected by the next refresh, and showing the other
|
|
@@ -123,14 +156,21 @@ export function updateStatus(currentVersion) {
|
|
|
123
156
|
* Fire the background refresh when the cached answer has aged out. Returns
|
|
124
157
|
* immediately in every case; the child is detached and unref'd so it cannot
|
|
125
158
|
* hold the statusline process open.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} currentVersion
|
|
161
|
+
* @param {{intervalMs?: number, spawnImpl?: Function}} [opts] - `intervalMs` as for
|
|
162
|
+
* updateStatus; `spawnImpl` replaces child_process.spawn (tests only)
|
|
126
163
|
*/
|
|
127
|
-
export function maybeSpawnUpdateCheck(currentVersion) {
|
|
164
|
+
export function maybeSpawnUpdateCheck(currentVersion, opts) {
|
|
128
165
|
if (updateCheckDisabled()) return false;
|
|
129
|
-
const { stale } = updateStatus(currentVersion);
|
|
166
|
+
const { stale } = updateStatus(currentVersion, opts);
|
|
130
167
|
if (!stale) return false;
|
|
168
|
+
const spawnChild = (opts && opts.spawnImpl) || spawn;
|
|
131
169
|
// Stamp the attempt before spawning. Without this, an offline machine
|
|
132
170
|
// re-spawns a doomed child on every single statusline render — several per
|
|
133
|
-
// second — because the cache never gets a fresh timestamp.
|
|
171
|
+
// second — because the cache never gets a fresh timestamp. The stamp carries
|
|
172
|
+
// `current` too, which is what stops an upgrade from reading as stale again
|
|
173
|
+
// on the very next render.
|
|
134
174
|
try {
|
|
135
175
|
writeUpdateState({ ...readUpdateState(), checkedAt: Date.now(), current: currentVersion });
|
|
136
176
|
} catch (e) {
|
|
@@ -138,7 +178,7 @@ export function maybeSpawnUpdateCheck(currentVersion) {
|
|
|
138
178
|
return false;
|
|
139
179
|
}
|
|
140
180
|
try {
|
|
141
|
-
|
|
181
|
+
spawnChild(process.execPath, [cliEntryPath(), 'update-check', '--refresh', '--quiet'], {
|
|
142
182
|
detached: true,
|
|
143
183
|
stdio: 'ignore',
|
|
144
184
|
// Without this Windows flashes a console window, and this one
|