sprag-cli 3.58.1 → 3.60.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.
@@ -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
+ }
@@ -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 >= CHECK_INTERVAL_MS,
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
- spawn(process.execPath, [cliEntryPath(), 'update-check', '--refresh', '--quiet'], {
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