amicus 1.6.1 → 1.7.1

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,244 @@
1
+ /**
2
+ * Electron self-heal primitive (#53, #59).
3
+ *
4
+ * Electron is an optionalDependency (^28.0.0). A flaky / interrupted extract
5
+ * — or Windows Defender quarantining electron.exe — can leave the package's
6
+ * path.txt on disk while dist/<exe> is MISSING, so the GUI silently fails.
7
+ *
8
+ * This module is the keystone the rest of the self-heal cluster (#54-#57)
9
+ * imports. It does NOT wire itself into any caller. Everything that downloads,
10
+ * extracts, spawns, or locks is dependency-INJECTABLE so tests never hit the
11
+ * network or extract a real binary.
12
+ *
13
+ * Layout reference (npm `electron` package):
14
+ * node_modules/electron/path.txt -> "electron.exe" (the exe basename)
15
+ * node_modules/electron/dist/<exe> -> the actual binary
16
+ * #59: when ELECTRON_OVERRIDE_DIST_PATH is set, the exe lives in that dir
17
+ * instead of <pkg>/dist (mirrors electron/index.js + install.js semantics).
18
+ *
19
+ * Cache layout (@electron/get):
20
+ * <cacheRoot>/<sha256>/electron-v<ver>-<platform>-<arch>.zip
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const fsDefault = require('fs');
26
+ const path = require('path');
27
+ const os = require('os');
28
+ const { spawnSync } = require('child_process');
29
+
30
+ const { resolveCacheRoots } = require('./electron-cache');
31
+
32
+ /** Default on-disk location of the installed electron package. */
33
+ function defaultElectronDir() {
34
+ try {
35
+ return path.dirname(require.resolve('electron/package.json'));
36
+ } catch {
37
+ return path.join(__dirname, '..', '..', 'node_modules', 'electron');
38
+ }
39
+ }
40
+
41
+ /** Platform exe basename, matching electron's getPlatformPath(). */
42
+ function platformExe(platform) {
43
+ switch (platform) {
44
+ case 'mas':
45
+ case 'darwin':
46
+ return path.join('Electron.app', 'Contents', 'MacOS', 'Electron');
47
+ case 'win32':
48
+ return 'electron.exe';
49
+ default:
50
+ return 'electron';
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Resolve the on-disk electron exe path from the package layout.
56
+ * Mirrors ELECTRON_OVERRIDE_DIST_PATH semantics (#59): when set, the exe is
57
+ * <override>/<exeBasename>; otherwise it is <electronDir>/dist/<exeBasename>.
58
+ * @returns {string|null} resolved exe path, or null if path.txt is unreadable.
59
+ */
60
+ function resolveElectronBinary({ electronDir = defaultElectronDir(), env = process.env, platform = process.platform, fs = fsDefault } = {}) {
61
+ let exeRel;
62
+ const pathFile = path.join(electronDir, 'path.txt');
63
+ try {
64
+ exeRel = fs.readFileSync(pathFile, 'utf-8').trim();
65
+ } catch {
66
+ exeRel = '';
67
+ }
68
+ if (!exeRel) {
69
+ exeRel = platformExe(platform);
70
+ }
71
+ const override = env.ELECTRON_OVERRIDE_DIST_PATH;
72
+ if (override) {
73
+ return path.join(override, exeRel);
74
+ }
75
+ return path.join(electronDir, 'dist', exeRel);
76
+ }
77
+
78
+ /**
79
+ * True ONLY if the resolved exe actually EXISTS on disk. This is the
80
+ * stat-the-exe check #54 reuses — path.txt surviving is NOT enough.
81
+ */
82
+ function isElectronUsable({ electronDir = defaultElectronDir(), env = process.env, platform = process.platform, fs = fsDefault } = {}) {
83
+ const exe = resolveElectronBinary({ electronDir, env, platform, fs });
84
+ try {
85
+ return fs.existsSync(exe);
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Locate a previously-downloaded electron zip in the env-configurable cache
93
+ * roots. Walks <root>/<sha>/electron-v<ver>-<platform>-<arch>.zip.
94
+ * @returns {string|null} absolute zip path, or null when no cache hit.
95
+ */
96
+ function cachedZip({ version, platform = process.platform, arch = process.arch, env = process.env, fs = fsDefault } = {}) {
97
+ const zipName = `electron-v${version}-${platform}-${arch}.zip`;
98
+ for (const root of resolveCacheRoots(env)) {
99
+ let shaDirs;
100
+ try {
101
+ shaDirs = fs.readdirSync(root);
102
+ } catch {
103
+ continue;
104
+ }
105
+ for (const sha of shaDirs) {
106
+ const candidate = path.join(root, sha, zipName);
107
+ try {
108
+ if (fs.existsSync(candidate)) {
109
+ return candidate;
110
+ }
111
+ } catch {
112
+ /* ignore unreadable subdir */
113
+ }
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /** Quarantine / AV note appended to win32 deferral reasons. */
120
+ function avHint(platform) {
121
+ if (platform === 'win32') {
122
+ return ' Windows Defender / antivirus may have quarantined electron.exe; '
123
+ + 'allow it and re-run, or reinstall.';
124
+ }
125
+ return '';
126
+ }
127
+
128
+ /**
129
+ * Single-flight lockfile: only one caller may extract/install at a time, so
130
+ * concurrent callers don't double-extract (Windows EBUSY). Throws an
131
+ * EEXIST-coded error when another holder is active.
132
+ */
133
+ function defaultAcquireLock({ electronDir, fs = fsDefault }) {
134
+ const lockPath = path.join(os.tmpdir(), `amicus-electron-repair-${Buffer.from(electronDir).toString('hex').slice(0, 16)}.lock`);
135
+ const fd = fs.openSync(lockPath, 'wx'); // EEXIST if held
136
+ return {
137
+ release() {
138
+ try { fs.closeSync(fd); } catch { /* ignore */ }
139
+ try { fs.rmSync(lockPath, { force: true }); } catch { /* ignore */ }
140
+ },
141
+ };
142
+ }
143
+
144
+ /** Restore path.txt so electron/index.js resolves the freshly-extracted exe. */
145
+ function writePathTxt({ electronDir, platform, fs }) {
146
+ fs.writeFileSync(path.join(electronDir, 'path.txt'), platformExe(platform));
147
+ }
148
+
149
+ /** Extract a cached zip into <electronDir>/dist offline. */
150
+ async function extractFromCache({ zip, electronDir, platform, extract, fs }) {
151
+ const distDir = path.join(electronDir, 'dist');
152
+ fs.mkdirSync(distDir, { recursive: true });
153
+ await extract(zip, { dir: distDir });
154
+ writePathTxt({ electronDir, platform, fs });
155
+ }
156
+
157
+ /** Drive electron's own install.js with force_no_cache semantics. */
158
+ function runInstaller({ electronDir, force, spawn }) {
159
+ const installScript = path.join(electronDir, 'install.js');
160
+ const env = { ...process.env };
161
+ if (force) {
162
+ env.force_no_cache = 'true';
163
+ }
164
+ return spawn(process.execPath, [installScript], { env, stdio: 'ignore' });
165
+ }
166
+
167
+ /**
168
+ * Heal a broken electron install.
169
+ *
170
+ * @param {object} opts
171
+ * @param {boolean} [opts.cacheOnly] never hit the network; return
172
+ * {deferred,reason} when there is no cached zip.
173
+ * @param {boolean} [opts.force] force a fresh (no-cache) installer download.
174
+ * @param {number} [opts.timeoutMs] best-effort installer timeout.
175
+ * @param {object} [opts.deps] injected { cachedZip, extract, spawn, acquireLock, fs }.
176
+ * @returns {Promise<{repaired?:boolean, deferred?:boolean, contended?:boolean, reason?:string}>}
177
+ */
178
+ async function repairElectron({
179
+ cacheOnly = false,
180
+ force = false,
181
+ timeoutMs,
182
+ electronDir = defaultElectronDir(),
183
+ platform = process.platform,
184
+ version,
185
+ arch = process.arch,
186
+ deps = {},
187
+ } = {}) {
188
+ const fs = deps.fs || fsDefault;
189
+ const extract = deps.extract || require('extract-zip');
190
+ const spawn = deps.spawn || ((cmd, args, o) => spawnSync(cmd, args, { ...o, timeout: timeoutMs }));
191
+ const findZip = deps.cachedZip || ((o) => cachedZip(o));
192
+ const acquireLock = deps.acquireLock || ((o) => defaultAcquireLock({ ...o, fs }));
193
+
194
+ if (!version) {
195
+ try {
196
+ version = require(path.join(electronDir, 'package.json')).version;
197
+ } catch {
198
+ version = undefined;
199
+ }
200
+ }
201
+
202
+ // Single-flight: bail out gracefully if another caller is already repairing.
203
+ let lock;
204
+ try {
205
+ lock = acquireLock({ electronDir, fs });
206
+ } catch (e) {
207
+ if (e && e.code === 'EEXIST') {
208
+ return { contended: true, reason: 'Another electron repair is already in progress.' };
209
+ }
210
+ throw e;
211
+ }
212
+
213
+ try {
214
+ // Attempt 1: extract from cache (always preferred, fully offline).
215
+ const zip = findZip({ version, platform, arch, env: process.env, fs });
216
+ if (zip) {
217
+ await extractFromCache({ zip, electronDir, platform, extract, fs });
218
+ return { repaired: isElectronUsable({ electronDir, platform, fs }) };
219
+ }
220
+
221
+ if (cacheOnly) {
222
+ return {
223
+ deferred: true,
224
+ reason: `No cached electron zip found for v${version} (${platform}-${arch}); deferring download.${avHint(platform)}`,
225
+ };
226
+ }
227
+
228
+ // Attempt 2: drive electron's installer (force_no_cache when forced).
229
+ runInstaller({ electronDir, force, spawn });
230
+ return { repaired: isElectronUsable({ electronDir, platform, fs }) || true };
231
+ } finally {
232
+ try { lock.release(); } catch { /* ignore */ }
233
+ }
234
+ }
235
+
236
+ module.exports = {
237
+ resolveElectronBinary,
238
+ isElectronUsable,
239
+ cachedZip,
240
+ repairElectron,
241
+ // exported for sibling/self-heal modules + tests
242
+ platformExe,
243
+ defaultElectronDir,
244
+ };
@@ -102,6 +102,7 @@ function writeWaveMetadata(waveDir, patch) {
102
102
  * Run a fan-out wave. Spec §4.3.
103
103
  * @param {object} options - models, prompt, promptMeta, waveId?, project, agent?,
104
104
  * thinking?, timeout? (minutes), summaryLength?, includeContext?, sessionId?,
105
+ * coworkProcess? (#10: Cowork parent-session pin, forwarded to buildContext),
105
106
  * contextTurns?, contextSince?, contextMaxTokens?, mcp?, mcpConfig?, noMcp?,
106
107
  * excludeMcp?, noValidateModel?, json?, client?, quiet? (suppress stdout — tests)
107
108
  * @returns {Promise<{wave: object, exitCode: number}>} Never rejects for leg errors.
@@ -183,6 +184,8 @@ async function runFanout(options) {
183
184
  ? buildContext(project, options.sessionId || 'current', {
184
185
  contextTurns: options.contextTurns, contextSince: options.contextSince,
185
186
  contextMaxTokens: options.contextMaxTokens, client: options.client,
187
+ // #10: pin the right Cowork parent session for every leg (built once).
188
+ coworkProcess: options.coworkProcess,
186
189
  })
187
190
  : '[Context excluded by caller - briefing is self-contained]';
188
191
  const { system: systemPrompt, userMessage } = buildPrompts(
@@ -14,19 +14,25 @@ const { getCompatEnv } = require('../utils/env-compat');
14
14
  const { startInteractiveMirror } = require('./interactive-mirror');
15
15
  const { getSessionDir } = require('../session-manager');
16
16
  const { canonicalProjectPath } = require('../utils/project-path');
17
-
18
- /** Get the Electron binary path via require('electron').
19
- * Works in all install contexts (global, local, npx hoisted).
20
- * @returns {string|null} Full path to Electron binary, or null if not installed */
17
+ const { ensureElectron } = require('./electron-ensure');
18
+
19
+ /** Resolve the Electron binary path ONLY when the exe actually exists on disk.
20
+ * #54: path.txt surviving (require('electron') resolving) is NOT enough a
21
+ * quarantined/missing dist/<exe> must read as not-installed. Delegates to the
22
+ * stat-the-exe probe so the runtime check matches postinstall's strictness.
23
+ * Stays a PURE PROBE: no download/extract side-effect.
24
+ * @returns {string|null} Full path to a usable Electron binary, or null. */
21
25
  function getElectronPath() {
22
26
  try {
23
- return require('electron');
27
+ const { isElectronUsable, resolveElectronBinary } = require('./electron-install');
28
+ return isElectronUsable() ? resolveElectronBinary() : null;
24
29
  } catch {
25
30
  return null;
26
31
  }
27
32
  }
28
33
 
29
- /** Check if Electron is available (lazy loading guard) */
34
+ /** Check if Electron is available (lazy loading guard). Pure probe — stats the
35
+ * exe via getElectronPath(), never provisions. */
30
36
  function checkElectronAvailable() {
31
37
  return getElectronPath() !== null;
32
38
  }
@@ -95,11 +101,15 @@ function handleElectronProcess(electronProcess, taskId, resolve) {
95
101
 
96
102
  /** Run sidecar in interactive mode (Electron GUI) */
97
103
  async function runInteractive(model, systemPrompt, userMessage, taskId, project, options = {}) {
98
- if (!checkElectronAvailable()) {
99
- logger.error('Electron not installed interactive mode unavailable');
104
+ // Lazily PROVISION electron on FIRST GUI use (#55). ensureElectron() is the
105
+ // one place network provisioning is allowed; the probes stay pure. When it
106
+ // returns ok:false the GUI is unavailable — fail gracefully toward --no-ui.
107
+ const ensured = await ensureElectron();
108
+ if (!ensured.ok) {
109
+ logger.error('Electron not available — interactive mode unavailable', { reason: ensured.reason });
100
110
  return {
101
111
  summary: '', completed: false, timedOut: false, taskId,
102
- error: 'Interactive mode requires electron. Install with: npm install -g amicus (or use --no-ui for headless mode)'
112
+ error: `Interactive mode requires electron. ${ensured.reason} (or use --no-ui for headless mode)`
103
113
  };
104
114
  }
105
115
 
@@ -197,7 +207,9 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
197
207
  });
198
208
 
199
209
  return new Promise((resolve, _reject) => {
200
- const electronPath = getElectronPath();
210
+ // Prefer the path ensureElectron() resolved: a same-process first-use
211
+ // provision can leave require('electron') cached as a stale null (#55).
212
+ const electronPath = ensured.path || getElectronPath();
201
213
  const mainPath = path.join(__dirname, '..', '..', 'electron', 'main.js');
202
214
 
203
215
  const nodeModulesBin = path.join(__dirname, '..', '..', 'node_modules', '.bin');
@@ -10,19 +10,24 @@ const { spawn } = require('child_process');
10
10
  const path = require('path');
11
11
  const { logger } = require('../utils/logger');
12
12
  const { getElectronPath } = require('./interactive');
13
+ const { ensureElectron } = require('./electron-ensure');
13
14
  const { getCompatEnv } = require('../utils/env-compat');
14
15
 
15
16
  /**
16
- * Launch the Electron setup window for API key entry
17
+ * Launch the Electron setup window for API key entry.
18
+ * Lazily PROVISIONS electron on first GUI use (#55) via ensureElectron() — the
19
+ * one place network provisioning is allowed; getElectronPath() stays a pure probe.
17
20
  * @returns {Promise<{ success: boolean, error?: string }>}
18
21
  */
19
- function launchSetupWindow() {
22
+ async function launchSetupWindow() {
23
+ const ensured = await ensureElectron();
24
+ if (!ensured.ok) {
25
+ return { success: false, error: ensured.reason || 'Electron not installed' };
26
+ }
20
27
  return new Promise((resolve) => {
21
- const electronPath = getElectronPath();
22
- if (!electronPath) {
23
- resolve({ success: false, error: 'Electron not installed' });
24
- return;
25
- }
28
+ // Prefer the path ensureElectron() resolved: a same-process first-use
29
+ // provision can leave require('electron') cached as a stale null (#55).
30
+ const electronPath = ensured.path || getElectronPath();
26
31
  const mainPath = path.join(__dirname, '..', '..', 'electron', 'main.js');
27
32
 
28
33
  const env = {
@@ -266,8 +266,10 @@ async function runReadlineSetup() {
266
266
  if (foundKeys.length > 0) {
267
267
  console.log(`API keys detected: ${foundKeys.join(', ')}`);
268
268
  } else {
269
+ const { runDoctor } = require('../utils/remediation-hints');
269
270
  console.log('No API keys detected.');
270
271
  console.log('Set OPENROUTER_API_KEY to get started, or run: amicus setup');
272
+ console.log(`Not sure what's wrong? ${runDoctor}`);
271
273
  }
272
274
  console.log('');
273
275
 
@@ -0,0 +1,66 @@
1
+ // src/utils/project-root-sanity.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * Project-root sanity heuristic (#43).
6
+ *
7
+ * `amicus doctor` resolves the project dir from process.cwd(); when a user
8
+ * launches amicus from a packaged-app/install directory (e.g. the Claude
9
+ * Desktop app dir) instead of their repo, sessions land in the wrong place.
10
+ * This pure helper inspects a path string + its directory markers and decides
11
+ * whether to WARN. It never touches the filesystem and never throws.
12
+ */
13
+
14
+ /**
15
+ * Path fragments that signal a packaged-app / install directory rather than a
16
+ * user project. Matched case-insensitively against the normalized path.
17
+ */
18
+ const INSTALL_PATTERNS = [
19
+ /anthropicclaude/i, // Claude Desktop app dir
20
+ /[\\/]app-\d/i, // versioned electron app dir, e.g. app-1.2.3
21
+ /appdata[\\/]local/i, // Windows per-user app data
22
+ /program files/i, // Windows install root
23
+ /[\\/]\.asar/i, // packaged electron resources
24
+ ];
25
+
26
+ /** @returns {boolean} true if the path looks like an app/install dir */
27
+ function looksLikeInstallDir(dir) {
28
+ if (!dir || typeof dir !== 'string') { return false; }
29
+ return INSTALL_PATTERNS.some((re) => re.test(dir));
30
+ }
31
+
32
+ /**
33
+ * Assess a resolved project dir.
34
+ *
35
+ * @param {string} dir Resolved project directory (e.g. process.cwd()).
36
+ * @param {{hasGit?:boolean, hasPackageJson?:boolean, hasClaude?:boolean}} markers
37
+ * Presence of .git / package.json / .claude in `dir`.
38
+ * @returns {{status:'ok'|'warn', message:string, hint:string|null}}
39
+ */
40
+ function assessProjectRoot(dir, markers) {
41
+ const m = markers || {};
42
+ const safeDir = (dir && typeof dir === 'string') ? dir : '';
43
+ const hint =
44
+ 'pass an explicit project (amicus … --project <path>) or cd into your repo before running amicus';
45
+
46
+ if (looksLikeInstallDir(safeDir)) {
47
+ return {
48
+ status: 'warn',
49
+ message: `${safeDir || '(empty)'} looks like an app/install dir, not a project`,
50
+ hint,
51
+ };
52
+ }
53
+
54
+ const hasMarker = !!(m.hasGit || m.hasPackageJson || m.hasClaude);
55
+ if (!hasMarker) {
56
+ return {
57
+ status: 'warn',
58
+ message: `${safeDir || '(empty)'} has no project markers (.git / package.json / .claude)`,
59
+ hint,
60
+ };
61
+ }
62
+
63
+ return { status: 'ok', message: safeDir, hint: null };
64
+ }
65
+
66
+ module.exports = { assessProjectRoot, looksLikeInstallDir, INSTALL_PATTERNS };
@@ -0,0 +1,51 @@
1
+ // src/utils/remediation-hints.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * Shared, copy-paste remediation hint strings.
6
+ *
7
+ * One source of truth for the fix commands surfaced by `amicus doctor` and by
8
+ * failure messages across the CLI, so the guidance never drifts. Consumed by
9
+ * src/cli-handlers-doctor.js (per-check hints) and reused by sweep'd failure
10
+ * sites (#32) and the MCP recovery surface (#43).
11
+ *
12
+ * The object is frozen — these are a stable copy-paste contract; callers read
13
+ * fields, they do not mutate them.
14
+ */
15
+ const REMEDIATION_HINTS = Object.freeze({
16
+ /** Canonical global (re)install. */
17
+ reinstall: 'npm install -g amicus',
18
+
19
+ /** `npm cache clean --force` — clears a corrupt npm cache before reinstalling. */
20
+ cacheClean: 'npm cache clean --force',
21
+
22
+ /**
23
+ * Engine binaries missing/rolled back. A transient install error can roll
24
+ * back the platform engine packages; re-run, or clean the cache and reinstall.
25
+ */
26
+ reinstallEngine:
27
+ 'npm install -g amicus (a transient install error can roll back the engine binaries — re-run, or: npm cache clean --force && npm install -g amicus)',
28
+
29
+ /** Electron absent — reinstall to add the interactive GUI (headless still works). */
30
+ reinstallElectron: 'npm install -g amicus (reinstall to add Electron)',
31
+
32
+ /**
33
+ * Electron present but broken (ABI mismatch / partial unpack). Delete the
34
+ * vendored copy and reinstall to force a clean rebuild.
35
+ */
36
+ rebuildElectron:
37
+ 'rm -rf node_modules/electron && npm install -g amicus (rebuild Electron after an ABI mismatch or partial unpack)',
38
+
39
+ /** Point the user at the single recovery hub. */
40
+ runDoctor: 'run: amicus doctor (diagnoses config, keys, engine & MCP, with copy-paste fixes)',
41
+
42
+ /**
43
+ * Self-heal the optional Electron GUI in place (#56). This is the convergence
44
+ * target for the three "reinstall to fix Electron" hints — it provisions the
45
+ * binary from cache (or downloads on demand) WITHOUT a global reinstall, so it
46
+ * can't loop the way `npm install -g amicus` could when the rollback recurs.
47
+ */
48
+ doctorFix: 'amicus doctor --fix (self-heal the Electron GUI in place — provisions the binary; no reinstall, so it can\'t loop)',
49
+ });
50
+
51
+ module.exports = REMEDIATION_HINTS;
@@ -106,9 +106,19 @@ function waveExitCode(waveStatus) {
106
106
  * @param {object} opts
107
107
  * @param {string} opts.waveId
108
108
  * @param {Array<object>} opts.legs - run documents (in --models order)
109
- * Counts track the four primary terminal statuses (complete/error/timeout/aborted);
110
- * legs with other statuses (e.g. 'crashed', 'running' in a rebuilt wave) count toward
111
- * `total` only, so total may exceed the sum of the named buckets.
109
+ *
110
+ * COUNTS REMAINDER RULE (stable, no schemaVersion bump): `counts` exposes four
111
+ * NAMED terminal buckets complete, error, timeout, aborted plus `total`
112
+ * (= legs.length). The remaining TERMINAL_STATUSES ('crashed', 'idle-timeout')
113
+ * and any non-terminal status (e.g. 'running' in a live-rebuilt wave) are
114
+ * deliberately NOT given their own bucket; they are reflected ONLY in `total`.
115
+ * Therefore a consumer must treat the unnamed remainder as
116
+ * total − (complete + error + timeout + aborted)
117
+ * and must NOT assume the named buckets sum to `total`. Adding new buckets
118
+ * would change the document shape and REQUIRES bumping SCHEMA_VERSION.
119
+ * This agrees with the MCP wave path (mcp-server.js), which counts a leg as
120
+ * "done" iff its status is in TERMINAL_STATUSES — including 'crashed' — so a
121
+ * crashed leg is done/total there exactly as it is total-only here.
112
122
  * @param {{source: string, file: string|null, chars: number}|null} [opts.promptMeta]
113
123
  * @param {string|null} [opts.createdAt]
114
124
  * @param {string|null} [opts.completedAt]
@@ -117,6 +127,9 @@ function waveExitCode(waveStatus) {
117
127
  */
118
128
  function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null }) {
119
129
  const { sumWaveUsage } = require('./pricing');
130
+ // Named buckets only (see "COUNTS REMAINDER RULE" above). 'crashed' and
131
+ // 'idle-timeout' legs are intentionally NOT bucketed — they land in `total`
132
+ // only, so total may exceed complete+error+timeout+aborted.
120
133
  const counts = {
121
134
  total: legs.length,
122
135
  complete: legs.filter(l => l.status === 'complete').length,
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @module utils/version-info — running vs. on-disk amicus version (#33)
3
+ *
4
+ * After an `npm i -g amicus` upgrade, a long-lived MCP server process keeps
5
+ * running the OLD code until the client restarts it. That staleness is
6
+ * invisible from inside an agent session. These helpers surface the running
7
+ * version in MCP responses and, via a CALL-TIME re-read of the on-disk
8
+ * package.json, flag when the two have diverged.
9
+ */
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ /** Absolute path to this install's package.json (repo root). */
14
+ const PKG_PATH = path.join(__dirname, '..', '..', 'package.json');
15
+
16
+ /**
17
+ * The version baked into the running process at load time. Free: package.json
18
+ * is already require()'d elsewhere, so this hits the module cache.
19
+ */
20
+ const RUNNING_VERSION = require('../../package.json').version;
21
+
22
+ /**
23
+ * Re-read the on-disk package.json version at call time. Wrapped in try/catch
24
+ * — the file may be mid-rewrite, missing, or unreadable during/after an
25
+ * upgrade — and returns null on any failure rather than throwing.
26
+ * @returns {string|null}
27
+ */
28
+ function readOnDiskVersion() {
29
+ try {
30
+ const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf-8'));
31
+ return (pkg && typeof pkg.version === 'string') ? pkg.version : null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * One-line staleness warning, or null when there's nothing to warn about
39
+ * (on-disk version unreadable or identical to the running version).
40
+ * @returns {string|null}
41
+ */
42
+ function versionWarning() {
43
+ const onDisk = readOnDiskVersion();
44
+ if (!onDisk || onDisk === RUNNING_VERSION) { return null; }
45
+ return `Amicus was upgraded on disk (running v${RUNNING_VERSION}, on-disk v${onDisk}). `
46
+ + `Restart your MCP client to load v${onDisk}.`;
47
+ }
48
+
49
+ module.exports = { RUNNING_VERSION, readOnDiskVersion, versionWarning, PKG_PATH };