amicus 1.7.1 → 1.7.3

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,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.7.1",
3
+ "version": "1.7.3",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "author": { "name": "Christian Wagner" },
6
6
  "homepage": "https://bourbondog.github.io/amicus/",
package/CHANGELOG.md CHANGED
@@ -5,6 +5,43 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.7.3] - 2026-06-30
9
+
10
+ ### Fixed
11
+ - **The Electron self-heal no longer wedges itself.** A repair that was killed or hung mid-run (or a
12
+ pre-1.7.3 build) could leave an orphaned single-flight lockfile, after which *every* subsequent repair
13
+ — including `amicus doctor --fix` and the GUI launch — reported "another electron repair is already in
14
+ progress" and did nothing. The lock now records the holder's PID + timestamp and reclaims an orphaned
15
+ lock (dead holder, older than a 15-minute TTL, or the old empty format), so the GUI can self-heal
16
+ again; a live, recent holder still yields honest contention (no double-extract). The controlled
17
+ download is time-boxed (and the last-resort installer bounded) so a stalled fetch can't recreate the
18
+ stuck lock. **After upgrading, an already-stuck lock clears itself on the next repair.**
19
+
20
+ ## [1.7.2] - 2026-06-30
21
+
22
+ The Electron self-heal now tells the truth, heals the cases it can, and clearly explains the ones it can't.
23
+
24
+ ### Fixed
25
+ - **The Electron self-heal no longer claims success when it didn't heal.** `repairElectron`'s
26
+ installer-fallback path always reported the GUI as "provisioned"/"fixed" even when the binary wasn't
27
+ actually on disk — so `amicus doctor --fix` and the install-time prewarm could falsely report
28
+ success. Every self-heal / provision path now declares success **only** when the Electron binary is
29
+ verified present on disk.
30
+
31
+ ### Changed
32
+ - **The GUI repair is now controlled and introspectable.** Instead of blindly re-running Electron's own
33
+ installer (the postinstall npm had already silently suppressed), the repair downloads and extracts the
34
+ binary itself (via `@electron/get`) and verifies the result; a corrupt cached download is cleared and
35
+ re-fetched once.
36
+ - **Antivirus quarantine is detected and explained, not retried forever.** When Windows Defender / AV
37
+ removes `electron.exe` right after extraction (the common Windows failure), amicus now tells you to
38
+ allow-list the binary and re-run `amicus doctor --fix`, instead of silently looping a repair that
39
+ cannot win.
40
+ - **Clear, actionable error when the OpenCode engine binary is missing.** The engine ships via
41
+ per-platform binaries that npm can silently skip (or AV can quarantine); when it's absent, amicus now
42
+ surfaces a specific instruction (run `amicus doctor`, reinstall, allow-list `opencode.exe`) instead of
43
+ an opaque spawn failure.
44
+
8
45
  ## [1.7.1] - 2026-06-30
9
46
 
10
47
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.7.1",
3
+ "version": "1.7.3",
4
4
  "description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
5
5
  "keywords": [
6
6
  "claude",
@@ -216,16 +216,29 @@ async function provisionElectron(deps = {}) {
216
216
  if (process.env.AMICUS_PREFETCH_ELECTRON === '1') {
217
217
  console.log('[amicus] AMICUS_PREFETCH_ELECTRON=1 — prewarming the Electron GUI binary (may download)...');
218
218
  const forced = await _repair({ force: true });
219
- if (forced && (forced.repaired || forced.usable)) {
219
+ if (forced && forced.repaired) {
220
220
  console.log('[amicus] Electron GUI binary prewarmed.');
221
221
  return;
222
222
  }
223
+ if (forced && forced.quarantined) {
224
+ console.warn(`[amicus] Note: the Electron GUI binary could not be installed — ${forced.reason || 'antivirus quarantine.'}`);
225
+ console.warn('[amicus] Headless runs and the council already work without the GUI.');
226
+ return;
227
+ }
223
228
  console.warn('[amicus] Note: Electron prewarm did not complete now — the GUI provisions on first use.');
224
229
  return;
225
230
  }
226
231
 
227
232
  const result = await _repair({ cacheOnly: true, timeoutMs: PROVISION_TIMEOUT_MS });
228
- if (result && (result.repaired || result.usable)) { return; }
233
+ if (result && result.repaired) { return; }
234
+ // AV quarantine (electron.exe deleted right after extract) needs ACTION, not
235
+ // a generic "provisions on first use" notice — re-extracting can never win,
236
+ // so print the allow-list instruction verbatim instead. (No retry loop.)
237
+ if (result && result.quarantined) {
238
+ console.warn(`[amicus] Note: the Electron GUI binary could not be installed — ${result.reason || 'antivirus quarantine.'}`);
239
+ console.warn('[amicus] Headless runs and the council already work without the GUI.');
240
+ return;
241
+ }
229
242
  // No cache hit (deferred), contended, or otherwise not provisioned now.
230
243
  console.warn('[amicus] Note: the Electron GUI binary is not provisioned yet — it will download on first use of the interactive GUI / setup-wizard.');
231
244
  console.warn(`[amicus] Headless runs and the council already work. To provision the GUI now: ${HINTS.doctorFix}`);
@@ -29,14 +29,10 @@ function realDeps() {
29
29
  collectAliasSources: () => require('./utils/alias-audit').collectAliasSources(),
30
30
  findStaleAliases: (s, c) => require('./utils/alias-audit').findStaleAliases(s, c),
31
31
  hasOpencodeBinary: () => {
32
- const { ensureNodeModulesBinInPath } = require('./utils/path-setup');
32
+ // Single source of truth shared with the runtime server-start guard.
33
+ const { ensureNodeModulesBinInPath, hasOpencodeBinary } = require('./utils/path-setup');
33
34
  ensureNodeModulesBinInPath();
34
- const root = path.join(__dirname, '..', 'node_modules');
35
- const candidates = process.platform === 'win32'
36
- ? [path.join(root, `opencode-windows-${os.arch() === 'arm64' ? 'arm64' : 'x64'}`, 'bin', 'opencode.exe'),
37
- path.join(root, `opencode-windows-${os.arch() === 'arm64' ? 'arm64' : 'x64'}-baseline`, 'bin', 'opencode.exe')]
38
- : [path.join(root, '.bin', 'opencode')];
39
- return candidates.some(p => fs.existsSync(p));
35
+ return hasOpencodeBinary();
40
36
  },
41
37
  getElectronPath: () => require('./sidecar/interactive').getElectronPath(),
42
38
  // #56: self-heal primitive for `doctor --fix`. Pure probe (getElectronPath)
@@ -128,7 +124,7 @@ async function runDoctorChecks(depsOverride = {}) {
128
124
  checks.push(guard('opencode-bin', 'OpenCode binary', () => (
129
125
  d.hasOpencodeBinary()
130
126
  ? { id: 'opencode-bin', name: 'OpenCode binary', status: 'ok', message: 'found', hint: null }
131
- : { id: 'opencode-bin', name: 'OpenCode binary', status: 'error', message: 'not found', hint: HINTS.reinstallEngine }
127
+ : { id: 'opencode-bin', name: 'OpenCode binary', status: 'error', message: 'not found', hint: HINTS.reinstallEngineAv }
132
128
  )));
133
129
 
134
130
  checks.push(await guardAsync('electron', 'Electron (interactive GUI)', async () => {
@@ -147,15 +143,20 @@ async function runDoctorChecks(depsOverride = {}) {
147
143
  return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: `repair failed: ${e.message} — headless still works`, hint: HINTS.doctorFix };
148
144
  }
149
145
  res = res || {};
150
- if (res.repaired || res.usable) {
146
+ if (res.repaired) {
151
147
  return { id: 'electron', name: 'Electron (interactive GUI)', status: 'ok', message: 'installed (self-healed)', hint: null };
152
148
  }
153
149
  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}`;
150
+ // Quarantine (AV deleted electron.exe post-extract) is NOT a deferral and
151
+ // must NEVER be silently retried: surface the allow-list instruction as a
152
+ // WARN and STOP. No re-run of repairElectron here (no loop).
153
+ const detail = res.quarantined
154
+ ? `antivirus quarantine${why}`
155
+ : res.deferred
156
+ ? `deferred${why}`
157
+ : res.contended
158
+ ? `repair already in progress${why}`
159
+ : `not provisioned${why}`;
159
160
  return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: `${detail} — headless still works`, hint: HINTS.doctorFix };
160
161
  }
161
162
  return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: 'not installed — headless still works', hint: HINTS.doctorFix };
@@ -565,6 +565,20 @@ function buildServerHandle(sdkServer, deps = {}) {
565
565
  * @returns {Promise<{client: object, server: {url: string, close: Function}}>}
566
566
  */
567
567
  async function startServer(options = {}) {
568
+ // Fail fast with a CLEAR, actionable error when the opencode engine binary
569
+ // is absent (skipped optionalDependency install or AV quarantine). Without
570
+ // this, createOpencodeServer → spawn('opencode') fails with an opaque ENOENT.
571
+ // Checked BEFORE the SDK is loaded so the message is the first thing the user
572
+ // sees. `_hasOpencodeBinary` is a test seam; default is the shared resolver
573
+ // (single source of truth with `amicus doctor`). NOTE: this does NOT
574
+ // auto-repair — re-running the opencode postinstall is the same flaky trap.
575
+ const hasOpencodeBinary = options._hasOpencodeBinary
576
+ || require('./utils/path-setup').hasOpencodeBinary;
577
+ if (!hasOpencodeBinary()) {
578
+ const HINTS = require('./utils/remediation-hints');
579
+ throw new Error(HINTS.engineMissing);
580
+ }
581
+
568
582
  const createOpencodeServer = await getCreateOpencodeServer();
569
583
  const serverOptions = buildServerOptions(options);
570
584
 
@@ -15,19 +15,18 @@
15
15
  * node_modules/electron/dist/<exe> -> the actual binary
16
16
  * #59: when ELECTRON_OVERRIDE_DIST_PATH is set, the exe lives in that dir
17
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
18
+ * Cache layout (@electron/get): <cacheRoot>/<sha256>/electron-v<ver>-<platform>-<arch>.zip
21
19
  */
22
20
 
23
21
  'use strict';
24
22
 
25
23
  const fsDefault = require('fs');
26
24
  const path = require('path');
27
- const os = require('os');
28
25
  const { spawnSync } = require('child_process');
29
26
 
30
27
  const { resolveCacheRoots } = require('./electron-cache');
28
+ const { avHint, verifyExtractOutcome: verifyQuarantine } = require('./electron-quarantine');
29
+ const { acquireRepairLock } = require('./electron-lock');
31
30
 
32
31
  /** Default on-disk location of the installed electron package. */
33
32
  function defaultElectronDir() {
@@ -116,31 +115,6 @@ function cachedZip({ version, platform = process.platform, arch = process.arch,
116
115
  return null;
117
116
  }
118
117
 
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
118
  /** Restore path.txt so electron/index.js resolves the freshly-extracted exe. */
145
119
  function writePathTxt({ electronDir, platform, fs }) {
146
120
  fs.writeFileSync(path.join(electronDir, 'path.txt'), platformExe(platform));
@@ -154,6 +128,45 @@ async function extractFromCache({ zip, electronDir, platform, extract, fs }) {
154
128
  writePathTxt({ electronDir, platform, fs });
155
129
  }
156
130
 
131
+ /** Best-effort cache root for downloadArtifact (first resolved root). */
132
+ function cacheRootFor(env = process.env) {
133
+ return resolveCacheRoots(env)[0];
134
+ }
135
+
136
+ /**
137
+ * CONTROLLED provision: fetch the zip ourselves with the SAME @electron/get
138
+ * api install.js uses (downloadArtifact, force:true), extract offline, and let
139
+ * the caller verify isElectronUsable(). No blind install.js spawn.
140
+ * @returns {Promise<void>}
141
+ */
142
+ async function controlledProvision({
143
+ electronDir, platform, arch, version, downloadArtifact, extract, fs, env = process.env,
144
+ }) {
145
+ const zip = await downloadArtifact({
146
+ version,
147
+ artifactName: 'electron',
148
+ force: true,
149
+ cacheRoot: cacheRootFor(env),
150
+ platform,
151
+ arch,
152
+ checksums: undefined,
153
+ // Bound the fetch so a stalled/blocked network aborts (got v11 timeouts:
154
+ // socket = inactivity, request = total) instead of hanging the repair —
155
+ // a hung-then-killed download is what orphaned the single-flight lock.
156
+ downloadOptions: { timeout: { socket: 60000, request: 480000 } },
157
+ });
158
+ await extractFromCache({ zip, electronDir, platform, extract, fs });
159
+ }
160
+
161
+ /** Bind the fs-aware probes for the post-extract AV-quarantine verify. */
162
+ function verifyExtractOutcome({ electronDir, platform, fs }) {
163
+ return verifyQuarantine({
164
+ isElectronUsable: () => isElectronUsable({ electronDir, platform, fs }),
165
+ resolveExe: () => resolveElectronBinary({ electronDir, platform, fs }),
166
+ platform,
167
+ });
168
+ }
169
+
157
170
  /** Drive electron's own install.js with force_no_cache semantics. */
158
171
  function runInstaller({ electronDir, force, spawn }) {
159
172
  const installScript = path.join(electronDir, 'install.js');
@@ -187,9 +200,13 @@ async function repairElectron({
187
200
  } = {}) {
188
201
  const fs = deps.fs || fsDefault;
189
202
  const extract = deps.extract || require('extract-zip');
190
- const spawn = deps.spawn || ((cmd, args, o) => spawnSync(cmd, args, { ...o, timeout: timeoutMs }));
203
+ // Default-bound the last-resort installer spawn (8 min) so a first-GUI-use
204
+ // provision that reaches runInstaller without an explicit timeoutMs can't hang
205
+ // the holder — the caller's timeoutMs still wins when provided.
206
+ const spawn = deps.spawn || ((cmd, args, o) => spawnSync(cmd, args, { ...o, timeout: timeoutMs || 480000 }));
191
207
  const findZip = deps.cachedZip || ((o) => cachedZip(o));
192
- const acquireLock = deps.acquireLock || ((o) => defaultAcquireLock({ ...o, fs }));
208
+ const acquireLock = deps.acquireLock || ((o) => acquireRepairLock({ ...o, fs }));
209
+ const downloadArtifact = deps.downloadArtifact || require('@electron/get').downloadArtifact;
193
210
 
194
211
  if (!version) {
195
212
  try {
@@ -214,20 +231,50 @@ async function repairElectron({
214
231
  // Attempt 1: extract from cache (always preferred, fully offline).
215
232
  const zip = findZip({ version, platform, arch, env: process.env, fs });
216
233
  if (zip) {
217
- await extractFromCache({ zip, electronDir, platform, extract, fs });
218
- return { repaired: isElectronUsable({ electronDir, platform, fs }) };
219
- }
220
-
221
- if (cacheOnly) {
234
+ try {
235
+ await extractFromCache({ zip, electronDir, platform, extract, fs });
236
+ // Non-throwing extract w/ absent exe = the AV-quarantine signature.
237
+ return verifyExtractOutcome({ electronDir, platform, fs });
238
+ } catch (extractErr) {
239
+ // Corrupt cached artifact: delete the bad zip so it can't poison the
240
+ // cache, then fall through to a forced fresh download (unless offline).
241
+ try { fs.rmSync(zip, { force: true }); } catch { /* ignore */ }
242
+ if (cacheOnly) {
243
+ return {
244
+ repaired: false,
245
+ reason: `Cached electron zip for v${version} (${platform}-${arch}) was corrupt and removed; deferring re-download.${avHint(platform)}`,
246
+ };
247
+ }
248
+ // else: drop into the controlled download below.
249
+ }
250
+ } else if (cacheOnly) {
222
251
  return {
223
252
  deferred: true,
224
253
  reason: `No cached electron zip found for v${version} (${platform}-${arch}); deferring download.${avHint(platform)}`,
225
254
  };
226
255
  }
227
256
 
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 };
257
+ // Attempt 2 (online): CONTROLLED download+extract instead of a blind
258
+ // install.js spawn — fetch via the SAME @electron/get api install.js uses,
259
+ // extract offline, then report the REAL usability. A structurally-successful
260
+ // download that produced no usable exe is a FAILURE (no false success; #53).
261
+ let controlledExtracted = false;
262
+ try {
263
+ await controlledProvision({
264
+ electronDir, platform, arch, version, downloadArtifact, extract, fs, env: process.env,
265
+ });
266
+ controlledExtracted = true; // download + extract returned without throwing
267
+ } catch {
268
+ // Controlled download/extract failed (network, checksum, unzip). Try the
269
+ // installer as a LAST resort — it can NEVER short-circuit the honest
270
+ // verify below; we always return isElectronUsable().
271
+ try { runInstaller({ electronDir, force, spawn }); } catch { /* ignore */ }
272
+ }
273
+ // A NON-throwing controlled extract that left no usable exe is the
274
+ // AV-quarantine signature — surface it actionably (no false success, no
275
+ // loop). A controlled FAILURE only reports plain repaired:false.
276
+ if (controlledExtracted) { return verifyExtractOutcome({ electronDir, platform, fs }); }
277
+ return { repaired: isElectronUsable({ electronDir, platform, fs }) };
231
278
  } finally {
232
279
  try { lock.release(); } catch { /* ignore */ }
233
280
  }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Stale-aware single-flight lock for the electron self-heal (#53).
3
+ *
4
+ * Only one process may extract/install electron at a time (concurrent extracts
5
+ * trip Windows EBUSY). The lock is a file in the OS temp dir keyed by the
6
+ * electron dir. It records the holder PID + a timestamp so a LATER caller can
7
+ * detect — and STEAL — a lock orphaned by a killed/crashed/rebooted repair (or
8
+ * left by a pre-v1.7.3 build, which wrote an empty lockfile). A bare wx lock
9
+ * with no staleness recovery wedges EVERY future repair forever with "another
10
+ * electron repair is already in progress" once the holder dies without
11
+ * releasing — which is exactly what it did in the field.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const fsDefault = require('fs');
17
+ const path = require('path');
18
+ const os = require('os');
19
+
20
+ /** A real repair (even a ~170MB download) finishes well within this. */
21
+ const STALE_MS = 15 * 60 * 1000;
22
+
23
+ /** Temp-dir lockfile path, keyed by the electron install dir. */
24
+ function lockPathFor(electronDir) {
25
+ const key = Buffer.from(electronDir).toString('hex').slice(0, 16);
26
+ return path.join(os.tmpdir(), `amicus-electron-repair-${key}.lock`);
27
+ }
28
+
29
+ /**
30
+ * Is an existing lockfile orphaned? Stale when it predates STALE_MS, its holder
31
+ * process is gone (ESRCH), or it is empty / corrupt / pre-v1.7.3 format. A lock
32
+ * held by a LIVE, recent process is NOT stale (real contention).
33
+ * @returns {boolean}
34
+ */
35
+ function isStaleLock(lockPath, fs = fsDefault, now = Date.now()) {
36
+ let raw;
37
+ try {
38
+ raw = fs.readFileSync(lockPath, 'utf-8');
39
+ } catch {
40
+ return false; // already gone — absent, not stale
41
+ }
42
+ let meta;
43
+ try {
44
+ meta = JSON.parse(raw);
45
+ } catch {
46
+ return true; // empty / corrupt / pre-v1.7.3 empty lockfile -> orphaned
47
+ }
48
+ if (typeof meta.at === 'number' && now - meta.at > STALE_MS) {
49
+ return true; // older than any real repair
50
+ }
51
+ if (typeof meta.pid === 'number') {
52
+ try {
53
+ process.kill(meta.pid, 0); // throws if the process is gone
54
+ return false; // holder alive
55
+ } catch (err) {
56
+ return !!(err && err.code === 'ESRCH'); // ESRCH => dead/stale; EPERM => alive (not ours)
57
+ }
58
+ }
59
+ return true; // no usable pid/timestamp -> treat as orphaned
60
+ }
61
+
62
+ /**
63
+ * Acquire the single-flight repair lock. Throws an EEXIST-coded error ONLY when
64
+ * a live, recent process genuinely holds it; otherwise steals an orphaned lock.
65
+ * @returns {{ release: () => void }}
66
+ */
67
+ function acquireRepairLock({ electronDir, fs = fsDefault }) {
68
+ const lockPath = lockPathFor(electronDir);
69
+
70
+ function create() {
71
+ // Atomic exclusive create WITH content in a single call: the file is never
72
+ // observable empty (no open-then-write gap a concurrent reader could
73
+ // misjudge as a corrupt orphan and steal). 'wx' throws EEXIST if held.
74
+ fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, at: Date.now() }), { flag: 'wx' });
75
+ }
76
+
77
+ try {
78
+ create();
79
+ } catch (e) {
80
+ if (!e || e.code !== 'EEXIST') { throw e; }
81
+ if (!isStaleLock(lockPath, fs)) { throw e; } // genuinely held by a live process
82
+ // Steal the orphan, then re-create. If a real process races us into the
83
+ // gap, the second create() throws EEXIST and we surface honest contention.
84
+ try { fs.rmSync(lockPath, { force: true }); } catch { /* ignore */ }
85
+ create();
86
+ }
87
+
88
+ return {
89
+ release() {
90
+ try { fs.rmSync(lockPath, { force: true }); } catch { /* ignore */ }
91
+ },
92
+ };
93
+ }
94
+
95
+ module.exports = { acquireRepairLock, isStaleLock, lockPathFor, STALE_MS };
@@ -0,0 +1,70 @@
1
+ /**
2
+ * AV / antivirus quarantine detection for the electron self-heal (#53).
3
+ *
4
+ * The DOMINANT real-world Windows failure: Windows Defender / antivirus deletes
5
+ * electron.exe right AFTER each extract. No re-extract can ever win that race —
6
+ * the user MUST allow-list the binary. This module owns the quarantine MESSAGE
7
+ * + the post-extract VERIFY so electron-install.js stays under the size gate and
8
+ * the wording lives in one place.
9
+ *
10
+ * Split out of src/sidecar/electron-install.js (kept <300 lines).
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ /** Quarantine / AV note appended to win32 deferral reasons. */
16
+ function avHint(platform) {
17
+ if (platform === 'win32') {
18
+ return ' Windows Defender / antivirus may have quarantined electron.exe; '
19
+ + 'allow it and re-run, or reinstall.';
20
+ }
21
+ return '';
22
+ }
23
+
24
+ /**
25
+ * Actionable quarantine instruction. AV / Windows Defender deletes electron.exe
26
+ * right AFTER each extract, so no re-extract can win — the user MUST allow-list
27
+ * the binary. We name the exact path to allow and point at the in-place
28
+ * self-heal re-run (NOT a reinstall, which would loop). win32-specific; other
29
+ * platforms get the generic avHint fallback.
30
+ */
31
+ function quarantineReason({ platform, exePath }) {
32
+ if (platform === 'win32') {
33
+ const target = exePath || 'node_modules/electron/dist/electron.exe';
34
+ return 'electron.exe was removed right after it was extracted — antivirus '
35
+ + '(e.g. Windows Defender) likely quarantined it. Allow '
36
+ + `${target} in your AV, then run amicus doctor --fix.`;
37
+ }
38
+ return 'The electron binary vanished right after extraction — your antivirus '
39
+ + `may have quarantined it. Allow it, then run amicus doctor --fix.${avHint(platform)}`;
40
+ }
41
+
42
+ /**
43
+ * Verify the outcome of a NON-throwing extract (cache OR controlled download).
44
+ *
45
+ * THE QUARANTINE SIGNATURE: the extract did not throw, yet isElectronUsable()
46
+ * is STILL false — the exe was written then vanished. On win32 that is almost
47
+ * always AV / Windows Defender quarantining electron.exe, and NO re-extract can
48
+ * win. We report { repaired:false, quarantined:true, reason } with an ACTIONABLE
49
+ * allow-list instruction — NEVER a false success, and the caller MUST NOT
50
+ * auto-retry (a quarantine loop is the bug we're killing).
51
+ *
52
+ * When the exe IS present, this is a clean { repaired:true }.
53
+ *
54
+ * @param {object} opts
55
+ * @param {() => boolean} opts.isElectronUsable bound usability probe.
56
+ * @param {() => string} opts.resolveExe resolves the on-disk exe path.
57
+ * @param {string} opts.platform
58
+ */
59
+ function verifyExtractOutcome({ isElectronUsable, resolveExe, platform }) {
60
+ if (isElectronUsable()) {
61
+ return { repaired: true };
62
+ }
63
+ return {
64
+ repaired: false,
65
+ quarantined: true,
66
+ reason: quarantineReason({ platform, exePath: resolveExe() }),
67
+ };
68
+ }
69
+
70
+ module.exports = { avHint, quarantineReason, verifyExtractOutcome };
@@ -36,6 +36,43 @@ function ensureNodeModulesBinInPath() {
36
36
  }
37
37
  }
38
38
 
39
+ /**
40
+ * Resolve whether the opencode engine binary actually exists on disk.
41
+ *
42
+ * opencode-ai ships the engine via per-platform binary sub-packages declared as
43
+ * optionalDependencies, so a skipped install or an antivirus quarantine can
44
+ * leave the .exe silently absent — and then `spawn('opencode')` fails with an
45
+ * opaque ENOENT. This is the SINGLE source of truth shared by `amicus doctor`
46
+ * (the opencode-bin check) and the runtime server-start guard, so both agree on
47
+ * what "the binary is present" means.
48
+ *
49
+ * Mirrors the PATH resolution order: on Windows the platform sub-package (and
50
+ * its -baseline variant) bin/opencode.exe; elsewhere node_modules/.bin/opencode.
51
+ * Never throws — a probe failure reads as not-found.
52
+ *
53
+ * @param {object} [deps] - test seams
54
+ * @param {object} [deps.fs] - fs module (existsSync)
55
+ * @param {string} [deps.platform] - process.platform override
56
+ * @param {string} [deps.arch] - os.arch() override
57
+ * @param {string} [deps.nodeModulesRoot] - node_modules root override
58
+ * @returns {boolean} true only when a real opencode binary resolves on disk
59
+ */
60
+ function hasOpencodeBinary(deps = {}) {
61
+ const fs = deps.fs || require('fs');
62
+ const platform = deps.platform || process.platform;
63
+ const arch = deps.arch || os.arch();
64
+ const root = deps.nodeModulesRoot || path.join(__dirname, '..', '..', 'node_modules');
65
+
66
+ const a = arch === 'arm64' ? 'arm64' : 'x64';
67
+ const candidates = platform === 'win32'
68
+ ? [path.join(root, `opencode-windows-${a}`, 'bin', 'opencode.exe'),
69
+ path.join(root, `opencode-windows-${a}-baseline`, 'bin', 'opencode.exe')]
70
+ : [path.join(root, '.bin', 'opencode')];
71
+
72
+ return candidates.some((p) => { try { return fs.existsSync(p); } catch (_e) { return false; } });
73
+ }
74
+
39
75
  module.exports = {
40
76
  ensureNodeModulesBinInPath,
77
+ hasOpencodeBinary,
41
78
  };
@@ -26,6 +26,24 @@ const REMEDIATION_HINTS = Object.freeze({
26
26
  reinstallEngine:
27
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
28
 
29
+ /**
30
+ * Engine binary missing/rolled back AND possibly AV-quarantined. The doctor
31
+ * opencode-bin hint: combines the transient-rollback reinstall guidance with
32
+ * the Windows-Defender allow-list note, matching the electron avHint contract.
33
+ */
34
+ reinstallEngineAv:
35
+ '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). '
36
+ + 'If your antivirus (e.g. Windows Defender) quarantined opencode.exe, allow-list it first, then reinstall.',
37
+
38
+ /**
39
+ * Runtime server-start failure when the opencode engine binary does not
40
+ * resolve on disk. One clear, actionable message replacing the opaque
41
+ * spawn ENOENT — surfaced by startServer (the missing-binary boundary).
42
+ */
43
+ engineMissing:
44
+ 'OpenCode engine binary not found — it was likely skipped during install or quarantined by antivirus. '
45
+ + 'Run "amicus doctor", reinstall with "npm i -g amicus", and allow-list opencode.exe in your antivirus.',
46
+
29
47
  /** Electron absent — reinstall to add the interactive GUI (headless still works). */
30
48
  reinstallElectron: 'npm install -g amicus (reinstall to add Electron)',
31
49