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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +37 -0
- package/README.md +2 -6
- package/bin/amicus.js +5 -0
- package/electron/close-guard.js +4 -4
- package/electron/fold.js +8 -8
- package/electron/ipc-guard.js +3 -3
- package/electron/main.js +31 -31
- package/electron/opencode-theme.js +3 -3
- package/electron/preload-content.js +1 -1
- package/package.json +4 -3
- package/src/sidecar/electron-install.js +9 -8
- package/src/utils/node-version-guard.js +16 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "
|
|
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
|
-

|
|
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
|

|
|
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
|
-

|
|
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": "
|
|
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();
|
package/electron/close-guard.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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 (
|
|
80
|
-
await
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
185
|
+
opencodeView.webContents.insertCSS(scopedCss).catch(() => {});
|
|
186
186
|
|
|
187
|
-
|
|
187
|
+
opencodeView.webContents.executeJavaScript(`
|
|
188
188
|
(function() {
|
|
189
189
|
if (!document.getElementById('fold-spin-style')) {
|
|
190
190
|
var style = document.createElement('style');
|
package/electron/ipc-guard.js
CHANGED
|
@@ -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
|
|
9
|
-
* - isAllowedContentNavigation: pin the OpenCode
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
// Load OpenCode off-screen first; only attach
|
|
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
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
231
|
+
opencodeView.webContents.loadURL(contentUrl);
|
|
232
232
|
|
|
233
|
-
|
|
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.
|
|
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,
|
|
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,
|
|
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,
|
|
303
|
+
closeGuard.handleClose(event, mainWindow, opencodeView);
|
|
304
304
|
});
|
|
305
305
|
mainWindow.on('closed', () => {
|
|
306
306
|
mainWindow = null;
|
|
307
|
-
|
|
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 || !
|
|
359
|
+
if (!mainWindow || !opencodeView) { return; }
|
|
360
360
|
const [w, h] = mainWindow.getContentSize();
|
|
361
|
-
|
|
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 (!
|
|
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
|
|
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
|
|
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,
|
|
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 `
|
|
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
|
|
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
|
|
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
|
|
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": "
|
|
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": "^
|
|
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": ">=
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|