amicus 2.2.0 → 3.0.0

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": "2.2.0",
3
+ "version": "3.0.0",
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,43 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [3.0.0] - 2026-07-15
9
+
10
+ ### ⚠️ Breaking
11
+
12
+ - **Node >=22.12 is now required** (`engines.node`). Amicus 3.0 fails fast on older Node with a
13
+ clear message instead of a confusing error deep in provisioning. This is driven by
14
+ `@electron/get` 5.x (ESM-only, requires Node >=22.12), which the Electron self-heal depends on.
15
+ Node 18/20 users — **including headless / council-only users who never touch the GUI** — must
16
+ upgrade Node.
17
+ - **Electron upgraded 28 -> 43.1.1**, which drops OS support for **Windows 8/8.1, Windows Server
18
+ 2012/2012 R2, and macOS 11**. The interactive GUI will not run there; headless runs and the
19
+ council are unaffected.
20
+
21
+ ### Changed
22
+
23
+ - **Electron 28.3.3 -> 43.1.1**, clearing the outstanding high-severity `npm audit` finding
24
+ (ASAR Integrity Bypass, GHSA-vmqv-hx8q-j7mg). Amicus runs Electron **unpackaged**, so the
25
+ ASAR-integrity attack class never applied to its deployment — the concrete effect is a clean
26
+ audit and staying on a supported Electron line.
27
+ - **Content view migrated from the deprecated `BrowserView` to `WebContentsView`**
28
+ (`mainWindow.contentView.addChildView`). All four windows now set `sandbox` explicitly.
29
+ - **`@electron/get` 2.x -> 5.x** (now a direct `dependency`, ESM-only). The self-heal defers a
30
+ lazy dynamic `import()` to the network path and bounds the download with an `AbortSignal`
31
+ timeout (5.x dropped the old `got`-style timeout).
32
+ - CI matrices raised to Node 22/24.
33
+
34
+ ### Fixed
35
+
36
+ - Runtime Node-version guard (`src/utils/node-version-guard.js`) fires early in `bin/amicus.js`,
37
+ before heavy imports, so an unsupported Node fails with an actionable message.
38
+
39
+ ### Known limitations
40
+
41
+ - `@electron/get` 5.x uses native `fetch`, which does **not** honor `HTTPS_PROXY` / `NO_PROXY`.
42
+ Provisioning Electron behind a corporate proxy needs a manual cache copy or `ELECTRON_MIRROR`
43
+ (see `docs/troubleshooting.md`). Headless runs and the council never download Electron.
44
+
8
45
  ## [2.2.0] - 2026-07-14
9
46
 
10
47
  ### Added
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  **A multi-model LLM Council for Claude — with a parallel AI window underneath.**
6
6
 
7
- ![Amicus: an LLM Council and a parallel AI window for Claude](./docs/hero.png)
7
+ ![The Amicus council mid-ritual: five models Gemini 3 Pro, Llama 4, Grok 4, Claude Opus — reading the same material independently, chaired by GPT-5](./docs/council.png)
8
8
 
9
9
  Hand Claude a plan, a design, a diff, an architecture decision, a manuscript — anything — and say *council review this*: Amicus routes it through several models from different families, has them anonymously cross-review each other, and a non-Claude chair synthesizes a verdict you turn into accept/deny edits. Or skip the ceremony and **fork** a single conversation to Gemini, GPT, DeepSeek, or any other model — it works in parallel with full context, and you **fold** the result back when you're ready. Claude orchestrates throughout; you stay in your editor.
10
10
 
@@ -53,10 +53,6 @@ Claude is the orchestrator. The council and chat skills run *on top of* the engi
53
53
 
54
54
  ![What one install delivers: council skill, chat skill, CLI + MCP, live catalog](./docs/what-is-amicus.png)
55
55
 
56
- The council skill in one picture — independent review, before cross-review or the verdict, with Claude Opus seated among the models it's judging:
57
-
58
- ![The Amicus council mid-ritual: five models — Gemini 3 Pro, Llama 4, Grok 4, Claude Opus — reading the same material independently, chaired by GPT-5](./docs/council.png)
59
-
60
56
  ---
61
57
 
62
58
  ## Quick start
@@ -323,7 +319,7 @@ $ amicus status demo123 --json
323
319
  "taskId": "demo123",
324
320
  "status": "complete",
325
321
  "elapsed": "5m 0s",
326
- "version": "2.2.0",
322
+ "version": "3.0.0",
327
323
  "model": "google/gemini-2.5-flash",
328
324
  "phase": "terminal"
329
325
  }
package/bin/amicus.js CHANGED
@@ -7,6 +7,11 @@
7
7
  * Routes commands to appropriate handlers.
8
8
  */
9
9
 
10
+ // Node version guard: fail fast on unsupported Node versions
11
+ const { checkNodeVersion } = require('../src/utils/node-version-guard');
12
+ const _nv = checkNodeVersion(process.version);
13
+ if (!_nv.ok) { process.stderr.write(_nv.message + '\n'); process.exit(1); }
14
+
10
15
  // Load API keys from all sources: process.env > amicus .env > auth.json
11
16
  const { loadCredentials } = require('../src/utils/env-loader');
12
17
  loadCredentials();
@@ -54,9 +54,9 @@
54
54
  * @param {() => boolean} [deps.hasCompleted] - Whether the fold's
55
55
  * `[SIDECAR_FOLD]` stdout write has actually succeeded. Falls back to
56
56
  * `hasFolded()` when omitted.
57
- * @param {(mainWindow: object, contentView: object) => Promise<void>} deps.triggerFold
57
+ * @param {(mainWindow: object, opencodeView: object) => Promise<void>} deps.triggerFold
58
58
  * - The SAME fold.js closure used by the shortcut/toolbar/IPC paths.
59
- * @returns {{ handleClose: (event: object, mainWindow: object, contentView: object) => void }}
59
+ * @returns {{ handleClose: (event: object, mainWindow: object, opencodeView: object) => void }}
60
60
  */
61
61
  function createCloseGuard({ hasFolded, isFolding, hasCompleted, triggerFold }) {
62
62
  const checkIsFolding = isFolding || hasFolded;
@@ -78,7 +78,7 @@ function createCloseGuard({ hasFolded, isFolding, hasCompleted, triggerFold }) {
78
78
  }
79
79
  }
80
80
 
81
- function handleClose(event, mainWindow, contentView) {
81
+ function handleClose(event, mainWindow, opencodeView) {
82
82
  if (checkHasCompleted()) {
83
83
  // Fold already completed — proceed exactly like the pre-existing
84
84
  // behavior (no interception, no destroy call from the guard itself;
@@ -110,7 +110,7 @@ function createCloseGuard({ hasFolded, isFolding, hasCompleted, triggerFold }) {
110
110
  }
111
111
  closeFoldAttempted = true;
112
112
 
113
- Promise.resolve(triggerFold(mainWindow, contentView)).then(() => {
113
+ Promise.resolve(triggerFold(mainWindow, opencodeView)).then(() => {
114
114
  // triggerFold can RESOLVE without ever calling mainWindow.close() —
115
115
  // its outer catch swallows failures (including a synchronous throw
116
116
  // from the post-write nudge-overlay executeJavaScript call, which can
package/electron/fold.js CHANGED
@@ -41,13 +41,13 @@ function createFoldHandler(state) {
41
41
  let folded = false;
42
42
  let completed = false;
43
43
 
44
- async function triggerFold(mainWindow, contentView) {
44
+ async function triggerFold(mainWindow, opencodeView) {
45
45
  if (folded) { return; }
46
46
  folded = true;
47
47
  completed = false;
48
48
 
49
49
  // Show fold progress in toolbar and content overlay
50
- showFoldOverlay(mainWindow, contentView);
50
+ showFoldOverlay(mainWindow, opencodeView);
51
51
 
52
52
  try {
53
53
  // Ask the model to generate a structured summary
@@ -76,8 +76,8 @@ function createFoldHandler(state) {
76
76
  logger.info('Fold completed', { taskId: state.taskId });
77
77
 
78
78
  // Show nudge overlay before closing
79
- if (contentView) {
80
- await contentView.webContents.executeJavaScript(`
79
+ if (opencodeView) {
80
+ await opencodeView.webContents.executeJavaScript(`
81
81
  (function() {
82
82
  var overlay = document.getElementById('amicus-fold-overlay');
83
83
  if (overlay) {
@@ -147,7 +147,7 @@ function createFoldHandler(state) {
147
147
  * Note: The JS strings below contain only hardcoded markup (no user input),
148
148
  * so there is no XSS risk from DOM manipulation.
149
149
  */
150
- function showFoldOverlay(mainWindow, contentView) {
150
+ function showFoldOverlay(mainWindow, opencodeView) {
151
151
  if (mainWindow) {
152
152
  mainWindow.webContents.executeJavaScript(`
153
153
  (function() {
@@ -174,7 +174,7 @@ function showFoldOverlay(mainWindow, contentView) {
174
174
  })();
175
175
  `).catch(() => {});
176
176
  }
177
- if (contentView) {
177
+ if (opencodeView) {
178
178
  // Scope token vars to the overlay container so var(--x) resolves without
179
179
  // touching OpenCode's own :root (which would clobber its CSS variables).
180
180
  const rawCss = tokenCss({ absoluteFontUrls: true });
@@ -182,9 +182,9 @@ function showFoldOverlay(mainWindow, contentView) {
182
182
  // custom properties are defined on #amicus-fold-overlay and inherited by
183
183
  // its descendants. @font-face blocks are left at global scope (no selector).
184
184
  const scopedCss = rawCss.replace(/:root\s*\{/, '#amicus-fold-overlay {');
185
- contentView.webContents.insertCSS(scopedCss).catch(() => {});
185
+ opencodeView.webContents.insertCSS(scopedCss).catch(() => {});
186
186
 
187
- contentView.webContents.executeJavaScript(`
187
+ opencodeView.webContents.executeJavaScript(`
188
188
  (function() {
189
189
  if (!document.getElementById('fold-spin-style')) {
190
190
  var style = document.createElement('style');
@@ -5,8 +5,8 @@
5
5
  * unit-testable (main.js itself runs heavy Electron side effects at import).
6
6
  *
7
7
  * - isPrivilegedSender: pin privileged IPC handlers to the toolbar window so a
8
- * compromised/remote page in the OpenCode BrowserView cannot invoke them (M9).
9
- * - isAllowedContentNavigation: pin the OpenCode BrowserView to its localhost
8
+ * compromised/remote page in the OpenCode WebContentsView cannot invoke them (M9).
9
+ * - isAllowedContentNavigation: pin the OpenCode WebContentsView to its localhost
10
10
  * origin so it cannot be navigated off to an attacker-controlled page (M9).
11
11
  * - handleFatalException: EPIPE stays a no-op; any other uncaught exception is
12
12
  * logged and then quits the app rather than leaving a wedged invisible shell
@@ -26,7 +26,7 @@ function isPrivilegedSender(event, getToolbarWindow) {
26
26
  }
27
27
 
28
28
  /**
29
- * Whether a navigation target is allowed for the OpenCode content BrowserView.
29
+ * Whether a navigation target is allowed for the OpenCode content WebContentsView.
30
30
  * Only the OpenCode localhost origin (any path) is permitted; everything else
31
31
  * (external http(s), file:, etc.) is blocked. data: URLs are allowed so the
32
32
  * in-app load-error page can render.
package/electron/main.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Amicus Electron Shell - v3
3
3
  *
4
- * Uses BrowserView to split the window into two physical areas:
4
+ * Uses WebContentsView to split the window into two physical areas:
5
5
  * - Top: OpenCode Web UI (gets its own viewport, no CSS conflicts)
6
6
  * - Bottom 40px: Amicus toolbar (branding, task ID, timer, fold button)
7
7
  *
@@ -12,7 +12,7 @@
12
12
  * Spec Reference: §4.4 Electron Wrapper
13
13
  */
14
14
 
15
- const { app, BrowserWindow, BrowserView, globalShortcut, ipcMain, screen } = require('electron');
15
+ const { app, BrowserWindow, WebContentsView, globalShortcut, ipcMain, screen } = require('electron');
16
16
  const path = require('path');
17
17
  const { logger } = require('../src/utils/logger');
18
18
  const { buildToolbarHTML, TOOLBAR_H, getBrandName } = require('./toolbar');
@@ -84,7 +84,7 @@ const OPENCODE_URL = `http://localhost:${OPENCODE_PORT}`;
84
84
  // ============================================================================
85
85
 
86
86
  let mainWindow = null;
87
- let contentView = null;
87
+ let opencodeView = null;
88
88
  let currentToolbarH = TOOLBAR_H;
89
89
 
90
90
  const foldHandler = createFoldHandler({
@@ -127,7 +127,7 @@ function createAmicusWindow() {
127
127
  icon: ICON_PATH,
128
128
  webPreferences: {
129
129
  preload: path.join(__dirname, 'preload.js'),
130
- contextIsolation: true, nodeIntegration: false,
130
+ contextIsolation: true, nodeIntegration: false, sandbox: true,
131
131
  }
132
132
  });
133
133
 
@@ -157,27 +157,27 @@ function createAmicusWindow() {
157
157
  mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(toolbarHtml)}`);
158
158
  mainWindow.webContents.on('page-title-updated', (e) => e.preventDefault());
159
159
 
160
- // BrowserView for OpenCode content. It uses a MINIMAL preload that exposes no
160
+ // WebContentsView for OpenCode content. It uses a MINIMAL preload that exposes no
161
161
  // privileged bridge — the OpenCode page must not be able to reach the fold /
162
162
  // settings / update IPC (M9). The toolbar window keeps preload.js.
163
- contentView = new BrowserView({
163
+ opencodeView = new WebContentsView({
164
164
  webPreferences: {
165
165
  preload: path.join(__dirname, 'preload-content.js'),
166
- contextIsolation: true, nodeIntegration: false,
166
+ contextIsolation: true, nodeIntegration: false, sandbox: true,
167
167
  }
168
168
  });
169
169
 
170
170
  // Pin the content view to the OpenCode localhost origin: block any attempt to
171
171
  // navigate it off-origin or open new windows (defense-in-depth, M9). data:
172
172
  // URLs (the in-app load-error page) are still allowed by the guard.
173
- contentView.webContents.on('will-navigate', (event, targetUrl) => {
173
+ opencodeView.webContents.on('will-navigate', (event, targetUrl) => {
174
174
  if (!isAllowedContentNavigation(targetUrl, OPENCODE_URL)) {
175
175
  logger.warn('Blocked content-view navigation', { targetUrl });
176
176
  event.preventDefault();
177
177
  }
178
178
  });
179
- contentView.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
180
- // Load OpenCode off-screen first; only attach BrowserView after rebranding
179
+ opencodeView.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
180
+ // Load OpenCode off-screen first; only attach WebContentsView after rebranding
181
181
  // to prevent the OpenCode logo/splash from flashing during load.
182
182
  mainWindow.on('resize', updateContentBounds);
183
183
 
@@ -190,9 +190,9 @@ function createAmicusWindow() {
190
190
  // is token-driven — it inlines tokenCss() and remaps OpenCode's own :root
191
191
  // custom properties — so it tracks the toolbar without brittle class
192
192
  // selectors. insertCSS is more reliable than preload DOM injection in a
193
- // BrowserView. NOTE: the live visual match is a user-side CDP/manual check.
194
- contentView.webContents.on('dom-ready', () => {
195
- contentView.webContents.insertCSS(buildOpencodeThemeCSS()).catch(() => {});
193
+ // WebContentsView. NOTE: the live visual match is a user-side CDP/manual check.
194
+ opencodeView.webContents.on('dom-ready', () => {
195
+ opencodeView.webContents.insertCSS(buildOpencodeThemeCSS()).catch(() => {});
196
196
  });
197
197
 
198
198
  // Navigate directly to the session URL to bypass the project selection screen.
@@ -205,7 +205,7 @@ function createAmicusWindow() {
205
205
  // failsafe, a failed/stalled UI load leaves an invisible window and a
206
206
  // silently hung process (the historical "Starting up... | 0 messages" bug).
207
207
  const failsafe = attachLoadFailsafe({
208
- webContents: contentView.webContents,
208
+ webContents: opencodeView.webContents,
209
209
  timeoutMs: parseInt(process.env.AMICUS_GUI_LOAD_TIMEOUT_MS || '', 10) || undefined,
210
210
  onFail: ({ reason, errorCode, errorDescription, validatedURL }) => {
211
211
  logger.error('OpenCode UI failed to load', {
@@ -215,12 +215,12 @@ function createAmicusWindow() {
215
215
  const html = buildLoadErrorHTML({
216
216
  url: validatedURL || contentUrl, errorCode, errorDescription
217
217
  });
218
- contentView.webContents
218
+ opencodeView.webContents
219
219
  .loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
220
220
  .catch(() => {});
221
221
  }
222
222
  // On timeout, show whatever is in flight rather than aborting the load.
223
- mainWindow.addBrowserView(contentView);
223
+ mainWindow.contentView.addChildView(opencodeView);
224
224
  updateContentBounds();
225
225
  if (!process.env.AMICUS_HEADLESS_TEST) {
226
226
  mainWindow.show();
@@ -228,16 +228,16 @@ function createAmicusWindow() {
228
228
  }
229
229
  });
230
230
 
231
- contentView.webContents.loadURL(contentUrl);
231
+ opencodeView.webContents.loadURL(contentUrl);
232
232
 
233
- contentView.webContents.on('did-finish-load', () => {
233
+ opencodeView.webContents.on('did-finish-load', () => {
234
234
  // Wait for React to render, then rebrand and show window
235
235
  setTimeout(() => {
236
236
  rebrandUI().then(() => {
237
237
  // Disarm only once the window is actually about to show, so a wedged
238
238
  // rebrand/executeJavaScript is still covered by the timeout.
239
239
  failsafe.cancel();
240
- mainWindow.addBrowserView(contentView);
240
+ mainWindow.contentView.addChildView(opencodeView);
241
241
  updateContentBounds();
242
242
  if (!process.env.AMICUS_HEADLESS_TEST) {
243
243
  mainWindow.show();
@@ -247,7 +247,7 @@ function createAmicusWindow() {
247
247
  });
248
248
 
249
249
  globalShortcut.register(FOLD_SHORTCUT, () => {
250
- foldHandler.triggerFold(mainWindow, contentView);
250
+ foldHandler.triggerFold(mainWindow, opencodeView);
251
251
  });
252
252
 
253
253
  // Poll toolbar for button clicks (IPC doesn't work with data: URLs).
@@ -258,7 +258,7 @@ function createAmicusWindow() {
258
258
  if (!action) { return; }
259
259
  mainWindow.webContents.executeJavaScript('window.__amicusToolbarAction = null');
260
260
  if (action === 'fold') {
261
- foldHandler.triggerFold(mainWindow, contentView);
261
+ foldHandler.triggerFold(mainWindow, opencodeView);
262
262
  } else if (action === 'open-settings') {
263
263
  createSettingsChildWindow();
264
264
  }
@@ -300,11 +300,11 @@ function createAmicusWindow() {
300
300
  }
301
301
 
302
302
  mainWindow.on('close', (event) => {
303
- closeGuard.handleClose(event, mainWindow, contentView);
303
+ closeGuard.handleClose(event, mainWindow, opencodeView);
304
304
  });
305
305
  mainWindow.on('closed', () => {
306
306
  mainWindow = null;
307
- contentView = null;
307
+ opencodeView = null;
308
308
  globalShortcut.unregisterAll();
309
309
  app.quit();
310
310
  });
@@ -334,7 +334,7 @@ async function createSetupWindow() {
334
334
  resizable: false,
335
335
  webPreferences: {
336
336
  preload: path.join(__dirname, 'preload-setup.js'),
337
- contextIsolation: true, nodeIntegration: false,
337
+ contextIsolation: true, nodeIntegration: false, sandbox: false, // preload-setup.js require()s shell (not sandbox-safe)
338
338
  }
339
339
  });
340
340
 
@@ -356,9 +356,9 @@ async function createSetupWindow() {
356
356
  // ============================================================================
357
357
 
358
358
  function updateContentBounds() {
359
- if (!mainWindow || !contentView) { return; }
359
+ if (!mainWindow || !opencodeView) { return; }
360
360
  const [w, h] = mainWindow.getContentSize();
361
- contentView.setBounds({ x: 0, y: 0, width: w, height: h - currentToolbarH });
361
+ opencodeView.setBounds({ x: 0, y: 0, width: w, height: h - currentToolbarH });
362
362
  }
363
363
 
364
364
  // Amicus wordmark SVG in the same pixel/block art style as the OpenCode logo.
@@ -387,12 +387,12 @@ const AMICUS_WORDMARK = [
387
387
  ].join('');
388
388
 
389
389
  function rebrandUI() {
390
- if (!contentView) { return Promise.resolve(); }
390
+ if (!opencodeView) { return Promise.resolve(); }
391
391
  const brandName = getBrandName(CLIENT);
392
392
  // The OpenCode logo may be hidden (display:none/visibility:hidden) by preload.js
393
393
  // or insertCSS before this runs. Use a MutationObserver with a fallback timeout
394
394
  // to catch it whenever React renders it into the DOM.
395
- return contentView.webContents.executeJavaScript(`
395
+ return opencodeView.webContents.executeJavaScript(`
396
396
  (function() {
397
397
  document.title = '${brandName}';
398
398
  var header = document.querySelector('#root > div > header');
@@ -436,7 +436,7 @@ function rebrandUI() {
436
436
  // ============================================================================
437
437
 
438
438
  // These handlers are privileged (fold/settings/update/resize). Only the toolbar
439
- // window may invoke them — the OpenCode content BrowserView must not (M9). The
439
+ // window may invoke them — the OpenCode content WebContentsView must not (M9). The
440
440
  // content view no longer gets a bridge preload, but we still validate the
441
441
  // sender as belt-and-suspenders in case a future preload change reintroduces one.
442
442
  const fromToolbar = (event) => isPrivilegedSender(event, () => mainWindow);
@@ -444,7 +444,7 @@ const fromToolbar = (event) => isPrivilegedSender(event, () => mainWindow);
444
444
  // Amicus mode: fold
445
445
  ipcMain.handle('sidecar:fold', (event) => {
446
446
  if (!fromToolbar(event)) { return; }
447
- return foldHandler.triggerFold(mainWindow, contentView);
447
+ return foldHandler.triggerFold(mainWindow, opencodeView);
448
448
  });
449
449
 
450
450
  // Amicus mode: open settings in a child window
@@ -498,7 +498,7 @@ function createSettingsChildWindow() {
498
498
  resizable: false,
499
499
  webPreferences: {
500
500
  preload: path.join(__dirname, 'preload-setup.js'),
501
- contextIsolation: true, nodeIntegration: false,
501
+ contextIsolation: true, nodeIntegration: false, sandbox: false, // shares preload-setup.js (shell)
502
502
  }
503
503
  });
504
504
 
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * Issue #49 — token-driven theme for the embedded OpenCode web UI.
5
5
  *
6
- * main.js injects this via `contentView.webContents.insertCSS(...)` on
6
+ * main.js injects this via `opencodeView.webContents.insertCSS(...)` on
7
7
  * `dom-ready`. Before #49 that hook only HID OpenCode's header/wordmark; this
8
8
  * module extends it so the embedded chat surface inherits the clay/gold tokens
9
9
  * and matches the token-driven Amicus toolbar (toolbar.js).
@@ -13,7 +13,7 @@
13
13
  * PREFER overriding OpenCode's OWN :root CSS custom properties — a much more
14
14
  * stable surface than `.css-abc123` class selectors. We:
15
15
  * 1. inline the canonical token CSS (tokenCss) so OUR vars + @font-face are
16
- * available inside the BrowserView (absolute font URLs, same as toolbar.js
16
+ * available inside the WebContentsView (absolute font URLs, same as toolbar.js
17
17
  * / setup-ui-styles.js / load-failsafe.js do),
18
18
  * 2. remap a generous superset of OpenCode's plausible theme custom-property
19
19
  * names to our tokens (var(--...)), covering the prefixes OpenCode has
@@ -115,7 +115,7 @@ const ELEMENT_FALLBACKS = `
115
115
  `;
116
116
 
117
117
  /**
118
- * Build the full theme CSS string injected into the OpenCode BrowserView.
118
+ * Build the full theme CSS string injected into the OpenCode WebContentsView.
119
119
  * @returns {string} hide-chrome + inlined tokens + :root overrides + fallbacks.
120
120
  */
121
121
  function buildOpencodeThemeCSS() {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Content Preload - OpenCode BrowserView (minimal, no privileged bridge)
2
+ * Content Preload - OpenCode WebContentsView (minimal, no privileged bridge)
3
3
  *
4
4
  * The OpenCode Web UI is remote-ish content: it should NOT be able to reach the
5
5
  * privileged sidecar IPC (fold/open-settings/perform-update/...). This preload
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "2.2.0",
3
+ "version": "3.0.0",
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": [
@@ -69,6 +69,7 @@
69
69
  "check:tarball": "node scripts/check-tarball-lifecycle.js"
70
70
  },
71
71
  "dependencies": {
72
+ "@electron/get": "^5.0.0",
72
73
  "@modelcontextprotocol/sdk": "^1.27.0",
73
74
  "@opencode-ai/sdk": "^1.1.36",
74
75
  "dotenv": "^17.2.3",
@@ -78,7 +79,7 @@
78
79
  "zod": "^3.0.0"
79
80
  },
80
81
  "optionalDependencies": {
81
- "electron": "^28.0.0"
82
+ "electron": "^43.1.1"
82
83
  },
83
84
  "devDependencies": {
84
85
  "chrome-remote-interface": "^0.33.3",
@@ -90,7 +91,7 @@
90
91
  "ws": "^8.19.0"
91
92
  },
92
93
  "engines": {
93
- "node": ">=18.0.0"
94
+ "node": ">=22.12.0"
94
95
  },
95
96
  "lint-staged": {
96
97
  "src/**/*.js": [
@@ -146,7 +146,7 @@ function cacheRootFor(env = process.env) {
146
146
  * @returns {Promise<void>}
147
147
  */
148
148
  async function controlledProvision({
149
- electronDir, platform, arch, version, downloadArtifact, extract, fs, env = process.env,
149
+ electronDir, platform, arch, version, downloadArtifact, extract, fs, env = process.env, downloadMs = 480000,
150
150
  }) {
151
151
  const zip = await downloadArtifact({
152
152
  version,
@@ -155,11 +155,7 @@ async function controlledProvision({
155
155
  cacheRoot: cacheRootFor(env),
156
156
  platform,
157
157
  arch,
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 } },
158
+ downloadOptions: { signal: AbortSignal.timeout(downloadMs) }, // 5.x native fetch: bound stalled downloads, free the lock
163
159
  });
164
160
  await extractFromCache({ zip, electronDir, platform, extract, fs });
165
161
  }
@@ -214,7 +210,11 @@ async function repairElectron({
214
210
  const spawn = deps.spawn || ((cmd, args, o) => spawnSync(cmd, args, { ...o, timeout: timeoutMs || 480000 }));
215
211
  const findZip = deps.cachedZip || ((o) => cachedZip(o));
216
212
  const acquireLock = deps.acquireLock || ((o) => acquireRepairLock({ ...o, fs }));
217
- const downloadArtifact = deps.downloadArtifact || require('@electron/get').downloadArtifact;
213
+ // Lazy: import the ESM-only @electron/get only on the network path, so cacheOnly
214
+ // repairs and injected mocks stay parseable under Jest (which can't import() ESM).
215
+ const resolveDownloadArtifact = deps.downloadArtifact
216
+ ? async () => deps.downloadArtifact
217
+ : async () => (await import('@electron/get')).downloadArtifact;
218
218
 
219
219
  if (!version) {
220
220
  try {
@@ -268,8 +268,9 @@ async function repairElectron({
268
268
  // download that produced no usable exe is a FAILURE (no false success; #53).
269
269
  let controlledExtracted = false;
270
270
  try {
271
+ const downloadArtifact = await resolveDownloadArtifact();
271
272
  await controlledProvision({
272
- electronDir, platform, arch, version, downloadArtifact, extract, fs, env: process.env,
273
+ electronDir, platform, arch, version, downloadArtifact, extract, fs, env: process.env, downloadMs: timeoutMs,
273
274
  });
274
275
  controlledExtracted = true; // download + extract returned without throwing
275
276
  } catch {
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+ const MIN_NODE = '22.12.0';
3
+
4
+ /** @param {string} current @param {string} min @returns {{ok:boolean,message:string|null}} */
5
+ function checkNodeVersion(current, min = MIN_NODE) {
6
+ const c = current.replace(/^v/, '').split('.').map(Number);
7
+ const m = min.split('.').map(Number);
8
+ for (let i = 0; i < 3; i++) {
9
+ if ((c[i] || 0) > (m[i] || 0)) { return { ok: true, message: null }; }
10
+ if ((c[i] || 0) < (m[i] || 0)) {
11
+ return { ok: false, message: `Amicus 3.0 requires Node >=${min}; you are on ${current.replace(/^v/, '')}. Upgrade Node and retry.` };
12
+ }
13
+ }
14
+ return { ok: true, message: null };
15
+ }
16
+ module.exports = { checkNodeVersion, MIN_NODE };