amicus 3.2.1 → 3.2.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": "3.2.1",
3
+ "version": "3.2.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": {
6
6
  "name": "Christian Wagner"
package/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [3.2.2] - 2026-07-17
9
+
10
+ ### Fixed
11
+
12
+ - **A missing `opencode` engine now self-heals instead of failing every call.**
13
+ When the engine binary is absent at a fanout/start — a skipped
14
+ optional-dependency install or an antivirus quarantine of the npx-cache copy
15
+ the MCP launches — amicus recovers in place by copying the `opencode-*`
16
+ packages from a healthy sibling install (running, global, or another npx
17
+ copy), then proceeds, instead of throwing `engineMissing` on every leg
18
+ (report #2).
19
+
20
+ ### Added
21
+
22
+ - **`amicus doctor --fix` repairs broken npx-cache engine copies**, self-healing
23
+ the copies the MCP actually launches, not just the running install.
24
+
8
25
  ## [3.2.1] - 2026-07-17
9
26
 
10
27
  ### Fixed
package/README.md CHANGED
@@ -319,7 +319,7 @@ $ amicus status demo123 --json
319
319
  "taskId": "demo123",
320
320
  "status": "complete",
321
321
  "elapsed": "5m 0s",
322
- "version": "3.2.1",
322
+ "version": "3.2.2",
323
323
  "model": "google/gemini-2.5-flash",
324
324
  "phase": "terminal"
325
325
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "3.2.1",
3
+ "version": "3.2.2",
4
4
  "mcpName": "io.github.BourbonDog/amicus",
5
5
  "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.",
6
6
  "keywords": [
@@ -48,8 +48,8 @@
48
48
  "scripts": {
49
49
  "start": "node --experimental-top-level-await --experimental-vm-modules bin/amicus.js",
50
50
  "test": "jest",
51
- "test:integration": "jest --testPathIgnorePatterns='\\.worktrees/' --testMatch='**/tests/**/*.integration.test.js'",
52
- "test:all": "jest --testPathIgnorePatterns='/node_modules/' --testPathIgnorePatterns='\\.worktrees/' && node scripts/mark-test-passed.js",
51
+ "test:integration": "jest --testPathIgnorePatterns='worktrees' --testMatch='**/tests/**/*.integration.test.js'",
52
+ "test:all": "jest --testPathIgnorePatterns='/node_modules/' --testPathIgnorePatterns='worktrees' && node scripts/mark-test-passed.js",
53
53
  "test:e2e:mcp": "jest tests/mcp-repomix-e2e.integration.test.js --testTimeout=180000 --forceExit",
54
54
  "posttest": "node scripts/mark-test-passed.js",
55
55
  "lint": "eslint src/",
@@ -42,6 +42,8 @@ function realDeps() {
42
42
  // engine-mcp check: probe the engine in every install that could serve the
43
43
  // MCP (running/global/npx-cache), not just the one doctor runs from (#1).
44
44
  scanEngineInstalls: () => require('./utils/engine-install-scan').scanEngineInstalls(),
45
+ // report #2: copy-from-sibling self-heal for `doctor --fix`.
46
+ repairEngine: (o) => require('./utils/engine-repair').repairEngine(o),
45
47
  getElectronPath: () => require('./sidecar/interactive-process').getElectronPath(),
46
48
  // #56: self-heal primitive for `doctor --fix`. Pure probe (getElectronPath)
47
49
  // stays separate; repair only runs when fix is requested.
@@ -150,7 +152,7 @@ async function runDoctorChecks(depsOverride = {}) {
150
152
  // Cross-install: verify the engine in the npx-cache copies the MCP actually
151
153
  // launches (`npx -y amicus@latest mcp`), so a green 'opencode-bin' (the running
152
154
  // install) can't hide a broken copy the MCP would spawn (bug report #1/#4).
153
- checks.push(guard('engine-mcp', 'OpenCode engine (MCP launch path)', () => engineCheck.evaluateEngineInstalls(d)));
155
+ checks.push(await guardAsync('engine-mcp', 'OpenCode engine (MCP launch path)', () => engineCheck.evaluateEngineMcp(d)));
154
156
 
155
157
  checks.push(await guardAsync('electron', 'Electron (interactive GUI)', async () => {
156
158
  if (d.getElectronPath()) {
@@ -625,22 +625,27 @@ function buildServerHandle(sdkServer, deps = {}) {
625
625
  * @returns {Promise<{client: object, server: {url: string, close: Function}}>}
626
626
  */
627
627
  async function startServer(options = {}) {
628
- // Fail fast with a CLEAR, actionable error when the opencode engine binary
629
- // is absent (skipped optionalDependency install or AV quarantine). Without
630
- // this, createOpencodeServer spawn('opencode') fails with an opaque ENOENT.
631
- // Checked BEFORE the SDK is loaded so the message is the first thing the user
632
- // sees. `_hasOpencodeBinary` is a test seam; default is the shared resolver
633
- // (single source of truth with `amicus doctor`). NOTE: this does NOT
634
- // auto-repair — re-running the opencode postinstall is the same flaky trap.
628
+ // Fail fast when the opencode engine binary is absent (skipped
629
+ // optionalDependency install or AV quarantine). Before giving up, attempt an
630
+ // in-place self-heal: COPY the opencode-* packages from a healthy sibling
631
+ // install (engine-ensure) NOT re-running the opencode postinstall, which is
632
+ // the same flaky optional-dependency trap. Only runs when truly missing; the
633
+ // fast path skips it. `_hasOpencodeBinary` / `_ensureEngine` are test seams.
635
634
  const hasOpencodeBinary = options._hasOpencodeBinary
636
635
  || require('./utils/path-setup').hasOpencodeBinary;
637
636
  if (!hasOpencodeBinary()) {
638
- const HINTS = require('./utils/remediation-hints');
639
- // Append the roots we actually probed so an npx-cache-vs-global divergence is
640
- // visible at the point of failure, not just in `amicus doctor` (report #4).
641
- const opencodeRoots = options._opencodeRoots
642
- || require('./utils/path-setup').opencodeRoots;
643
- throw new Error(`${HINTS.engineMissing} Searched: ${opencodeRoots().join(', ')}`);
637
+ const ensureEngine = options._ensureEngine
638
+ || require('./utils/engine-ensure').ensureEngine;
639
+ const healed = await ensureEngine().catch(() => ({ ok: false }));
640
+ if (!healed.ok) {
641
+ const HINTS = require('./utils/remediation-hints');
642
+ // Append the roots we probed so an npx-cache-vs-global divergence is visible
643
+ // at the point of failure, not just in `amicus doctor` (report #4).
644
+ const opencodeRoots = options._opencodeRoots
645
+ || require('./utils/path-setup').opencodeRoots;
646
+ const note = healed.reason ? ` (self-heal: ${healed.reason})` : '';
647
+ throw new Error(`${HINTS.engineMissing} Searched: ${opencodeRoots().join(', ')}${note}`);
648
+ }
644
649
  }
645
650
 
646
651
  const createOpencodeServer = await getCreateOpencodeServer();
@@ -68,4 +68,37 @@ function evaluateEngineInstalls(d) {
68
68
  return { id, name, status, message: `${lead}: ${detail}`, hint: HINTS.reinstallEngineAv };
69
69
  }
70
70
 
71
- module.exports = { evaluateEngineInstalls };
71
+ /**
72
+ * Fix-aware wrapper. When d.fix and the scan shows broken npx-cache copies, copy
73
+ * the engine into each via d.repairEngine, then re-report from a fresh scan.
74
+ * Without d.fix — or when nothing is copy-fixable — returns the plain verdict.
75
+ * @param {object} d doctor deps (scanEngineInstalls, fix?, repairEngine?)
76
+ * @returns {Promise<{id,name,status,message,hint}>}
77
+ */
78
+ async function evaluateEngineMcp(d) {
79
+ const verdict = evaluateEngineInstalls(d);
80
+ if (!d.fix || verdict.status === 'ok') { return verdict; }
81
+
82
+ const { installs } = d.scanEngineInstalls();
83
+ const broken = installs.filter((i) => i.kind === 'npx' && !i.engineOk);
84
+ if (broken.length === 0) { return verdict; }
85
+
86
+ const results = [];
87
+ for (const b of broken) {
88
+ let r;
89
+ try { r = await d.repairEngine({ destPkgDir: b.pkgDir }); }
90
+ catch (e) { r = { repaired: false, reason: e && e.message }; }
91
+ results.push({ pkgDir: b.pkgDir, ...r });
92
+ }
93
+
94
+ const after = evaluateEngineInstalls(d); // fresh scan reflects the copies
95
+ if (after.status === 'ok') {
96
+ const n = results.length;
97
+ return { ...after, message: `${after.message} (self-healed ${n} npx-cache ${plural(n, 'copy', 'copies')})` };
98
+ }
99
+ const failed = results.filter((r) => !r.repaired)
100
+ .map((r) => `${r.pkgDir}${r.reason ? ` — ${r.reason}` : ''}`).join('; ');
101
+ return { ...after, message: `${after.message}; self-heal incomplete: ${failed}` };
102
+ }
103
+
104
+ module.exports = { evaluateEngineInstalls, evaluateEngineMcp };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @module utils/engine-ensure
3
+ * ensureEngine() — runtime engine self-heal at server start (report #2).
4
+ *
5
+ * The single-flight wrapper opencode-client.startServer calls when the engine
6
+ * binary is missing: fast-path hasOpencodeBinary(); otherwise copy the engine
7
+ * from a healthy sibling install via engine-repair, refresh PATH, and re-check.
8
+ * A module-level promise stops concurrent fanout-leg server starts in one
9
+ * process from re-copying. Only success is memoized; a failed attempt clears the
10
+ * guard so a later call may retry. Mirrors src/sidecar/electron-ensure.js.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const { hasOpencodeBinary: defaultHas } = require('./path-setup');
16
+
17
+ let _ensurePromise = null;
18
+
19
+ /** Test-only: clear the single-flight guard so each test starts clean. */
20
+ function _resetEnsureEngine() {
21
+ _ensurePromise = null;
22
+ }
23
+
24
+ /**
25
+ * Ensure the opencode engine is present, self-healing by copy if missing.
26
+ * @param {object} [opts]
27
+ * @param {object} [opts.deps] injected { hasOpencodeBinary, repairEngine, ensurePath, logProgress }
28
+ * @param {object} [opts.repairOptions] forwarded to repairEngine (destPkgDir, etc.)
29
+ * @returns {Promise<{ok:boolean, reason?:string, donor?:string}>}
30
+ */
31
+ function ensureEngine({ deps = {}, repairOptions = {} } = {}) {
32
+ const has = deps.hasOpencodeBinary || defaultHas;
33
+ const repair = deps.repairEngine || ((o) => require('./engine-repair').repairEngine(o));
34
+ const ensurePath = deps.ensurePath || require('./path-setup').ensureNodeModulesBinInPath;
35
+ const logProgress = deps.logProgress
36
+ || ((msg) => { try { process.stderr.write(`${msg}\n`); } catch { /* ignore */ } });
37
+
38
+ // Fast path: already present. Cheap disk stat — safe every call.
39
+ if (has()) { return Promise.resolve({ ok: true }); }
40
+
41
+ // Single-flight: reuse an in-flight repair.
42
+ if (_ensurePromise) { return _ensurePromise; }
43
+
44
+ _ensurePromise = (async () => {
45
+ logProgress('[amicus] OpenCode engine missing — self-healing by copying from a healthy install...');
46
+ let result;
47
+ try {
48
+ result = await repair({ ...repairOptions });
49
+ } catch (err) {
50
+ return { ok: false, reason: `engine self-heal failed: ${err && err.message}` };
51
+ }
52
+ if (has()) {
53
+ logProgress('[amicus] OpenCode engine restored.');
54
+ return { ok: true, donor: result && result.donor };
55
+ }
56
+ return { ok: false, reason: (result && result.reason) || 'engine self-heal did not restore the binary' };
57
+ })().then((r) => {
58
+ if (r.ok) {
59
+ try { ensurePath(); } catch { /* ignore */ } // refresh PATH so spawn('opencode') resolves
60
+ } else {
61
+ _ensurePromise = null; // failure is not memoized — a later call may retry
62
+ }
63
+ return r;
64
+ }, (err) => {
65
+ _ensurePromise = null;
66
+ throw err;
67
+ });
68
+
69
+ return _ensurePromise;
70
+ }
71
+
72
+ module.exports = { ensureEngine, _resetEnsureEngine };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @module utils/engine-lock
3
+ * Stale-aware single-flight lock for the engine self-heal (report #2).
4
+ *
5
+ * Only one process may copy the opencode engine into a given install at a time:
6
+ * the report saw multiple live MCP processes, so two could self-heal the SAME
7
+ * npx-cache copy at once and a leg could spawn a half-written opencode.exe. The
8
+ * lock is a file in the OS temp dir keyed by the destination pkgDir, recording
9
+ * the holder PID + timestamp so a LATER caller can detect and STEAL a lock
10
+ * orphaned by a killed/crashed repair rather than wedging every future repair.
11
+ * Mirrors src/sidecar/electron-lock.js (kept separate so the shipped GUI heal is
12
+ * never touched).
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const fsDefault = require('fs');
18
+ const path = require('path');
19
+ const os = require('os');
20
+
21
+ /** A real engine copy (a few tens of MB) finishes well within this. */
22
+ const STALE_MS = 15 * 60 * 1000;
23
+
24
+ /** Temp-dir lockfile path, keyed by the destination install dir. */
25
+ function lockPathFor(pkgDir) {
26
+ const key = require('crypto').createHash('sha1').update(pkgDir).digest('hex').slice(0, 16);
27
+ return path.join(os.tmpdir(), `amicus-engine-repair-${key}.lock`);
28
+ }
29
+
30
+ /**
31
+ * Is an existing lockfile orphaned? Stale when it predates STALE_MS, its holder
32
+ * process is gone (ESRCH), or it is empty / corrupt. A lock held by a live,
33
+ * recent process is NOT stale (real contention).
34
+ * @returns {boolean}
35
+ */
36
+ function isStaleLock(lockPath, fs = fsDefault, now = Date.now()) {
37
+ let raw;
38
+ try {
39
+ raw = fs.readFileSync(lockPath, 'utf-8');
40
+ } catch {
41
+ return false; // already gone — absent, not stale
42
+ }
43
+ let meta;
44
+ try {
45
+ meta = JSON.parse(raw);
46
+ } catch {
47
+ return true; // empty / corrupt -> orphaned
48
+ }
49
+ if (typeof meta.at === 'number' && now - meta.at > STALE_MS) {
50
+ return true;
51
+ }
52
+ if (typeof meta.pid === 'number') {
53
+ try {
54
+ process.kill(meta.pid, 0); // throws if the process is gone
55
+ return false; // holder alive
56
+ } catch (err) {
57
+ return !!(err && err.code === 'ESRCH'); // ESRCH => dead; EPERM => alive
58
+ }
59
+ }
60
+ return true;
61
+ }
62
+
63
+ /**
64
+ * Acquire the single-flight repair lock. Throws an EEXIST-coded error ONLY when
65
+ * a live, recent process genuinely holds it; otherwise steals an orphaned lock.
66
+ * @param {{pkgDir:string, fs?:object}} opts
67
+ * @returns {{ release: () => void }}
68
+ */
69
+ function acquireRepairLock({ pkgDir, fs = fsDefault }) {
70
+ const lockPath = lockPathFor(pkgDir);
71
+
72
+ function create() {
73
+ // Atomic exclusive create WITH content: never observable empty. 'wx' throws
74
+ // EEXIST if held.
75
+ fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, at: Date.now() }), { flag: 'wx' });
76
+ }
77
+
78
+ try {
79
+ create();
80
+ } catch (e) {
81
+ if (!e || e.code !== 'EEXIST') { throw e; }
82
+ if (!isStaleLock(lockPath, fs)) { throw e; } // live holder -> honest contention
83
+ try { fs.rmSync(lockPath, { force: true }); } catch { /* ignore */ }
84
+ create();
85
+ }
86
+
87
+ return {
88
+ release() {
89
+ try { fs.rmSync(lockPath, { force: true }); } catch { /* ignore */ }
90
+ },
91
+ };
92
+ }
93
+
94
+ module.exports = { acquireRepairLock, isStaleLock, lockPathFor, STALE_MS };
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @module utils/engine-repair
3
+ * Engine self-heal primitive (report #2): make the opencode engine present ON
4
+ * DISK in a target amicus install by COPYING the opencode-* packages from a
5
+ * healthy sibling install (running/global/npx). No network, and the donor is on
6
+ * the same machine so its platform binaries match — unlike re-running the
7
+ * opencode postinstall, the flaky optional-dependency trap the startServer guard
8
+ * warns about.
9
+ *
10
+ * PURE copy — no PATH mutation — so it is targetable at ANY copy: the runtime
11
+ * path (engine-ensure) repairs the RUNNING copy; `amicus doctor --fix` repairs a
12
+ * foreign broken npx copy. Everything is injectable so tests never touch the real
13
+ * fs. Mirrors the Electron self-heal primitive (src/sidecar/electron-install.js).
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const fsDefault = require('fs');
19
+ const path = require('path');
20
+
21
+ /** The running amicus package root (this file lives in <pkg>/src/utils). */
22
+ function runningPkgDir() {
23
+ return path.join(__dirname, '..', '..');
24
+ }
25
+
26
+ /** First healthy install whose real path differs from the destination. */
27
+ function findDonor({ installs, destPkgDir, fs }) {
28
+ const norm = (p) => { try { return path.normalize(fs.realpathSync(p)); } catch { return path.normalize(p); } };
29
+ const destReal = norm(destPkgDir);
30
+ return installs.find((i) => i.engineOk && norm(i.pkgDir) !== destReal) || null;
31
+ }
32
+
33
+ /** The donor root (nested or hoisted) that actually holds the engine binary. */
34
+ function engineSourceRoot({ donorPkgDir, hasOpencodeBinary, opencodeRoots, fs }) {
35
+ for (const root of opencodeRoots({ pkgDir: donorPkgDir })) {
36
+ if (hasOpencodeBinary({ nodeModulesRoot: root, fs })) { return root; }
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /** Copy every opencode-* package dir + the .bin/opencode* shims source→dest. */
42
+ function copyEnginePackages({ sourceRoot, destRoot, fs }) {
43
+ const copied = [];
44
+ fs.mkdirSync(destRoot, { recursive: true });
45
+ for (const name of fs.readdirSync(sourceRoot)) {
46
+ if (!name.startsWith('opencode-')) { continue; }
47
+ fs.cpSync(path.join(sourceRoot, name), path.join(destRoot, name), { recursive: true, force: true });
48
+ copied.push(name);
49
+ }
50
+ // The engine resolver checks <root>/.bin/opencode on non-Windows, so carry the
51
+ // shims across too (relative symlinks resolve against the copied opencode-ai).
52
+ const srcBin = path.join(sourceRoot, '.bin');
53
+ let shims = [];
54
+ try { shims = fs.readdirSync(srcBin).filter((n) => n.startsWith('opencode')); } catch { shims = []; }
55
+ if (shims.length) {
56
+ const dstBin = path.join(destRoot, '.bin');
57
+ fs.mkdirSync(dstBin, { recursive: true });
58
+ for (const n of shims) {
59
+ fs.cpSync(path.join(srcBin, n), path.join(dstBin, n), { recursive: true, force: true });
60
+ copied.push(path.join('.bin', n));
61
+ }
62
+ }
63
+ return copied;
64
+ }
65
+
66
+ /**
67
+ * Copy the opencode engine into destPkgDir from a healthy sibling install.
68
+ * Never throws — every failure mode is a {repaired:false, ...} document.
69
+ *
70
+ * @param {object} [opts]
71
+ * @param {string} [opts.destPkgDir] install to repair (default: running copy)
72
+ * @param {object} [opts.deps] injected { fs, scanEngineInstalls, hasOpencodeBinary, opencodeRoots, acquireLock }
73
+ * @returns {Promise<{repaired:boolean, reason?:string, contended?:boolean, donor?:string, copied?:string[]}>}
74
+ */
75
+ async function repairEngine({ destPkgDir = runningPkgDir(), deps = {} } = {}) {
76
+ const fs = deps.fs || fsDefault;
77
+ const scanEngineInstalls = deps.scanEngineInstalls
78
+ || (() => require('./engine-install-scan').scanEngineInstalls());
79
+ const hasOpencodeBinary = deps.hasOpencodeBinary || require('./path-setup').hasOpencodeBinary;
80
+ const opencodeRoots = deps.opencodeRoots || require('./path-setup').opencodeRoots;
81
+ const acquireLock = deps.acquireLock
82
+ || ((o) => require('./engine-lock').acquireRepairLock({ ...o, fs }));
83
+
84
+ // Already healthy — nothing to do (ensureEngine fast-paths, but doctor --fix
85
+ // may call us directly on a copy a prior leg already healed).
86
+ if (hasOpencodeBinary({ pkgDir: destPkgDir, fs })) {
87
+ return { repaired: true };
88
+ }
89
+
90
+ const { installs } = scanEngineInstalls();
91
+ const donor = findDonor({ installs, destPkgDir, fs });
92
+ if (!donor) {
93
+ return { repaired: false, reason: 'no healthy sibling install to copy the engine from' };
94
+ }
95
+
96
+ const sourceRoot = engineSourceRoot({ donorPkgDir: donor.pkgDir, hasOpencodeBinary, opencodeRoots, fs });
97
+ if (!sourceRoot) {
98
+ return { repaired: false, reason: `donor ${donor.pkgDir} has no resolvable engine root` };
99
+ }
100
+
101
+ let lock;
102
+ try {
103
+ lock = acquireLock({ pkgDir: destPkgDir });
104
+ } catch (e) {
105
+ if (e && e.code === 'EEXIST') {
106
+ return { repaired: false, contended: true, reason: 'another engine repair is in progress' };
107
+ }
108
+ return { repaired: false, reason: `could not acquire repair lock: ${e && e.message}` };
109
+ }
110
+
111
+ try {
112
+ const destRoot = path.join(destPkgDir, 'node_modules');
113
+ const copied = copyEnginePackages({ sourceRoot, destRoot, fs });
114
+ const repaired = !!hasOpencodeBinary({ pkgDir: destPkgDir, fs });
115
+ return repaired
116
+ ? { repaired: true, donor: donor.pkgDir, copied }
117
+ : { repaired: false, reason: 'engine still missing after copy', donor: donor.pkgDir, copied };
118
+ } catch (e) {
119
+ return { repaired: false, reason: `engine copy failed: ${e && e.message}` };
120
+ } finally {
121
+ try { lock.release(); } catch { /* ignore */ }
122
+ }
123
+ }
124
+
125
+ module.exports = { repairEngine, findDonor, engineSourceRoot, copyEnginePackages, runningPkgDir };