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.
@@ -1,8 +1,13 @@
1
1
  // src/cli-handlers-doctor.js
2
2
  'use strict';
3
3
 
4
+ const HINTS = require('./utils/remediation-hints');
5
+
4
6
  const MAX_CATALOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h (mirrors model-catalog DEFAULT_MAX_AGE_MS)
5
7
 
8
+ /** #56: keep `doctor --fix`'s electron self-heal from ever hanging on a slow disk/network. */
9
+ const FIX_TIMEOUT_MS = 90 * 1000;
10
+
6
11
  /** Default real helpers; tests override via deps. */
7
12
  function realDeps() {
8
13
  const fs = require('fs');
@@ -11,6 +16,13 @@ function realDeps() {
11
16
  return {
12
17
  nodeVersion: process.version,
13
18
  readApiKeys: () => require('./utils/api-key-store').readApiKeys(),
19
+ readApiKeyValues: () => require('./utils/api-key-store').readApiKeyValues(),
20
+ checkOpenRouterCredit: (key) => require('./utils/api-key-validation').checkOpenRouterCredit(key),
21
+ getCwd: () => process.cwd(),
22
+ readProjectMarkers: (dir) => {
23
+ const exists = (name) => { try { return fs.existsSync(path.join(dir, name)); } catch (_e) { return false; } };
24
+ return { hasGit: exists('.git'), hasPackageJson: exists('package.json'), hasClaude: exists('.claude') };
25
+ },
14
26
  getConfigDir: () => require('./utils/config').getConfigDir(),
15
27
  resolveModel: () => require('./utils/config').resolveModel(),
16
28
  readCache: () => require('./utils/model-catalog').readCache(),
@@ -27,6 +39,10 @@ function realDeps() {
27
39
  return candidates.some(p => fs.existsSync(p));
28
40
  },
29
41
  getElectronPath: () => require('./sidecar/interactive').getElectronPath(),
42
+ // #56: self-heal primitive for `doctor --fix`. Pure probe (getElectronPath)
43
+ // stays separate; repair only runs when fix is requested.
44
+ repairElectron: (opts) => require('./sidecar/electron-install').repairElectron(opts),
45
+ fix: false,
30
46
  discoverClaudeCodeMcps: () => require('./utils/mcp-discovery').discoverClaudeCodeMcps(),
31
47
  discoverCoworkMcps: () => require('./utils/mcp-discovery').discoverCoworkMcps(),
32
48
  skillInstalled: () => {
@@ -43,12 +59,19 @@ function guard(id, name, fn) {
43
59
  catch (e) { return { id, name, status: 'error', message: e.message, hint: null }; }
44
60
  }
45
61
 
62
+ /** Async variant of guard; a thrown/rejected fn becomes an error line. */
63
+ async function guardAsync(id, name, fn) {
64
+ try { return await fn(); }
65
+ catch (e) { return { id, name, status: 'error', message: e.message, hint: null }; }
66
+ }
67
+
46
68
  /**
47
- * Compose the health checks. Never throws.
69
+ * Compose the health checks. Never throws (async; awaits non-blocking
70
+ * network checks such as the OpenRouter credit probe).
48
71
  * @param {object} [depsOverride]
49
- * @returns {Array<{id,name,status,message,hint}>}
72
+ * @returns {Promise<Array<{id,name,status,message,hint}>>}
50
73
  */
51
- function runDoctorChecks(depsOverride = {}) {
74
+ async function runDoctorChecks(depsOverride = {}) {
52
75
  const d = { ...realDeps(), ...depsOverride };
53
76
  const checks = [];
54
77
 
@@ -105,19 +128,43 @@ function runDoctorChecks(depsOverride = {}) {
105
128
  checks.push(guard('opencode-bin', 'OpenCode binary', () => (
106
129
  d.hasOpencodeBinary()
107
130
  ? { id: 'opencode-bin', name: 'OpenCode binary', status: 'ok', message: 'found', hint: null }
108
- : { id: 'opencode-bin', name: 'OpenCode binary', status: 'error', message: 'not found', hint: '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)' }
131
+ : { id: 'opencode-bin', name: 'OpenCode binary', status: 'error', message: 'not found', hint: HINTS.reinstallEngine }
109
132
  )));
110
133
 
111
- checks.push(guard('electron', 'Electron (interactive GUI)', () => (
112
- d.getElectronPath()
113
- ? { id: 'electron', name: 'Electron (interactive GUI)', status: 'ok', message: 'installed', hint: null }
114
- : { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: 'not installed — headless still works', hint: 'npm install -g amicus (reinstall to add Electron)' }
115
- )));
134
+ checks.push(await guardAsync('electron', 'Electron (interactive GUI)', async () => {
135
+ if (d.getElectronPath()) {
136
+ return { id: 'electron', name: 'Electron (interactive GUI)', status: 'ok', message: 'installed', hint: null };
137
+ }
138
+ // Broken (missing / quarantined). With --fix, self-heal in place (#56):
139
+ // repairElectron provisions the binary; {deferred} (no cache, no network)
140
+ // maps to WARN — a deferred download is not a failure. Without --fix, just
141
+ // point the user at `amicus doctor --fix`.
142
+ if (d.fix) {
143
+ let res;
144
+ try {
145
+ res = await d.repairElectron({ timeoutMs: FIX_TIMEOUT_MS });
146
+ } catch (e) {
147
+ return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: `repair failed: ${e.message} — headless still works`, hint: HINTS.doctorFix };
148
+ }
149
+ res = res || {};
150
+ if (res.repaired || res.usable) {
151
+ return { id: 'electron', name: 'Electron (interactive GUI)', status: 'ok', message: 'installed (self-healed)', hint: null };
152
+ }
153
+ const why = res.reason ? ` — ${res.reason}` : '';
154
+ const detail = res.deferred
155
+ ? `deferred${why}`
156
+ : res.contended
157
+ ? `repair already in progress${why}`
158
+ : `not provisioned${why}`;
159
+ return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: `${detail} — headless still works`, hint: HINTS.doctorFix };
160
+ }
161
+ return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: 'not installed — headless still works', hint: HINTS.doctorFix };
162
+ }));
116
163
 
117
164
  checks.push(guard('skills', 'Skills installed', () => (
118
165
  d.skillInstalled()
119
166
  ? { id: 'skills', name: 'Skills installed', status: 'ok', message: '~/.claude/skills/{sidecar,second-opinion}', hint: null }
120
- : { id: 'skills', name: 'Skills installed', status: 'warn', message: 'one or both skills missing', hint: 'npm install -g amicus (re-runs the skill install)' }
167
+ : { id: 'skills', name: 'Skills installed', status: 'warn', message: 'one or both skills missing', hint: `${HINTS.reinstall} (re-runs the skill install)` }
121
168
  )));
122
169
 
123
170
  checks.push(guard('mcp', 'MCP registration', () => {
@@ -127,12 +174,37 @@ function runDoctorChecks(depsOverride = {}) {
127
174
  const inCowork = !!(cowork && cowork.amicus);
128
175
  // Primary signal: Claude Code MCP registration. Cowork/Desktop is reported as bonus only.
129
176
  if (!inCode) {
130
- return { id: 'mcp', name: 'MCP registration', status: 'warn', message: 'not registered in Claude Code', hint: 'npm install -g amicus (or install the amicus plugin)' };
177
+ return { id: 'mcp', name: 'MCP registration', status: 'warn', message: 'not registered in Claude Code', hint: `${HINTS.reinstall} (or install the amicus plugin)` };
131
178
  }
132
179
  const extra = inCowork ? ', Cowork/Desktop' : '';
133
180
  return { id: 'mcp', name: 'MCP registration', status: 'ok', message: `registered: Claude Code${extra}`, hint: null };
134
181
  }));
135
182
 
183
+ // #43: OpenRouter credit/free-tier — warns (never errors); skipped when no key.
184
+ checks.push(await guardAsync('openrouter-credit', 'OpenRouter credit', async () => {
185
+ const values = d.readApiKeyValues() || {};
186
+ const key = values.openrouter;
187
+ if (!key) {
188
+ return { id: 'openrouter-credit', name: 'OpenRouter credit', status: 'ok', message: 'no OpenRouter key — skipped', hint: null };
189
+ }
190
+ // Reuses the #38 non-blocking probe; resolves warning:null on any failure.
191
+ const res = (await d.checkOpenRouterCredit(key)) || {};
192
+ if (res.warning) {
193
+ return { id: 'openrouter-credit', name: 'OpenRouter credit', status: 'warn', message: res.warning, hint: 'Add credit at openrouter.ai/credits, or build a free council (amicus setup → option 2).' };
194
+ }
195
+ const remaining = (typeof res.limitRemaining === 'number') ? ` ($${res.limitRemaining} remaining)` : '';
196
+ return { id: 'openrouter-credit', name: 'OpenRouter credit', status: 'ok', message: `credit ok${remaining}`, hint: null };
197
+ }));
198
+
199
+ // #43: project-root sanity — warns when cwd looks like an app/install dir or lacks project markers.
200
+ checks.push(guard('project-root', 'Project root', () => {
201
+ const dir = d.getCwd();
202
+ const markers = d.readProjectMarkers(dir);
203
+ const { assessProjectRoot } = require('./utils/project-root-sanity');
204
+ const r = assessProjectRoot(dir, markers);
205
+ return { id: 'project-root', name: 'Project root', status: r.status, message: r.message, hint: r.hint };
206
+ }));
207
+
136
208
  return checks;
137
209
  }
138
210
 
@@ -151,14 +223,18 @@ function renderHuman(checks) {
151
223
  }
152
224
 
153
225
  /**
154
- * `amicus doctor [--json]`. Injectable `runChecks` for tests.
155
- * @param {{_:string[], json?:boolean}} args
226
+ * `amicus doctor [--json] [--fix]`. Injectable `runChecks` for tests.
227
+ * `--fix` (#56) self-heals fixable checks in place (electron via repairElectron).
228
+ * @param {{_:string[], json?:boolean, fix?:boolean}} args
156
229
  * @param {(deps?:object)=>Array} [runChecks]
157
230
  * @returns {Promise<number>} exit code
158
231
  */
159
232
  async function handleDoctor(args, runChecks = runDoctorChecks) {
160
233
  const useJson = !!args.json;
161
- const checks = runChecks();
234
+ // #56: --fix flows into runDoctorChecks as a dep so fixable checks (electron)
235
+ // self-heal in place. Omitted (not false) when absent so the injected
236
+ // test-double sees a clean "no fix" call.
237
+ const checks = await runChecks(args.fix ? { fix: true } : undefined);
162
238
  if (useJson) {
163
239
  const { buildDoctorDoc } = require('./utils/result-schema');
164
240
  const VERSION = require('../package.json').version;
@@ -173,6 +173,10 @@ async function handleFanout(args) {
173
173
  contextTurns: args['context-turns'],
174
174
  contextSince: args['context-since'],
175
175
  contextMaxTokens: args['context-max-tokens'],
176
+ // #10: forward the Cowork parent so MCP-spawned fanout legs pin the right
177
+ // session (mirrors handleStart's coworkProcess plumbing). Without this the
178
+ // spawned `--cowork-process` flag is dropped and buildContext gets null.
179
+ coworkProcess: args['cowork-process'],
176
180
  mcp: args.mcp,
177
181
  mcpConfig: args['mcp-config'],
178
182
  noMcp: args['no-mcp'],
package/src/cli.js CHANGED
@@ -116,6 +116,7 @@ function isBooleanFlag(key) {
116
116
  'no-ledger', // council tally: compute the record without appending to the reliability ledger
117
117
  'html', // council report: emit a self-contained HTML page
118
118
  'md', // council report: emit Markdown (default)
119
+ 'fix', // doctor: self-heal fixable checks in place (#56)
119
120
  ];
120
121
  return booleanFlags.includes(key);
121
122
  }
@@ -438,6 +439,8 @@ Subcommands for 'council':
438
439
  doctor: `
439
440
  Options for 'doctor':
440
441
  --json Machine-readable output
442
+ --fix Self-heal fixable checks in place (provisions the
443
+ Electron GUI binary; no global reinstall)
441
444
  `,
442
445
  setup: `
443
446
  Options for 'setup':
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 };