amicus 2.2.0 → 3.1.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.
Files changed (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +80 -0
  3. package/README.md +13 -8
  4. package/bin/amicus.js +5 -0
  5. package/electron/close-guard.js +4 -4
  6. package/electron/fold.js +8 -8
  7. package/electron/ipc-guard.js +3 -3
  8. package/electron/main.js +31 -31
  9. package/electron/opencode-theme.js +3 -3
  10. package/electron/preload-content.js +1 -1
  11. package/electron/setup-ui.js +27 -3
  12. package/package.json +4 -3
  13. package/skills/second-opinion/SKILL.md +2 -0
  14. package/skills/sidecar/SKILL.md +66 -38
  15. package/src/cli-handlers-resume-continue.js +31 -4
  16. package/src/cli-handlers-run.js +15 -4
  17. package/src/cli.js +13 -0
  18. package/src/mcp-server.js +99 -12
  19. package/src/mcp-tools.js +26 -4
  20. package/src/opencode-client.js +18 -2
  21. package/src/sidecar/continue.js +10 -3
  22. package/src/sidecar/electron-install.js +9 -8
  23. package/src/sidecar/fanout-leg.js +26 -1
  24. package/src/sidecar/fanout-output.js +5 -0
  25. package/src/sidecar/fanout-validate.js +81 -0
  26. package/src/sidecar/fanout.js +65 -77
  27. package/src/sidecar/session-utils.js +6 -0
  28. package/src/sidecar/setup.js +2 -1
  29. package/src/utils/alias-resolver.js +6 -35
  30. package/src/utils/api-key-store.js +1 -9
  31. package/src/utils/auth-json.js +1 -1
  32. package/src/utils/config.js +98 -16
  33. package/src/utils/curated-models.js +33 -4
  34. package/src/utils/gateway-router.js +115 -0
  35. package/src/utils/input-validators.js +12 -42
  36. package/src/utils/model-classification.js +65 -0
  37. package/src/utils/model-descriptor.js +72 -0
  38. package/src/utils/model-fetcher.js +35 -9
  39. package/src/utils/model-input-default.js +32 -0
  40. package/src/utils/model-validator.js +68 -84
  41. package/src/utils/node-version-guard.js +16 -0
  42. package/src/utils/provider-registry.js +57 -0
  43. package/src/utils/quick-picks.js +11 -3
  44. package/src/utils/result-schema-rebuild.js +98 -0
  45. package/src/utils/result-schema.js +6 -76
  46. package/src/utils/route-error.js +137 -0
  47. package/src/utils/route-launch.js +179 -0
  48. package/src/utils/start-helpers.js +96 -43
  49. package/src/utils/validators.js +1 -8
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "2.2.0",
3
+ "version": "3.1.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,86 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [3.1.0] - 2026-07-15
9
+
10
+ ### Added
11
+
12
+ - **Direct-first gateway routing** (#61): bare `provider/model` model IDs (e.g. `anthropic/claude-opus-4-5`)
13
+ now route to your **direct** provider key when one is configured, falling back to OpenRouter only when
14
+ it isn't. An explicit `openrouter/...`-prefixed model ID remains a force-OpenRouter override — that
15
+ literal form never changes behavior.
16
+ - New `--gateway auto|direct|openrouter` CLI flag on `start`, `fanout`, and `continue` (`auto` is the
17
+ direct-first default) and a matching `gateway` enum on the MCP `amicus_start` / `amicus_fanout` /
18
+ `amicus_continue` tools.
19
+ - New `routing.prefer` config key (`"direct"` default | `"openrouter"`) sets the global default; the
20
+ per-call `--gateway`/`gateway` param overrides it for that run.
21
+ - Non-interactive CLI (`--json`) and MCP now emit a structured `model_route_error` (`type`, `field`,
22
+ `requested`, `reason`) instead of an ad hoc message when a request can't be routed — identical shape
23
+ on both surfaces.
24
+ - Interactive runs get a picker with alternatives when a direct route misses (e.g. key missing or model
25
+ not on that vendor's live catalog), instead of failing outright.
26
+ - Live Anthropic model fetcher: the model catalog now queries Anthropic's API directly for the current
27
+ model list, the same live-fetch treatment OpenAI and Google already had.
28
+ - Session provenance (resume/continue) preserves the gateway a run originally resolved to, even if keys
29
+ or `routing.prefer` change in between.
30
+
31
+ ### Changed
32
+
33
+ - **Default aliases for direct-capable vendors** (`openai`, `google`, `anthropic`, `deepseek`) now resolve
34
+ to bare canonical model IDs instead of `openrouter/...`-prefixed ones, so they participate in
35
+ direct-first routing out of the box. Gateway-only vendors (`qwen`, `grok`, `glm`, and other
36
+ OpenRouter-exclusive families) are unchanged — they still resolve through OpenRouter, since there's no
37
+ direct key path for them.
38
+ - **Migration:** if you hold both an OpenRouter key and a direct key for one of the four vendors above,
39
+ the next run against that vendor moves you to the direct route and prints a one-time notice; it's
40
+ silent after that. Set `routing.prefer: "openrouter"` in config (or pass `--gateway openrouter` /
41
+ `gateway: "openrouter"` per call) to keep routing everything through OpenRouter as before. Aliases you
42
+ already overrode via `amicus setup --add-alias` are untouched.
43
+
44
+ ### Notes
45
+
46
+ - Builds on the #61 gateway-routing foundation (router core, resolution modes, key discovery) merged to
47
+ main ahead of this release; this release wires that router into the live launch path (CLI + MCP), adds
48
+ the control surface (`--gateway` / `gateway` / `routing.prefer`), and switches default guidance to the
49
+ direct-first form.
50
+
51
+ ## [3.0.0] - 2026-07-15
52
+
53
+ ### ⚠️ Breaking
54
+
55
+ - **Node >=22.12 is now required** (`engines.node`). Amicus 3.0 fails fast on older Node with a
56
+ clear message instead of a confusing error deep in provisioning. This is driven by
57
+ `@electron/get` 5.x (ESM-only, requires Node >=22.12), which the Electron self-heal depends on.
58
+ Node 18/20 users — **including headless / council-only users who never touch the GUI** — must
59
+ upgrade Node.
60
+ - **Electron upgraded 28 -> 43.1.1**, which drops OS support for **Windows 8/8.1, Windows Server
61
+ 2012/2012 R2, and macOS 11**. The interactive GUI will not run there; headless runs and the
62
+ council are unaffected.
63
+
64
+ ### Changed
65
+
66
+ - **Electron 28.3.3 -> 43.1.1**, clearing the outstanding high-severity `npm audit` finding
67
+ (ASAR Integrity Bypass, GHSA-vmqv-hx8q-j7mg). Amicus runs Electron **unpackaged**, so the
68
+ ASAR-integrity attack class never applied to its deployment — the concrete effect is a clean
69
+ audit and staying on a supported Electron line.
70
+ - **Content view migrated from the deprecated `BrowserView` to `WebContentsView`**
71
+ (`mainWindow.contentView.addChildView`). All four windows now set `sandbox` explicitly.
72
+ - **`@electron/get` 2.x -> 5.x** (now a direct `dependency`, ESM-only). The self-heal defers a
73
+ lazy dynamic `import()` to the network path and bounds the download with an `AbortSignal`
74
+ timeout (5.x dropped the old `got`-style timeout).
75
+ - CI matrices raised to Node 22/24.
76
+
77
+ ### Fixed
78
+
79
+ - Runtime Node-version guard (`src/utils/node-version-guard.js`) fires early in `bin/amicus.js`,
80
+ before heavy imports, so an unsupported Node fails with an actionable message.
81
+
82
+ ### Known limitations
83
+
84
+ - `@electron/get` 5.x uses native `fetch`, which does **not** honor `HTTPS_PROXY` / `NO_PROXY`.
85
+ Provisioning Electron behind a corporate proxy needs a manual cache copy or `ELECTRON_MIRROR`
86
+ (see `docs/troubleshooting.md`). Headless runs and the council never download Electron.
87
+
8
88
  ## [2.2.0] - 2026-07-14
9
89
 
10
90
  ### 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.1.0",
327
323
  "model": "google/gemini-2.5-flash",
328
324
  "phase": "terminal"
329
325
  }
@@ -342,7 +338,16 @@ amicus models # list the catalog
342
338
  amicus models --search gemini # filter by substring
343
339
  ```
344
340
 
345
- `start`/`fanout` validate your model against the catalog before launching (skip with `--no-validate-model`). You can also always bypass aliases and pass a full `provider/model` or `openrouter/provider/model` ID directly. Catalog internals, alias management, and the full-id passthrough table are in **[docs/usage.md § Models](./docs/usage.md#amicus-models--the-model-catalog)**.
341
+ `start`/`fanout` validate your model against the catalog before launching (skip with `--no-validate-model`). You can also always bypass aliases and pass a full model ID directly — bare `provider/model` (canonical) or `openrouter/provider/model` (explicit override); see Routing below. Catalog internals, alias management, and the full-id passthrough table are in **[docs/usage.md § Models](./docs/usage.md#amicus-models--the-model-catalog)**.
342
+
343
+ ### Routing
344
+
345
+ - **Bare `provider/model`** (e.g. `openai/gpt-5.5`, `anthropic/claude-opus-4.8`, `google/gemini-3.5-flash`) is the canonical, policy-routed form — Amicus routes it **direct-first**: your direct provider key when one is configured, falling back to OpenRouter automatically when only an OpenRouter key exists.
346
+ - **`openrouter/provider/model`** is an explicit override that always forces OpenRouter, even when a direct key is present — reach for it deliberately, or for gateway-only vendors with no direct integration (Qwen, Grok, Mistral, GLM, …).
347
+ - **`--gateway auto|direct|openrouter`** (CLI, also on the MCP tools) overrides routing for one call; `auto` is the direct-first default. `routing.prefer` in `config.json` (`"direct"` by default, or `"openrouter"`) sets the global default.
348
+ - **One-time migration notice:** if you hold both an OpenRouter key and a direct key for a vendor, the first launch that resolves to that vendor under **`auto`** routing (the default) prints a one-time notice that routing moved to direct API; set `routing.prefer: "openrouter"` to restore the old all-OpenRouter behavior. The notice never fires when you explicitly pass `--gateway direct` — that's your own choice, not a migration.
349
+
350
+ Full details, the API-key/prefix table, and the migration notice are in **[docs/configuration.md](./docs/configuration.md)**.
346
351
 
347
352
  ---
348
353
 
@@ -421,7 +426,7 @@ Run `amicus doctor` first — it checks keys, catalog, OpenCode binary, Electron
421
426
  | "council review this" does nothing | The `second-opinion` skill isn't installed | Check `~/.claude/skills/second-opinion/SKILL.md` exists; re-run `npm install -g amicus` (postinstall installs both skills) |
422
427
  | `npm install -g amicus` fails with `EEXIST: … claude-sidecar` | The old upstream `claude-sidecar` package is still installed globally; npm won't overwrite another package's bin shims | `npm uninstall -g claude-sidecar`, then `npm install -g amicus`. Your keys and past sessions are not lost, but v2.0.0 no longer reads the old paths automatically — see [docs/SHIMS.md](./docs/SHIMS.md) for the one-time migration steps (rename `~/.config/sidecar/` and any `.claude/sidecar_sessions/` dirs). |
423
428
  | Install fails partway, or `amicus doctor` reports the OpenCode binary "not found" | A **transient** error during the OpenCode engine's own postinstall (a spawn `ENOENT`, or an antivirus file-lock while it lays down its 11 per-platform binaries) can roll back the whole atomic install — retrying usually succeeds | Just re-run `npm install -g amicus`. If it still fails, clear the cache first: `npm cache clean --force && npm install -g amicus`. |
424
- | `401` / auth error | API key missing, or the model prefix doesn't match the key you have | Run `amicus setup`; make sure the prefix (`openrouter/…` vs `google/…` vs `openai/…` vs `anthropic/…`) matches the credentials you configured. |
429
+ | `401` / auth error | No usable key for the model's vendor bare `provider/model` ids fall back to `OPENROUTER_API_KEY` automatically, so this means neither the direct key nor an OpenRouter key is configured (or `--gateway direct`/`openrouter` forced a gateway whose key is missing) | Run `amicus setup`, or `amicus key <provider> <key>` to add the missing key; see [Routing](#routing). |
425
430
  | `402` / "Payment Required" on first council review / `start` / `fanout` call | Your OpenRouter key is real but has no credit. Key save (`amicus key openrouter <key>` or the setup wizard's key step) only checks that the key **authenticates** — it doesn't check balance, so a zero-credit key saves cleanly and only fails later, on the first real model call. (The `amicus council` subcommand itself is deterministic math and never calls a model.) | Add credit at [openrouter.ai/credits](https://openrouter.ai/credits), **or** switch to a zero-cost council: `amicus setup` → option 2 (Free OpenRouter council) builds one from live `:free`-suffixed models and saves it as `councils.free` — then run `amicus fanout --council free …`. See "Free council (zero-cost)" under [The Council](#the-council) above. |
426
431
  | Session not found | No session matches the given ID | Run `amicus list`, or omit `--session-id` to use the most recent. |
427
432
  | No conversation history found | Project-path encoding | Check `~/.claude/projects/`; `/` and `_` in the project path are encoded as `-` in the directory name. |
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
@@ -10,6 +10,7 @@ const { getDefaultAliases } = require('../src/utils/config');
10
10
  const { getBrandName } = require('./toolbar');
11
11
  const { resolveQuickPicks } = require('../src/utils/quick-picks');
12
12
  const { PROVIDER_FAMILY_NAMES } = require('../src/utils/model-fetcher');
13
+ const { listDirectProviders } = require('../src/utils/provider-registry');
13
14
 
14
15
  /**
15
16
  * @param {object} [options={}]
@@ -32,6 +33,7 @@ function buildSetupHTML(options = {}) {
32
33
  const providerNamesJson = JSON.stringify(PROVIDER_NAMES);
33
34
  const defaultAliasesJson = JSON.stringify(getDefaultAliases());
34
35
  const familyNamesJson = JSON.stringify(PROVIDER_FAMILY_NAMES);
36
+ const directProvidersJson = JSON.stringify(listDirectProviders());
35
37
  return `<!DOCTYPE html>
36
38
  <html><head><meta charset="utf-8"><title>Amicus Setup</title>
37
39
  <style>${css}</style></head><body>
@@ -53,11 +55,11 @@ function buildSetupHTML(options = {}) {
53
55
  </div>
54
56
  </div>
55
57
  <div class="footer"><div class="footer-brand"><svg width="15" height="15" viewBox="0 0 32 32" fill="none"><path d="M4 8H19"/><path d="M4 11H14L19 8"/><path d="M4 14H13L19 8"/><path d="M4 17H12L19 8"/><path d="M4 20H11L19 8"/><path d="M4 23H10L19 8"/><path class="brand-main" d="M19 8H28"/></svg> ${brandName}</div><div class="footer-nav"><button class="nav-btn" id="back-btn" style="display:none">Back</button><button class="nav-btn primary" id="next-btn" disabled>Next</button><button class="nav-btn primary" id="finish-btn" style="display:none">Finish</button></div></div>
56
- ${buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson)}
58
+ ${buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson, directProvidersJson)}
57
59
  </body></html>`;
58
60
  }
59
61
 
60
- function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson) {
62
+ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson, directProvidersJson) {
61
63
  const keysJs = buildKeysScript();
62
64
  const aliasJs = buildAliasScript();
63
65
  const councilJs = buildCouncilScript();
@@ -72,7 +74,9 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
72
74
  var providerNamesData = ${providerNamesJson};
73
75
  var defaultAliases = ${defaultAliasesJson};
74
76
  var PROVIDER_FAMILY_NAMES = ${familyNamesJson};
77
+ var directProviders = ${directProvidersJson};
75
78
  var routingChoices = {};
79
+ var explicitRouteChoices = {};
76
80
  var aliasEdits = {};
77
81
  var aliasDisplay = {};
78
82
  window.availableModels = null;
@@ -195,6 +199,21 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
195
199
  } else { nextBtn.disabled = false; }
196
200
  }
197
201
 
202
+ // #61: an auto-selected (non-explicit) openrouter/<vendor>/<model> route
203
+ // whose vendor also has a direct integration must be stored bare
204
+ // (<vendor>/<model>) so the gateway router can policy-route it direct-first;
205
+ // a stored openrouter/... string is treated as an explicit force-OpenRouter
206
+ // literal and never reconsiders direct-first. Mirrors
207
+ // src/utils/curated-models.js's toCanonicalDefault exactly. Gateway-only
208
+ // vendors (not in directProviders, e.g. qwen/grok/glm/...) pass through
209
+ // unchanged since OpenRouter is their only route anyway.
210
+ function toBareIfDirect(route) {
211
+ if (typeof route !== 'string' || route.indexOf('openrouter/') !== 0) { return route; }
212
+ var rest = route.slice('openrouter/'.length);
213
+ var vendor = rest.split('/')[0];
214
+ return directProviders.indexOf(vendor) !== -1 ? rest : route;
215
+ }
216
+
198
217
  // Single source of the route choice for a quick-pick row: explicit pill
199
218
  // choice if its key still exists, else first provider with a key, else
200
219
  // the row's first route. Returns the full model id or null.
@@ -209,7 +228,11 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
209
228
  }
210
229
  if (!prov) { prov = provs[0]; }
211
230
  }
212
- return mc.routes[prov] || null;
231
+ var route = mc.routes[prov] || null;
232
+ // Only canonicalize auto-picks; an explicit "via OpenRouter" pill click
233
+ // is a deliberate choice and is returned unchanged.
234
+ if (route && !explicitRouteChoices[mc.alias]) { route = toBareIfDirect(route); }
235
+ return route;
213
236
  }
214
237
 
215
238
  function updateRoutingPills() {
@@ -367,6 +390,7 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
367
390
  var provider = pill.getAttribute('data-provider');
368
391
  if (!alias || !provider) { return; }
369
392
  routingChoices[alias] = provider;
393
+ explicitRouteChoices[alias] = true;
370
394
  var toggle = pill.parentElement;
371
395
  toggle.querySelectorAll('.route-pill').forEach(function(p) { p.classList.toggle('active', p === pill); });
372
396
  updateWritePreviews();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "2.2.0",
3
+ "version": "3.1.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": [