amicus 1.7.0 → 1.7.2

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.0",
3
+ "version": "1.7.2",
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,39 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.7.2] - 2026-06-30
9
+
10
+ The Electron self-heal now tells the truth, heals the cases it can, and clearly explains the ones it can't.
11
+
12
+ ### Fixed
13
+ - **The Electron self-heal no longer claims success when it didn't heal.** `repairElectron`'s
14
+ installer-fallback path always reported the GUI as "provisioned"/"fixed" even when the binary wasn't
15
+ actually on disk — so `amicus doctor --fix` and the install-time prewarm could falsely report
16
+ success. Every self-heal / provision path now declares success **only** when the Electron binary is
17
+ verified present on disk.
18
+
19
+ ### Changed
20
+ - **The GUI repair is now controlled and introspectable.** Instead of blindly re-running Electron's own
21
+ installer (the postinstall npm had already silently suppressed), the repair downloads and extracts the
22
+ binary itself (via `@electron/get`) and verifies the result; a corrupt cached download is cleared and
23
+ re-fetched once.
24
+ - **Antivirus quarantine is detected and explained, not retried forever.** When Windows Defender / AV
25
+ removes `electron.exe` right after extraction (the common Windows failure), amicus now tells you to
26
+ allow-list the binary and re-run `amicus doctor --fix`, instead of silently looping a repair that
27
+ cannot win.
28
+ - **Clear, actionable error when the OpenCode engine binary is missing.** The engine ships via
29
+ per-platform binaries that npm can silently skip (or AV can quarantine); when it's absent, amicus now
30
+ surfaces a specific instruction (run `amicus doctor`, reinstall, allow-list `opencode.exe`) instead of
31
+ an opaque spawn failure.
32
+
33
+ ## [1.7.1] - 2026-06-30
34
+
35
+ ### Fixed
36
+ - **The Electron GUI now shows the current rail-yard brand mark.** The window/taskbar icon, the setup
37
+ wizard's header and footer, and the session toolbar were still rendering the pre-redesign squiggle
38
+ mark; they now use the shipped clay→gold rail-yard mark (matching the site favicon). The inline
39
+ glyphs stay token-bound (clay tracks / gold mainline) so they follow the design system.
40
+
8
41
  ## [1.7.0] - 2026-06-30
9
42
 
10
43
  Electron self-heal, a real `amicus doctor`, and the GUI on the design system — plus MCP/diagnostics correctness.
Binary file
@@ -1,5 +1,12 @@
1
- <svg width="1024" height="1024" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
2
- <rect width="16" height="16" rx="3" fill="#2D2B2A"/>
3
- <path d="M3 2v12" stroke="#D97757" stroke-width="2" stroke-linecap="round"/>
4
- <path d="M10 2v5c0 2-3 3-7 5" stroke="#D97757" stroke-width="2" stroke-linecap="round" stroke-opacity="0.6"/>
1
+ <svg width="1024" height="1024" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <rect width="32" height="32" rx="7" fill="#2D2B2A"/>
3
+ <g stroke="#D97757" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="miter">
4
+ <path d="M4 8H19"/>
5
+ <path d="M4 11H14L19 8"/>
6
+ <path d="M4 14H13L19 8"/>
7
+ <path d="M4 17H12L19 8"/>
8
+ <path d="M4 20H11L19 8"/>
9
+ <path d="M4 23H10L19 8"/>
10
+ </g>
11
+ <path d="M19 8H28" stroke="#E8B24A" stroke-width="1.2" stroke-linecap="round"/>
5
12
  </svg>
@@ -18,7 +18,8 @@ function __rawWizardCSS() {
18
18
  padding: 10px 20px; border-bottom: 1px solid var(--border);
19
19
  }
20
20
  .header svg { flex-shrink: 0; }
21
- .header svg path { stroke: var(--accent); }
21
+ .header svg path { stroke: var(--accent); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: miter; }
22
+ .header svg path.brand-main { stroke: var(--gold-400); }
22
23
  .header-title {
23
24
  color: var(--accent); font-size: 12px; font-weight: 600;
24
25
  letter-spacing: 0.8px; text-transform: uppercase;
@@ -319,7 +320,8 @@ function __rawWizardCSS() {
319
320
  letter-spacing: 0.8px; text-transform: uppercase;
320
321
  display: flex; align-items: center; gap: 6px;
321
322
  }
322
- .footer-brand svg path { stroke: var(--accent); }
323
+ .footer-brand svg path { stroke: var(--accent); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: miter; }
324
+ .footer-brand svg path.brand-main { stroke: var(--gold-400); }
323
325
  .footer-nav { display: flex; gap: 6px; }
324
326
  .nav-btn {
325
327
  padding: 6px 16px; border: 1px solid var(--border);
@@ -33,7 +33,7 @@ function buildSetupHTML(options = {}) {
33
33
  return `<!DOCTYPE html>
34
34
  <html><head><meta charset="utf-8"><title>Amicus Setup</title>
35
35
  <style>${css}</style></head><body>
36
- <div class="header"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 2v12" stroke-width="2" stroke-linecap="round"/><path d="M10 2v5c0 2-3 3-7 5" stroke-width="2" stroke-linecap="round" stroke-opacity="0.6"/></svg><span class="header-title">${brandName} Setup</span></div>
36
+ <div class="header"><svg width="18" height="18" viewBox="0 0 32 32" fill="none"><path d="M4 8H19"/><path d="M4 11H14L19 8"/><path d="M4 14H13L19 8"/><path d="M4 17H12L19 8"/><path d="M4 20H11L19 8"/><path d="M4 23H10L19 8"/><path class="brand-main" d="M19 8H28"/></svg><span class="header-title">${brandName} Setup</span></div>
37
37
  <div class="progress-bar"><div class="progress-step active" id="step-1"><span class="progress-dot">1</span><span>API Keys</span></div><div class="progress-connector"></div><div class="progress-step" id="step-2"><span class="progress-dot">2</span><span>Models</span></div><div class="progress-connector"></div><div class="progress-step" id="step-3"><span class="progress-dot">3</span><span>Routing</span></div><div class="progress-connector"></div><div class="progress-step" id="step-4"><span class="progress-dot">4</span><span>Review</span></div></div>
38
38
  <div class="content">
39
39
  <div class="wizard-step visible" id="wizard-step-1"><div id="import-notice"></div>${keysHtml}</div>
@@ -50,7 +50,7 @@ function buildSetupHTML(options = {}) {
50
50
  </div>
51
51
  </div>
52
52
  </div>
53
- <div class="footer"><div class="footer-brand"><svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M3 2v12" stroke-width="2" stroke-linecap="round"/><path d="M10 2v5c0 2-3 3-7 5" stroke-width="2" stroke-linecap="round" stroke-opacity="0.6"/></svg> ${brandName}</div><div class="footer-nav"><button class="nav-btn" id="back-btn" style="display:none">Back</button><button class="nav-btn primary" id="next-btn" disabled>Next</button><button class="nav-btn primary" id="finish-btn" style="display:none">Finish</button></div></div>
53
+ <div class="footer"><div class="footer-brand"><svg width="15" height="15" viewBox="0 0 32 32" fill="none"><path d="M4 8H19"/><path d="M4 11H14L19 8"/><path d="M4 14H13L19 8"/><path d="M4 17H12L19 8"/><path d="M4 20H11L19 8"/><path d="M4 23H10L19 8"/><path class="brand-main" d="M19 8H28"/></svg> ${brandName}</div><div class="footer-nav"><button class="nav-btn" id="back-btn" style="display:none">Back</button><button class="nav-btn primary" id="next-btn" disabled>Next</button><button class="nav-btn primary" id="finish-btn" style="display:none">Finish</button></div></div>
54
54
  ${buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson)}
55
55
  </body></html>`;
56
56
  }
@@ -70,7 +70,8 @@ function buildToolbarHTML(options = {}) {
70
70
  letter-spacing: 0.8px;
71
71
  text-transform: uppercase;
72
72
  }
73
- .logo path { stroke: var(--accent); }
73
+ .logo path { stroke: var(--accent); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: miter; }
74
+ .logo path.brand-main { stroke: var(--gold-400); }
74
75
  .sep { color: var(--border-strong); font-size: 14px; }
75
76
  .detail, .timer {
76
77
  color: var(--text-3);
@@ -136,9 +137,10 @@ function buildToolbarHTML(options = {}) {
136
137
  }
137
138
  .update-banner .dismiss-btn:hover { color: var(--text-1); }`;
138
139
 
139
- const logoSvg = `<svg class="logo" width="16" height="16" viewBox="0 0 16 16" fill="none">
140
- <path d="M3 2v12" stroke-width="2" stroke-linecap="round"/>
141
- <path d="M10 2v5c0 2-3 3-7 5" stroke-width="2" stroke-linecap="round" stroke-opacity="0.6"/>
140
+ const logoSvg = `<svg class="logo" width="16" height="16" viewBox="0 0 32 32" fill="none">
141
+ <path d="M4 8H19"/><path d="M4 11H14L19 8"/><path d="M4 14H13L19 8"/>
142
+ <path d="M4 17H12L19 8"/><path d="M4 20H11L19 8"/><path d="M4 23H10L19 8"/>
143
+ <path class="brand-main" d="M19 8H28"/>
142
144
  </svg>`;
143
145
 
144
146
  if (mode === 'setup') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
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,9 +15,7 @@
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';
@@ -28,6 +26,7 @@ const os = require('os');
28
26
  const { spawnSync } = require('child_process');
29
27
 
30
28
  const { resolveCacheRoots } = require('./electron-cache');
29
+ const { avHint, verifyExtractOutcome: verifyQuarantine } = require('./electron-quarantine');
31
30
 
32
31
  /** Default on-disk location of the installed electron package. */
33
32
  function defaultElectronDir() {
@@ -116,15 +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
118
  /**
129
119
  * Single-flight lockfile: only one caller may extract/install at a time, so
130
120
  * concurrent callers don't double-extract (Windows EBUSY). Throws an
@@ -154,6 +144,41 @@ async function extractFromCache({ zip, electronDir, platform, extract, fs }) {
154
144
  writePathTxt({ electronDir, platform, fs });
155
145
  }
156
146
 
147
+ /** Best-effort cache root for downloadArtifact (first resolved root). */
148
+ function cacheRootFor(env = process.env) {
149
+ return resolveCacheRoots(env)[0];
150
+ }
151
+
152
+ /**
153
+ * CONTROLLED provision: fetch the zip ourselves with the SAME @electron/get
154
+ * api install.js uses (downloadArtifact, force:true), extract offline, and let
155
+ * the caller verify isElectronUsable(). No blind install.js spawn.
156
+ * @returns {Promise<void>}
157
+ */
158
+ async function controlledProvision({
159
+ electronDir, platform, arch, version, downloadArtifact, extract, fs, env = process.env,
160
+ }) {
161
+ const zip = await downloadArtifact({
162
+ version,
163
+ artifactName: 'electron',
164
+ force: true,
165
+ cacheRoot: cacheRootFor(env),
166
+ platform,
167
+ arch,
168
+ checksums: undefined,
169
+ });
170
+ await extractFromCache({ zip, electronDir, platform, extract, fs });
171
+ }
172
+
173
+ /** Bind the fs-aware probes for the post-extract AV-quarantine verify. */
174
+ function verifyExtractOutcome({ electronDir, platform, fs }) {
175
+ return verifyQuarantine({
176
+ isElectronUsable: () => isElectronUsable({ electronDir, platform, fs }),
177
+ resolveExe: () => resolveElectronBinary({ electronDir, platform, fs }),
178
+ platform,
179
+ });
180
+ }
181
+
157
182
  /** Drive electron's own install.js with force_no_cache semantics. */
158
183
  function runInstaller({ electronDir, force, spawn }) {
159
184
  const installScript = path.join(electronDir, 'install.js');
@@ -190,6 +215,7 @@ async function repairElectron({
190
215
  const spawn = deps.spawn || ((cmd, args, o) => spawnSync(cmd, args, { ...o, timeout: timeoutMs }));
191
216
  const findZip = deps.cachedZip || ((o) => cachedZip(o));
192
217
  const acquireLock = deps.acquireLock || ((o) => defaultAcquireLock({ ...o, fs }));
218
+ const downloadArtifact = deps.downloadArtifact || require('@electron/get').downloadArtifact;
193
219
 
194
220
  if (!version) {
195
221
  try {
@@ -214,20 +240,50 @@ async function repairElectron({
214
240
  // Attempt 1: extract from cache (always preferred, fully offline).
215
241
  const zip = findZip({ version, platform, arch, env: process.env, fs });
216
242
  if (zip) {
217
- await extractFromCache({ zip, electronDir, platform, extract, fs });
218
- return { repaired: isElectronUsable({ electronDir, platform, fs }) };
219
- }
220
-
221
- if (cacheOnly) {
243
+ try {
244
+ await extractFromCache({ zip, electronDir, platform, extract, fs });
245
+ // Non-throwing extract w/ absent exe = the AV-quarantine signature.
246
+ return verifyExtractOutcome({ electronDir, platform, fs });
247
+ } catch (extractErr) {
248
+ // Corrupt cached artifact: delete the bad zip so it can't poison the
249
+ // cache, then fall through to a forced fresh download (unless offline).
250
+ try { fs.rmSync(zip, { force: true }); } catch { /* ignore */ }
251
+ if (cacheOnly) {
252
+ return {
253
+ repaired: false,
254
+ reason: `Cached electron zip for v${version} (${platform}-${arch}) was corrupt and removed; deferring re-download.${avHint(platform)}`,
255
+ };
256
+ }
257
+ // else: drop into the controlled download below.
258
+ }
259
+ } else if (cacheOnly) {
222
260
  return {
223
261
  deferred: true,
224
262
  reason: `No cached electron zip found for v${version} (${platform}-${arch}); deferring download.${avHint(platform)}`,
225
263
  };
226
264
  }
227
265
 
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 };
266
+ // Attempt 2 (online): CONTROLLED download+extract instead of a blind
267
+ // install.js spawn — fetch via the SAME @electron/get api install.js uses,
268
+ // extract offline, then report the REAL usability. A structurally-successful
269
+ // download that produced no usable exe is a FAILURE (no false success; #53).
270
+ let controlledExtracted = false;
271
+ try {
272
+ await controlledProvision({
273
+ electronDir, platform, arch, version, downloadArtifact, extract, fs, env: process.env,
274
+ });
275
+ controlledExtracted = true; // download + extract returned without throwing
276
+ } catch {
277
+ // Controlled download/extract failed (network, checksum, unzip). Try the
278
+ // installer as a LAST resort — it can NEVER short-circuit the honest
279
+ // verify below; we always return isElectronUsable().
280
+ try { runInstaller({ electronDir, force, spawn }); } catch { /* ignore */ }
281
+ }
282
+ // A NON-throwing controlled extract that left no usable exe is the
283
+ // AV-quarantine signature — surface it actionably (no false success, no
284
+ // loop). A controlled FAILURE only reports plain repaired:false.
285
+ if (controlledExtracted) { return verifyExtractOutcome({ electronDir, platform, fs }); }
286
+ return { repaired: isElectronUsable({ electronDir, platform, fs }) };
231
287
  } finally {
232
288
  try { lock.release(); } catch { /* ignore */ }
233
289
  }
@@ -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