amicus 1.7.2 → 1.7.4

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.2",
3
+ "version": "1.7.4",
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,33 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.7.4] - 2026-06-30
9
+
10
+ ### Fixed
11
+ - **The Electron GUI self-heal survives a stalled `extract-zip` on Node 24.** On some Node 24 boxes the
12
+ bundled `extract-zip@2.0.1` (its latest release — it cannot be bumped) stalls mid-extract: its promise
13
+ never resolves *and* never rejects. Because the self-heal `await`s it, the event loop drains and the
14
+ process exits `0` with a half-extracted `dist/` and **no `electron.exe`** — so the repair looked like it
15
+ "did nothing." Extraction is now hardened two ways: `extract-zip` is bounded by an idle + max timer (a
16
+ stall becomes a caught error instead of a silent hang, and the live timer prevents the premature exit),
17
+ and if it stalls, throws, or produces no files, amicus falls back to a **native OS unzip** (Windows:
18
+ bundled `bsdtar`, then PowerShell `Expand-Archive`; macOS: `ditto`, then `unzip`; Linux: `unzip`, then
19
+ `tar`) — each verified to extract the exact Electron zip that `extract-zip` choked on. Success is still
20
+ reported **only** when the real binary lands on disk (the existing exe-stat verify is unchanged), so no
21
+ path can claim a false repair.
22
+
23
+ ## [1.7.3] - 2026-06-30
24
+
25
+ ### Fixed
26
+ - **The Electron self-heal no longer wedges itself.** A repair that was killed or hung mid-run (or a
27
+ pre-1.7.3 build) could leave an orphaned single-flight lockfile, after which *every* subsequent repair
28
+ — including `amicus doctor --fix` and the GUI launch — reported "another electron repair is already in
29
+ progress" and did nothing. The lock now records the holder's PID + timestamp and reclaims an orphaned
30
+ lock (dead holder, older than a 15-minute TTL, or the old empty format), so the GUI can self-heal
31
+ again; a live, recent holder still yields honest contention (no double-extract). The controlled
32
+ download is time-boxed (and the last-resort installer bounded) so a stalled fetch can't recreate the
33
+ stuck lock. **After upgrading, an already-stuck lock clears itself on the next repair.**
34
+
8
35
  ## [1.7.2] - 2026-06-30
9
36
 
10
37
  The Electron self-heal now tells the truth, heals the cases it can, and clearly explains the ones it can't.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.7.2",
3
+ "version": "1.7.4",
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",
@@ -22,11 +22,17 @@
22
22
 
23
23
  const fsDefault = require('fs');
24
24
  const path = require('path');
25
- const os = require('os');
26
25
  const { spawnSync } = require('child_process');
27
26
 
28
27
  const { resolveCacheRoots } = require('./electron-cache');
29
28
  const { avHint, verifyExtractOutcome: verifyQuarantine } = require('./electron-quarantine');
29
+ const { acquireRepairLock } = require('./electron-lock');
30
+ const { robustExtract } = require('./unzip');
31
+
32
+ /** Self-heal progress line to stderr (visible during first-GUI provision). */
33
+ function stderrLog(msg) {
34
+ try { process.stderr.write(`${msg}\n`); } catch { /* stderr closed */ }
35
+ }
30
36
 
31
37
  /** Default on-disk location of the installed electron package. */
32
38
  function defaultElectronDir() {
@@ -115,22 +121,6 @@ function cachedZip({ version, platform = process.platform, arch = process.arch,
115
121
  return null;
116
122
  }
117
123
 
118
- /**
119
- * Single-flight lockfile: only one caller may extract/install at a time, so
120
- * concurrent callers don't double-extract (Windows EBUSY). Throws an
121
- * EEXIST-coded error when another holder is active.
122
- */
123
- function defaultAcquireLock({ electronDir, fs = fsDefault }) {
124
- const lockPath = path.join(os.tmpdir(), `amicus-electron-repair-${Buffer.from(electronDir).toString('hex').slice(0, 16)}.lock`);
125
- const fd = fs.openSync(lockPath, 'wx'); // EEXIST if held
126
- return {
127
- release() {
128
- try { fs.closeSync(fd); } catch { /* ignore */ }
129
- try { fs.rmSync(lockPath, { force: true }); } catch { /* ignore */ }
130
- },
131
- };
132
- }
133
-
134
124
  /** Restore path.txt so electron/index.js resolves the freshly-extracted exe. */
135
125
  function writePathTxt({ electronDir, platform, fs }) {
136
126
  fs.writeFileSync(path.join(electronDir, 'path.txt'), platformExe(platform));
@@ -166,6 +156,10 @@ async function controlledProvision({
166
156
  platform,
167
157
  arch,
168
158
  checksums: undefined,
159
+ // Bound the fetch so a stalled/blocked network aborts (got v11 timeouts:
160
+ // socket = inactivity, request = total) instead of hanging the repair —
161
+ // a hung-then-killed download is what orphaned the single-flight lock.
162
+ downloadOptions: { timeout: { socket: 60000, request: 480000 } },
169
163
  });
170
164
  await extractFromCache({ zip, electronDir, platform, extract, fs });
171
165
  }
@@ -211,10 +205,15 @@ async function repairElectron({
211
205
  deps = {},
212
206
  } = {}) {
213
207
  const fs = deps.fs || fsDefault;
214
- const extract = deps.extract || require('extract-zip');
215
- const spawn = deps.spawn || ((cmd, args, o) => spawnSync(cmd, args, { ...o, timeout: timeoutMs }));
208
+ // Default extract: extract-zip bounded (idle/max) + native-unzip fallback (extract-zip-node24 stall).
209
+ const extract = deps.extract
210
+ || ((zipPath, o) => robustExtract(zipPath, { ...o, platform, deps: { fs, log: stderrLog } }));
211
+ // Default-bound the last-resort installer spawn (8 min) so a first-GUI-use
212
+ // provision that reaches runInstaller without an explicit timeoutMs can't hang
213
+ // the holder — the caller's timeoutMs still wins when provided.
214
+ const spawn = deps.spawn || ((cmd, args, o) => spawnSync(cmd, args, { ...o, timeout: timeoutMs || 480000 }));
216
215
  const findZip = deps.cachedZip || ((o) => cachedZip(o));
217
- const acquireLock = deps.acquireLock || ((o) => defaultAcquireLock({ ...o, fs }));
216
+ const acquireLock = deps.acquireLock || ((o) => acquireRepairLock({ ...o, fs }));
218
217
  const downloadArtifact = deps.downloadArtifact || require('@electron/get').downloadArtifact;
219
218
 
220
219
  if (!version) {
@@ -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,231 @@
1
+ /**
2
+ * Robust unzip for the electron self-heal (#53 follow-up; extract-zip-node24).
3
+ *
4
+ * FIELD BUG: on some Node 24 boxes `extract-zip@2.0.1` STALLS mid-extract — its
5
+ * promise never resolves AND never rejects. Because the self-heal `await`s it,
6
+ * when the event loop drains Node exits 0 with a partial extract and no
7
+ * electron.exe, so `repairElectron` silently no-ops. extract-zip 2.0.1 is the
8
+ * LATEST published release, so "just bump it" is impossible.
9
+ *
10
+ * robustExtract() survives that with three independent layers, none of which
11
+ * can report a false success:
12
+ * 1. BOUND extract-zip with an idle timer (reset on each onEntry) + a hard max
13
+ * timer, so a stall becomes a catchable outcome — and the live timer keeps
14
+ * the event loop alive so the process can't exit 0 before we fall back.
15
+ * 2. FALL BACK to a native OS unzip (tar / Expand-Archive on Windows,
16
+ * ditto / unzip on macOS, unzip / tar on Linux) — each confirmed to extract
17
+ * the exact electron zip the field box choked on.
18
+ * 3. Only report success when files actually landed on disk. The electron
19
+ * exe-stat verify stays upstream (electron-quarantine.verifyExtractOutcome).
20
+ *
21
+ * Everything network/spawn/timer-facing is dependency-INJECTABLE so tests never
22
+ * hit the real clock, spawn a real process, or extract a real binary.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const path = require('path');
28
+ const fsDefault = require('fs');
29
+ const { spawnSync } = require('child_process');
30
+
31
+ // No-progress window: if extract-zip reports no new entry for this long AND has
32
+ // not settled, treat it as the silent stall. Reset on every onEntry so a slow-
33
+ // but-progressing extract is never falsely aborted.
34
+ const IDLE_MS = 30_000;
35
+ // Hard cap so a "drips one entry forever" pathology can't run unbounded.
36
+ const MAX_MS = 240_000;
37
+
38
+ /** PowerShell single-quoted string literal, injection-safe (double any quote). */
39
+ function psQuote(s) {
40
+ return `'${String(s).replace(/'/g, "''")}'`;
41
+ }
42
+
43
+ /**
44
+ * Native OS unzip strategies, tried in order per platform. Each writes the
45
+ * zip's entries at the ROOT of `dir` — the SAME on-disk layout extract-zip
46
+ * produces (electron.exe, resources/, locales/, ...). Confirmed on the field
47
+ * box (Expand-Archive) and locally (tar/bsdtar + Expand-Archive, both <1s).
48
+ * @returns {Array<{name:string, cmd:string, args:string[]}>}
49
+ */
50
+ function nativeUnzipPlan(zip, dir, platform = process.platform) {
51
+ if (platform === 'win32') {
52
+ // ABSOLUTE path to System32 bsdtar (Win10 1803+/11). A bare "tar" resolves
53
+ // to GNU tar when git-bash/MSYS is on PATH — GNU tar reads "C:\..." as a
54
+ // remote host ("Cannot connect to C:") and can't read zips at all. path.win32
55
+ // keeps this a valid Windows path even when the plan is built off-Windows.
56
+ const winTar = path.win32.join(process.env.SystemRoot || process.env.windir || 'C:\\Windows', 'System32', 'tar.exe');
57
+ return [
58
+ // bsdtar — fast, auto-detects zip format. If absent (rare/WOW64), the
59
+ // spawn errors ENOENT and we fall through to Expand-Archive below.
60
+ { name: 'tar', cmd: winTar, args: ['-xf', zip, '-C', dir] },
61
+ // Universal Windows fallback; silence progress so stdio:'ignore' is clean.
62
+ {
63
+ name: 'Expand-Archive',
64
+ cmd: 'powershell',
65
+ args: ['-NoProfile', '-NonInteractive', '-Command',
66
+ `$ProgressPreference='SilentlyContinue'; Expand-Archive -LiteralPath ${psQuote(zip)} -DestinationPath ${psQuote(dir)} -Force`],
67
+ },
68
+ ];
69
+ }
70
+ if (platform === 'darwin') {
71
+ return [
72
+ { name: 'ditto', cmd: 'ditto', args: ['-x', '-k', zip, dir] },
73
+ { name: 'unzip', cmd: 'unzip', args: ['-o', '-q', zip, '-d', dir] },
74
+ ];
75
+ }
76
+ return [
77
+ { name: 'unzip', cmd: 'unzip', args: ['-o', '-q', zip, '-d', dir] },
78
+ { name: 'tar', cmd: 'tar', args: ['-xf', zip, '-C', dir] },
79
+ ];
80
+ }
81
+
82
+ /** True if `dir` exists and holds at least one entry. */
83
+ function dirNonEmpty(fs, dir) {
84
+ try {
85
+ return fs.readdirSync(dir).length > 0;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ /** Remove everything inside `dir` (best-effort) so the next strategy starts clean. */
92
+ function cleanDir(fs, dir) {
93
+ try {
94
+ for (const entry of fs.readdirSync(dir)) {
95
+ fs.rmSync(path.join(dir, entry), { recursive: true, force: true });
96
+ }
97
+ } catch {
98
+ /* best-effort */
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Run extract-zip bounded by an idle timer (reset on each onEntry) and a hard
104
+ * max timer. NEVER rejects — resolves {ok:true} on completion, {ok:false,reason}
105
+ * on stall/throw. The timers keep the event loop alive so a stalled extract
106
+ * can't let the process exit 0 before we fall back. NOTE: a stalled extract-zip
107
+ * promise is abandoned (2.0.1 has no cancel API); it holds a fd until the short-
108
+ * lived process exits — acceptable versus a wedged, no-op self-heal.
109
+ */
110
+ function runExtractZipBounded({ zip, dir, onEntry, extractZip, idleMs, maxMs, setTimer, clearTimer }) {
111
+ return new Promise((resolve) => {
112
+ let settled = false;
113
+ let idleTimer = null;
114
+ let maxTimer = null;
115
+ const done = (val) => {
116
+ if (settled) { return; }
117
+ settled = true;
118
+ if (idleTimer !== null) { clearTimer(idleTimer); }
119
+ if (maxTimer !== null) { clearTimer(maxTimer); }
120
+ resolve(val);
121
+ };
122
+ const armIdle = () => {
123
+ if (idleTimer !== null) { clearTimer(idleTimer); }
124
+ idleTimer = setTimer(() => done({ ok: false, reason: `stalled: no extract progress for ${idleMs}ms` }), idleMs);
125
+ };
126
+ maxTimer = setTimer(() => done({ ok: false, reason: `stalled: exceeded ${maxMs}ms` }), maxMs);
127
+ armIdle();
128
+ try {
129
+ const result = extractZip(zip, {
130
+ dir,
131
+ onEntry: (entry, zipfile) => {
132
+ armIdle(); // progress → restart the idle window
133
+ if (onEntry) {
134
+ try { onEntry(entry, zipfile); } catch { /* caller onEntry must not break extraction */ }
135
+ }
136
+ },
137
+ });
138
+ Promise.resolve(result).then(
139
+ () => done({ ok: true }),
140
+ (e) => done({ ok: false, reason: (e && e.message) || 'extract-zip threw' }),
141
+ );
142
+ } catch (e) {
143
+ done({ ok: false, reason: (e && e.message) || 'extract-zip threw synchronously' });
144
+ }
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Extract `zip` into `dir`, surviving a stalled or broken extract-zip.
150
+ *
151
+ * CONTRACT: a returned {strategy} means files LANDED in `dir` — NOT that any
152
+ * specific payload (e.g. electron.exe) is present. Callers needing a usable
153
+ * binary MUST still stat it (electron-quarantine.verifyExtractOutcome does).
154
+ *
155
+ * @param {string} zip absolute path to the .zip
156
+ * @param {object} opts
157
+ * @param {string} opts.dir destination dir (created if absent)
158
+ * @param {function} [opts.onEntry] forwarded to extract-zip's onEntry
159
+ * @param {string} [opts.platform] override process.platform (native plan)
160
+ * @param {number} [opts.idleMs] no-progress window before treating as stalled
161
+ * @param {number} [opts.maxMs] hard cap for both extract-zip and each spawn
162
+ * @param {object} [opts.deps] injected { fs, extractZip, spawn, setTimeout, clearTimeout, log }
163
+ * @returns {Promise<{strategy:string, fallback?:boolean, extractZipReason?:string}>}
164
+ * @throws {Error} code 'UNZIP_ALL_FAILED' when no strategy produced files.
165
+ */
166
+ async function robustExtract(zip, opts = {}) {
167
+ const {
168
+ dir,
169
+ onEntry,
170
+ platform = process.platform,
171
+ idleMs = IDLE_MS,
172
+ maxMs = MAX_MS,
173
+ deps = {},
174
+ } = opts;
175
+ const fs = deps.fs || fsDefault;
176
+ const extractZip = deps.extractZip || require('extract-zip');
177
+ const spawn = deps.spawn || spawnSync;
178
+ const setTimer = deps.setTimeout || setTimeout;
179
+ const clearTimer = deps.clearTimeout || clearTimeout;
180
+ const log = deps.log || (() => {});
181
+
182
+ fs.mkdirSync(dir, { recursive: true });
183
+
184
+ // Strategy 1: extract-zip, bounded. Trust it only if it RESOLVED and files landed.
185
+ const z = await runExtractZipBounded({ zip, dir, onEntry, extractZip, idleMs, maxMs, setTimer, clearTimer });
186
+ if (z.ok && dirNonEmpty(fs, dir)) {
187
+ return { strategy: 'extract-zip' };
188
+ }
189
+
190
+ // extract-zip stalled / threw / produced nothing → clean partial output, go native.
191
+ const zipReason = z.ok ? 'extract-zip produced no files' : z.reason;
192
+ cleanDir(fs, dir);
193
+ log(`[amicus] extract-zip did not complete (${zipReason}); falling back to native unzip.`);
194
+
195
+ const failures = [];
196
+ for (const strat of nativeUnzipPlan(zip, dir, platform)) {
197
+ let res;
198
+ try {
199
+ res = spawn(strat.cmd, strat.args, { stdio: 'ignore', windowsHide: true, timeout: maxMs });
200
+ } catch (e) {
201
+ failures.push(`${strat.name}: spawn ${(e && e.code) || (e && e.message) || 'threw'}`);
202
+ continue;
203
+ }
204
+ // A spawn error OR an external signal-kill (status:null, e.g. SIGKILL/OOM,
205
+ // possibly leaving partial files) is a FAILURE — never trust dirNonEmpty here.
206
+ if (res && (res.error || res.signal)) {
207
+ failures.push(`${strat.name}: ${res.error ? (res.error.code || res.error.message) : `killed by ${res.signal}`}`);
208
+ cleanDir(fs, dir);
209
+ continue;
210
+ }
211
+ if (res && typeof res.status === 'number' && res.status !== 0) {
212
+ failures.push(`${strat.name}: exit ${res.status}`);
213
+ cleanDir(fs, dir);
214
+ continue;
215
+ }
216
+ if (dirNonEmpty(fs, dir)) {
217
+ log(`[amicus] recovered via native unzip (${strat.name}).`);
218
+ return { strategy: strat.name, fallback: true, extractZipReason: zipReason };
219
+ }
220
+ failures.push(`${strat.name}: produced no files`);
221
+ cleanDir(fs, dir);
222
+ }
223
+
224
+ const err = new Error(
225
+ `unzip failed for ${zip} (extract-zip: ${zipReason}; native: ${failures.join('; ') || 'no native strategy available'})`,
226
+ );
227
+ err.code = 'UNZIP_ALL_FAILED';
228
+ throw err;
229
+ }
230
+
231
+ module.exports = { robustExtract, nativeUnzipPlan, IDLE_MS, MAX_MS };