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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +80 -0
- package/README.md +13 -8
- 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/electron/setup-ui.js +27 -3
- package/package.json +4 -3
- package/skills/second-opinion/SKILL.md +2 -0
- package/skills/sidecar/SKILL.md +66 -38
- package/src/cli-handlers-resume-continue.js +31 -4
- package/src/cli-handlers-run.js +15 -4
- package/src/cli.js +13 -0
- package/src/mcp-server.js +99 -12
- package/src/mcp-tools.js +26 -4
- package/src/opencode-client.js +18 -2
- package/src/sidecar/continue.js +10 -3
- package/src/sidecar/electron-install.js +9 -8
- package/src/sidecar/fanout-leg.js +26 -1
- package/src/sidecar/fanout-output.js +5 -0
- package/src/sidecar/fanout-validate.js +81 -0
- package/src/sidecar/fanout.js +65 -77
- package/src/sidecar/session-utils.js +6 -0
- package/src/sidecar/setup.js +2 -1
- package/src/utils/alias-resolver.js +6 -35
- package/src/utils/api-key-store.js +1 -9
- package/src/utils/auth-json.js +1 -1
- package/src/utils/config.js +98 -16
- package/src/utils/curated-models.js +33 -4
- package/src/utils/gateway-router.js +115 -0
- package/src/utils/input-validators.js +12 -42
- package/src/utils/model-classification.js +65 -0
- package/src/utils/model-descriptor.js +72 -0
- package/src/utils/model-fetcher.js +35 -9
- package/src/utils/model-input-default.js +32 -0
- package/src/utils/model-validator.js +68 -84
- package/src/utils/node-version-guard.js +16 -0
- package/src/utils/provider-registry.js +57 -0
- package/src/utils/quick-picks.js +11 -3
- package/src/utils/result-schema-rebuild.js +98 -0
- package/src/utils/result-schema.js +6 -76
- package/src/utils/route-error.js +137 -0
- package/src/utils/route-launch.js +179 -0
- package/src/utils/start-helpers.js +96 -43
- package/src/utils/validators.js +1 -8
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "
|
|
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
|
-

|
|
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.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`
|
|
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 |
|
|
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();
|
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/electron/setup-ui.js
CHANGED
|
@@ -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
|
-
|
|
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": "
|
|
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": "^
|
|
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": [
|