amicus 1.6.1 → 1.7.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/src/mcp-server.js CHANGED
@@ -14,6 +14,7 @@ const { durationBetween } = require('./utils/result-schema');
14
14
  const { canonicalProjectPath } = require('./utils/project-path');
15
15
  const { recordSession } = require('./utils/session-index');
16
16
  const { fileURLToPath } = require('url');
17
+ const { RUNNING_VERSION, versionWarning } = require('./utils/version-info');
17
18
 
18
19
  /**
19
20
  * Elapsed run duration: time between createdAt and the run's end, bounding the
@@ -142,6 +143,17 @@ function textResult(text, isError) {
142
143
  return result;
143
144
  }
144
145
 
146
+ /**
147
+ * Append a stale-version warning content block (#33) when the on-disk
148
+ * package.json has been upgraded under the running process. No-op when in
149
+ * sync or unreadable (versionWarning() returns null). Mutates `content`.
150
+ */
151
+ function appendVersionWarning(content) {
152
+ const warn = versionWarning();
153
+ if (warn) { content.push({ type: 'text', text: warn }); }
154
+ return content;
155
+ }
156
+
145
157
  /**
146
158
  * Compute next poll hint for headless sessions.
147
159
  * @returns {{ hint: string }}
@@ -429,15 +441,17 @@ const handlers = {
429
441
  taskId: metadata.taskId, type: 'wave', status: metadata.status,
430
442
  legsComplete: done, legsTotal: legs.length, legs,
431
443
  elapsed: `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`,
444
+ version: RUNNING_VERSION,
432
445
  };
433
446
  if (metadata.status === 'crashed' || metadata.status === 'error') {
434
447
  response.reason = metadata.reason || 'Unknown error';
435
448
  }
436
- const responseText = JSON.stringify(response);
449
+ const content = [{ type: 'text', text: JSON.stringify(response) }];
450
+ appendVersionWarning(content);
437
451
  if (metadata.status === 'running') {
438
- return { content: [{ type: 'text', text: responseText }, { type: 'text', text: HEADLESS_STATUS_REMINDER }] };
452
+ content.push({ type: 'text', text: HEADLESS_STATUS_REMINDER });
439
453
  }
440
- return textResult(responseText);
454
+ return { content };
441
455
  }
442
456
 
443
457
  if (metadata.status === 'running' && metadata.pid) {
@@ -455,6 +469,7 @@ const handlers = {
455
469
  const response = {
456
470
  taskId: metadata.taskId, status: metadata.status,
457
471
  elapsed: `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`,
472
+ version: RUNNING_VERSION,
458
473
  };
459
474
  if (metadata.model) { response.model = metadata.model; }
460
475
 
@@ -479,11 +494,12 @@ const handlers = {
479
494
  if (metadata.status === 'crashed' || metadata.status === 'error') {
480
495
  response.reason = metadata.reason || 'Unknown error';
481
496
  }
482
- const responseText = JSON.stringify(response);
497
+ const content = [{ type: 'text', text: JSON.stringify(response) }];
498
+ appendVersionWarning(content);
483
499
  if (metadata.status === 'running' && metadata.headless) {
484
- return { content: [{ type: 'text', text: responseText }, { type: 'text', text: HEADLESS_STATUS_REMINDER }] };
500
+ content.push({ type: 'text', text: HEADLESS_STATUS_REMINDER });
485
501
  }
486
- return textResult(responseText);
502
+ return { content };
487
503
  },
488
504
 
489
505
  async amicus_read(input, project) {
@@ -729,6 +745,10 @@ const handlers = {
729
745
  if (input.timeout) { args.push('--timeout', String(input.timeout)); }
730
746
  if (input.summaryLength) { args.push('--summary-length', input.summaryLength); }
731
747
  if (input.includeContext === false) { args.push('--no-context'); }
748
+ // #10: forward cowork session pinning to the legs (parity with amicus_start),
749
+ // so context-inheriting fanout launched from Cowork resolves the right parent.
750
+ if (input.coworkProcess) { args.push('--cowork-process', input.coworkProcess); }
751
+ if (input.parentSession) { args.push('--session-id', input.parentSession); }
732
752
 
733
753
  try { spawnSidecarProcess(args, waveDir); } catch (err) {
734
754
  // Best-effort: never leave a pid-less wave record claiming 'running'
package/src/mcp-tools.js CHANGED
@@ -115,7 +115,7 @@ function getTools() {
115
115
  },
116
116
  {
117
117
  name: 'amicus_status',
118
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
118
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
119
119
  description:
120
120
  'Check the status of a running Amicus session. Returns status ' +
121
121
  '(running/complete), elapsed time, and progress info. Primarily ' +
@@ -279,6 +279,14 @@ function getTools() {
279
279
  includeContext: z.boolean().optional().default(true).describe(
280
280
  'Include parent conversation context (built once, shared by all legs). Set false for self-contained briefings.'
281
281
  ),
282
+ coworkProcess: z.string().optional().describe(
283
+ 'Cowork VM process name (e.g., "modest-laughing-goodall"). ' +
284
+ 'Extract from CWD: /sessions/<name>/. Required for parent context loading from Cowork.'
285
+ ),
286
+ parentSession: z.string().optional().describe(
287
+ 'Claude Code session UUID for exact context matching. ' +
288
+ 'Prevents ambiguity when multiple sessions are active in the same project.'
289
+ ),
282
290
  project: z.string().optional().describe(
283
291
  'Optional project directory path. Auto-detected from working directory if omitted.'
284
292
  ),
@@ -360,13 +368,21 @@ function getTools() {
360
368
  */
361
369
  function getGuideText() {
362
370
  const { getEffectiveAliases } = require('./utils/config');
371
+ const { RUNNING_VERSION, versionWarning } = require('./utils/version-info');
363
372
  const aliases = getEffectiveAliases();
364
373
  const aliasRows = Object.entries(aliases)
365
374
  .map(([name, model]) => `| ${name} | ${model} |`)
366
375
  .join('\n');
376
+ // #33: surface the running version (and a call-time staleness warning) so a
377
+ // post-upgrade agent session can tell it's running old code.
378
+ const warn = versionWarning();
379
+ const versionLine = `**Running amicus version:** ${RUNNING_VERSION}`
380
+ + (warn ? `\n\n> ⚠️ ${warn}` : '');
367
381
 
368
382
  return `# Amicus Usage Guide
369
383
 
384
+ ${versionLine}
385
+
370
386
  ## What Is Amicus?
371
387
  Amicus spawns parallel conversations with different LLMs and folds results back into your context.
372
388
 
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Electron download-cache root resolution (#53 helper).
3
+ *
4
+ * Split out of electron-install.js to keep that module under the 300-line
5
+ * size gate. Mirrors @electron/get's cache-root precedence:
6
+ * - electron_config_cache (npm config / .npmrc)
7
+ * - ELECTRON_CACHE (env override)
8
+ * - the platform default from env-paths('electron').cache
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const path = require('path');
14
+ const os = require('os');
15
+
16
+ /** Platform default cache dir, mirroring env-paths('electron',{suffix:''}).cache. */
17
+ function defaultCacheRoot(env = process.env) {
18
+ const home = env.HOME || os.homedir();
19
+ if (process.platform === 'win32') {
20
+ const localAppData = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
21
+ return path.join(localAppData, 'electron', 'Cache');
22
+ }
23
+ if (process.platform === 'darwin') {
24
+ return path.join(home, 'Library', 'Caches', 'electron');
25
+ }
26
+ const xdg = env.XDG_CACHE_HOME || path.join(home, '.cache');
27
+ return path.join(xdg, 'electron');
28
+ }
29
+
30
+ /**
31
+ * Ordered, de-duplicated list of cache roots to probe for a cached zip.
32
+ * @returns {string[]}
33
+ */
34
+ function resolveCacheRoots(env = process.env) {
35
+ const roots = [];
36
+ if (env.electron_config_cache) { roots.push(env.electron_config_cache); }
37
+ if (env.ELECTRON_CACHE) { roots.push(env.ELECTRON_CACHE); }
38
+ roots.push(defaultCacheRoot(env));
39
+ return [...new Set(roots.filter(Boolean))];
40
+ }
41
+
42
+ module.exports = { resolveCacheRoots, defaultCacheRoot };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * ensureElectron() — lazy first-GUI provisioning (#55).
3
+ *
4
+ * This is the ONLY entry point allowed to PROVISION (download/extract) electron,
5
+ * and only on FIRST GUI use. getElectronPath()/checkElectronAvailable() stay
6
+ * PURE PROBES: the doctor check (cli-handlers-doctor.js) and MCP amicus_setup
7
+ * (mcp-server.js) depend on that purity — making a probe provision would silently
8
+ * fetch ~170MB. Kept in its own module so src/sidecar/electron-install.js stays
9
+ * under the 300-line size gate.
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const {
15
+ isElectronUsable: defaultIsUsable,
16
+ resolveElectronBinary: defaultResolve,
17
+ repairElectron: defaultRepair,
18
+ } = require('./electron-install');
19
+ const HINTS = require('../utils/remediation-hints');
20
+
21
+ /**
22
+ * Module-level single-flight guard. Holds the in-flight (or last SUCCESSFUL)
23
+ * provision promise so repeated GUI launches in one process never re-download.
24
+ * A FAILED provision is cleared so a later launch may retry.
25
+ */
26
+ let _ensurePromise = null;
27
+
28
+ /** Test-only: clear the once-guard so each test starts single-flight-clean. */
29
+ function _resetEnsureElectron() {
30
+ _ensurePromise = null;
31
+ }
32
+
33
+ /**
34
+ * Lazily PROVISION electron on FIRST GUI use.
35
+ *
36
+ * Flow: if isElectronUsable() return the resolved path (NO repair). Otherwise
37
+ * call repairElectron({cacheOnly:false}) — network ALLOWED here, this is an
38
+ * explicit first-use provision — with progress messaging, then re-check.
39
+ *
40
+ * Single-flight: the in-flight / last-successful promise is memoized so
41
+ * concurrent and repeated launches share ONE provision. A failed attempt is
42
+ * NOT cached (the guard is cleared) so a later launch can retry.
43
+ *
44
+ * @param {object} opts
45
+ * @param {object} [opts.deps] injected
46
+ * { isElectronUsable, resolveElectronBinary, repairElectron, logProgress }.
47
+ * @param {object} [opts.repairOptions] forwarded to repairElectron (electronDir, etc.).
48
+ * @returns {Promise<{ok:boolean, path?:string, reason?:string}>}
49
+ */
50
+ function ensureElectron({ deps = {}, repairOptions = {} } = {}) {
51
+ const usable = deps.isElectronUsable || defaultIsUsable;
52
+ const resolve = deps.resolveElectronBinary || defaultResolve;
53
+ const repair = deps.repairElectron || defaultRepair;
54
+ const logProgress = deps.logProgress
55
+ || ((msg) => { try { process.stderr.write(`${msg}\n`); } catch { /* ignore */ } });
56
+
57
+ // Fast path: already provisioned. Cheap stat — safe to run every launch.
58
+ if (usable()) {
59
+ return Promise.resolve({ ok: true, path: resolve() });
60
+ }
61
+
62
+ // Single-flight: reuse an in-flight (or already-succeeded) provision.
63
+ if (_ensurePromise) { return _ensurePromise; }
64
+
65
+ _ensurePromise = (async () => {
66
+ logProgress('[amicus] Provisioning the Electron GUI binary (first GUI use, ~170MB). This runs once...');
67
+ let result;
68
+ try {
69
+ result = await repair({ cacheOnly: false, ...repairOptions });
70
+ } catch (err) {
71
+ return { ok: false, reason: `Electron provisioning failed: ${err && err.message}` };
72
+ }
73
+ if (usable()) {
74
+ logProgress('[amicus] Electron GUI ready.');
75
+ return { ok: true, path: resolve() };
76
+ }
77
+ const reason = (result && result.reason)
78
+ || `Electron could not be provisioned; the GUI is unavailable. ${HINTS.doctorFix} (or use --no-ui).`;
79
+ return { ok: false, reason };
80
+ })().then((r) => {
81
+ // Only memoize SUCCESS; a failure clears the guard so a later launch retries.
82
+ if (!r.ok) { _ensurePromise = null; }
83
+ return r;
84
+ }, (err) => {
85
+ _ensurePromise = null;
86
+ throw err;
87
+ });
88
+
89
+ return _ensurePromise;
90
+ }
91
+
92
+ module.exports = { ensureElectron, _resetEnsureElectron };
@@ -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