amicus 1.7.5 → 1.7.7
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 +27 -6
- package/CHANGELOG.md +66 -0
- package/LICENSE +2 -22
- package/README.md +26 -2
- package/bin/amicus.js +4 -4
- package/electron/ipc-guard.js +66 -0
- package/electron/main.js +40 -9
- package/electron/preload-content.js +37 -0
- package/electron/session-route.js +8 -1
- package/package.json +1 -1
- package/src/council/report-html.js +2 -1
- package/src/council/report.js +2 -1
- package/src/council/tally.js +18 -3
- package/src/jsonl-parser.js +31 -2
- package/src/mcp-server.js +55 -2
- package/src/opencode-client.js +48 -6
- package/src/project-root-allowlist.js +75 -0
- package/src/session-manager.js +7 -4
- package/src/session.js +0 -12
- package/src/sidecar/continue.js +29 -6
- package/src/sidecar/fanout-leg.js +42 -29
- package/src/sidecar/fanout-output.js +3 -5
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/interactive-mirror.js +14 -2
- package/src/sidecar/interactive.js +12 -0
- package/src/sidecar/resume.js +23 -7
- package/src/sidecar/setup-window.js +8 -0
- package/src/sidecar/start.js +16 -11
- package/src/utils/atomic-write.js +39 -0
- package/src/utils/error-doc.js +3 -0
- package/src/utils/format-duration.js +24 -0
- package/src/utils/mcp-discovery.js +6 -9
- package/src/utils/mcp-self-identity.js +69 -0
- package/src/utils/server-setup.js +4 -13
- package/src/utils/session-abort.js +3 -1
- package/src/utils/shared-server.js +64 -1
|
@@ -1,19 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.7",
|
|
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
|
-
"author": {
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Christian Wagner"
|
|
7
|
+
},
|
|
6
8
|
"homepage": "https://bourbondog.github.io/amicus/",
|
|
7
9
|
"repository": "https://github.com/BourbonDog/amicus",
|
|
8
10
|
"bugs": "https://github.com/BourbonDog/amicus/issues",
|
|
9
11
|
"license": "MIT",
|
|
10
|
-
"keywords": [
|
|
11
|
-
|
|
12
|
+
"keywords": [
|
|
13
|
+
"claude-code",
|
|
14
|
+
"multi-model",
|
|
15
|
+
"llm",
|
|
16
|
+
"council",
|
|
17
|
+
"second-opinion",
|
|
18
|
+
"sidecar",
|
|
19
|
+
"gemini",
|
|
20
|
+
"gpt",
|
|
21
|
+
"deepseek"
|
|
22
|
+
],
|
|
23
|
+
"skills": [
|
|
24
|
+
"./skills/sidecar",
|
|
25
|
+
"./skills/second-opinion"
|
|
26
|
+
],
|
|
12
27
|
"mcpServers": {
|
|
13
28
|
"amicus": {
|
|
14
29
|
"command": "npx",
|
|
15
|
-
"args": [
|
|
16
|
-
|
|
30
|
+
"args": [
|
|
31
|
+
"-y",
|
|
32
|
+
"amicus@latest",
|
|
33
|
+
"mcp"
|
|
34
|
+
],
|
|
35
|
+
"env": {
|
|
36
|
+
"AMICUS_SKIP_POSTINSTALL": "1"
|
|
37
|
+
}
|
|
17
38
|
}
|
|
18
39
|
}
|
|
19
40
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,72 @@ All notable changes to Amicus are documented here. Format follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.7.7] - 2026-07-01
|
|
9
|
+
|
|
10
|
+
Correctness patch from the 2026-07-01 full product review (multi-agent review, every finding adversarially
|
|
11
|
+
verified against source), executed subagent-driven with per-task adversarial review plus a final whole-branch review.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **Terminal errors now show their actionable hint.** Human-mode errors printed only the message while `--json`
|
|
15
|
+
carried a `hint` field; the hint now prints on a second ` → …` line. Budget-gate refusals finally tell you the
|
|
16
|
+
offending model, the threshold, and the `--max-cost` / `--no-cost-gate` overrides.
|
|
17
|
+
- **Spawned sidecars no longer inherit Amicus's own MCP server.** The recursive-spawn guard only excluded a server
|
|
18
|
+
literally named `sidecar`, but the product registers as `amicus` — so every child model inherited the full
|
|
19
|
+
Amicus toolset and could spawn recursively. Children now exclude any inherited entry that *is* Amicus, matched
|
|
20
|
+
by name **or** by what the command actually runs (`amicus mcp`, `npx … amicus … mcp`, a `bin/amicus.js … mcp`
|
|
21
|
+
path). Note: this strip has no opt-out — a deliberately configured nested Amicus MCP entry is also removed from
|
|
22
|
+
spawned children.
|
|
23
|
+
- **Shared-server crash detection actually works.** The crash/restart machinery listened on an event emitter the
|
|
24
|
+
real server handle never exposed, so it was dead code — a dead engine silently degraded every later session.
|
|
25
|
+
A pid liveness poll now drives detection and restart, and shutting down during the restart backoff cancels the
|
|
26
|
+
pending restart instead of spawning a server nobody asked for.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- **`amicus continue` and `amicus resume` now report failures truthfully** (behavior change): error exits 1,
|
|
30
|
+
timeout exits 2, abort exits 130/143/2 — previously both always exited 0 and recorded the session as
|
|
31
|
+
`complete` even when the model errored or timed out. The session record now finalizes `error`/`timed-out`
|
|
32
|
+
accordingly (interactive sessions that legitimately end with an empty summary still finalize `complete`).
|
|
33
|
+
Scripts that gated on exit code 0 for these verbs will now see real failures.
|
|
34
|
+
|
|
35
|
+
## [1.7.6] - 2026-07-01
|
|
36
|
+
|
|
37
|
+
A second independent review (GLM 5.2), adversarially verified against source, then fixed across 11 lanes.
|
|
38
|
+
20 of 22 confirmed findings fixed; 2 partial (deferred as follow-ups). Full unit suite green.
|
|
39
|
+
|
|
40
|
+
### Security
|
|
41
|
+
- **The `project`/`cwd` MCP input is now sandboxed.** Previously any caller could pass an arbitrary directory
|
|
42
|
+
(e.g. a system path) and Amicus would create session files and spawn a sidecar there. A new project-root
|
|
43
|
+
allow-list rejects out-of-bounds paths **before** any filesystem write or spawn, while still allowing paths
|
|
44
|
+
under your home directory, the current working directory, `AMICUS_PROJECT_DIR`/`AMICUS_PROJECT_ROOTS`, or the
|
|
45
|
+
MCP client's advertised root — so legitimate `--cwd` use is unaffected.
|
|
46
|
+
- **Folded-back sidecar summaries are fenced as untrusted output.** `amicus_read`'s returned summary — produced
|
|
47
|
+
by an arbitrary model — is now wrapped in a read-only fence (mirroring the outbound conversation fence), so
|
|
48
|
+
model prose entering the orchestrator's context is marked as data, not instructions.
|
|
49
|
+
- **The Electron content view no longer shares the privileged bridge.** The embedded OpenCode web view gets a
|
|
50
|
+
minimal preload that exposes nothing privileged, and IPC handlers validate the sender, so only the toolbar can
|
|
51
|
+
trigger update/settings actions.
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
- **A crashed OpenCode server is now detected.** The shared-server crash/restart machinery was unreachable (no
|
|
55
|
+
exit listener was ever attached); a server exit is now wired to the restart path.
|
|
56
|
+
- **Session metadata is written atomically** (temp file + rename), so a crash mid-write can no longer corrupt
|
|
57
|
+
`metadata.json` and silently mask an abort marker.
|
|
58
|
+
- **Port lookup works on Windows.** The stale-process cleanup used a hardcoded `lsof` (a no-op on Windows); it
|
|
59
|
+
now uses the cross-platform `netstat`-based lookup.
|
|
60
|
+
- **A fan-out leg whose setup throws no longer sinks the whole wave** — the leg is turned into an error result
|
|
61
|
+
and `wave.json` is still written.
|
|
62
|
+
- **The setup window can't hang on a spawn failure** — a spawn error now resolves cleanly instead of leaving the
|
|
63
|
+
launch promise pending forever, and the Electron child is killed on parent exit.
|
|
64
|
+
- **Project-scoped `opencode.json` resolves against the target project**, not the launcher's working directory.
|
|
65
|
+
- Smaller correctness/cleanup fixes: single-peer-agreed council findings now count as corroborated; unknown
|
|
66
|
+
council verdicts are guarded; tool-call turns render a summary instead of blank; quote-aware `--mcp` command
|
|
67
|
+
parsing; a model-object shape guard; a single shared duration formatter; timed mirror teardown; a lock on the
|
|
68
|
+
continuation session; and canonical session-route separators.
|
|
69
|
+
|
|
70
|
+
### Known follow-ups
|
|
71
|
+
- Fencing `amicus_council_tally`/`amicus_verdict` (they return JSON records, so they need a field-level fence).
|
|
72
|
+
- Removing the now-dead top-level `tool_use` formatter branch (blocked on an unrelated test assertion).
|
|
73
|
+
|
|
8
74
|
## [1.7.5] - 2026-07-01
|
|
9
75
|
|
|
10
76
|
A batch of fixes from an independent DeepSeek V4 Pro code review, each verified against source.
|
package/LICENSE
CHANGED
|
@@ -1,27 +1,7 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c)
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
Copyright (c) 2026 Christian Wagner Amicus
|
|
3
|
+
Copyright (c) 2026 Christian Wagner
|
|
4
|
+
Copyright (c) 2025 John Renaldi
|
|
25
5
|
|
|
26
6
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
27
7
|
of this software and associated documentation files (the "Software"), to deal
|
package/README.md
CHANGED
|
@@ -57,13 +57,37 @@ Claude is the orchestrator. The council and chat skills run *on top of* the engi
|
|
|
57
57
|
|
|
58
58
|
## Quick start
|
|
59
59
|
|
|
60
|
-
**Install
|
|
60
|
+
**Install** — pick whichever fits; all deliver the same CLI, MCP server, and both skills:
|
|
61
|
+
|
|
62
|
+
**As a Claude Code plugin** — the most native path if you use Claude Code:
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
/plugin marketplace add BourbonDog/amicus
|
|
66
|
+
/plugin install amicus@bourbondog-amicus
|
|
67
|
+
/reload-plugins
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Claude Code registers the MCP server and both skills for you — nothing to configure. (The standalone Electron window is npm-only, and the first council/sidecar call downloads the OpenCode engine.)
|
|
71
|
+
|
|
72
|
+
**With the install script** — macOS, Linux, or Windows (needs [Node.js](https://nodejs.org) ≥ 18):
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# macOS / Linux
|
|
76
|
+
curl -fsSL https://raw.githubusercontent.com/BourbonDog/amicus/main/install.sh | sh
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```powershell
|
|
80
|
+
# Windows (PowerShell)
|
|
81
|
+
irm https://raw.githubusercontent.com/BourbonDog/amicus/main/install.ps1 | iex
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**With npm** — the canonical path (needs [Node.js](https://nodejs.org) ≥ 18):
|
|
61
85
|
|
|
62
86
|
```bash
|
|
63
87
|
npm install -g amicus
|
|
64
88
|
```
|
|
65
89
|
|
|
66
|
-
|
|
90
|
+
For the **npm** and **install-script** paths, a postinstall auto-configures everything — no manual registration:
|
|
67
91
|
|
|
68
92
|
- Registers the **MCP server** in Claude Code and in Claude Desktop / Cowork, so the Amicus tools appear natively.
|
|
69
93
|
- Installs **both skills** into `~/.claude/skills/` — `second-opinion` (the council) and `sidecar` (the chat skill).
|
package/bin/amicus.js
CHANGED
|
@@ -95,10 +95,10 @@ async function main() {
|
|
|
95
95
|
await handleList(args);
|
|
96
96
|
break;
|
|
97
97
|
case 'resume':
|
|
98
|
-
await handleResume(args);
|
|
98
|
+
exitCode = await handleResume(args);
|
|
99
99
|
break;
|
|
100
100
|
case 'continue':
|
|
101
|
-
await handleContinue(args);
|
|
101
|
+
exitCode = await handleContinue(args);
|
|
102
102
|
break;
|
|
103
103
|
case 'read':
|
|
104
104
|
await handleRead(args);
|
|
@@ -188,7 +188,7 @@ async function handleResume(args) {
|
|
|
188
188
|
|
|
189
189
|
const { resumeSidecar } = require('../src/index');
|
|
190
190
|
|
|
191
|
-
await resumeSidecar({
|
|
191
|
+
return await resumeSidecar({
|
|
192
192
|
taskId,
|
|
193
193
|
project: args.cwd,
|
|
194
194
|
headless: args['no-ui'],
|
|
@@ -242,7 +242,7 @@ async function handleContinue(args) {
|
|
|
242
242
|
|
|
243
243
|
const { continueSidecar } = require('../src/index');
|
|
244
244
|
|
|
245
|
-
await continueSidecar({
|
|
245
|
+
return await continueSidecar({
|
|
246
246
|
taskId,
|
|
247
247
|
newTaskId: args['task-id'],
|
|
248
248
|
briefing: args.prompt || args.briefing,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPC Guard Helpers
|
|
3
|
+
*
|
|
4
|
+
* Small, pure helpers extracted from main.js so the security-sensitive bits are
|
|
5
|
+
* unit-testable (main.js itself runs heavy Electron side effects at import).
|
|
6
|
+
*
|
|
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
|
|
10
|
+
* origin so it cannot be navigated off to an attacker-controlled page (M9).
|
|
11
|
+
* - handleFatalException: EPIPE stays a no-op; any other uncaught exception is
|
|
12
|
+
* logged and then quits the app rather than leaving a wedged invisible shell
|
|
13
|
+
* (L10).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether an IPC event originated from the privileged toolbar window.
|
|
18
|
+
* @param {{ sender?: object }} event - The ipcMain event.
|
|
19
|
+
* @param {() => (object|null)} getToolbarWindow - Returns the toolbar BrowserWindow (or null).
|
|
20
|
+
* @returns {boolean} true only when the sender is the toolbar window's webContents.
|
|
21
|
+
*/
|
|
22
|
+
function isPrivilegedSender(event, getToolbarWindow) {
|
|
23
|
+
const win = getToolbarWindow();
|
|
24
|
+
if (!win || win.isDestroyed?.()) { return false; }
|
|
25
|
+
return Boolean(event && event.sender && event.sender === win.webContents);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether a navigation target is allowed for the OpenCode content BrowserView.
|
|
30
|
+
* Only the OpenCode localhost origin (any path) is permitted; everything else
|
|
31
|
+
* (external http(s), file:, etc.) is blocked. data: URLs are allowed so the
|
|
32
|
+
* in-app load-error page can render.
|
|
33
|
+
* @param {string} targetUrl - The URL the view is trying to navigate to.
|
|
34
|
+
* @param {string} allowedOrigin - e.g. 'http://localhost:4096'.
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
function isAllowedContentNavigation(targetUrl, allowedOrigin) {
|
|
38
|
+
if (typeof targetUrl !== 'string' || !targetUrl) { return false; }
|
|
39
|
+
if (targetUrl.startsWith('data:')) { return true; }
|
|
40
|
+
try {
|
|
41
|
+
const target = new URL(targetUrl);
|
|
42
|
+
const allowed = new URL(allowedOrigin);
|
|
43
|
+
return target.origin === allowed.origin;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Handle a process 'uncaughtException'. EPIPE is a benign no-op; anything else
|
|
51
|
+
* is logged and then quits the app so a genuinely unexpected error cannot leave
|
|
52
|
+
* an invisible, silently-hung shell.
|
|
53
|
+
* @param {Error & { code?: string }} err
|
|
54
|
+
* @param {{ quit: () => void, log?: (err: Error) => void }} deps
|
|
55
|
+
*/
|
|
56
|
+
function handleFatalException(err, { quit, log } = {}) {
|
|
57
|
+
if (err && err.code === 'EPIPE') { return; }
|
|
58
|
+
if (log) {
|
|
59
|
+
log(err);
|
|
60
|
+
} else {
|
|
61
|
+
console.error('Uncaught exception:', err);
|
|
62
|
+
}
|
|
63
|
+
if (typeof quit === 'function') { quit(); }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { isPrivilegedSender, isAllowedContentNavigation, handleFatalException };
|
package/electron/main.js
CHANGED
|
@@ -24,6 +24,11 @@ const { attachLoadFailsafe, buildLoadErrorHTML } = require('./load-failsafe');
|
|
|
24
24
|
const { buildSessionRoute } = require('./session-route');
|
|
25
25
|
const { buildOpencodeThemeCSS } = require('./opencode-theme');
|
|
26
26
|
const { TOKENS } = require('../src/design/tokens');
|
|
27
|
+
const {
|
|
28
|
+
isPrivilegedSender,
|
|
29
|
+
isAllowedContentNavigation,
|
|
30
|
+
handleFatalException,
|
|
31
|
+
} = require('./ipc-guard');
|
|
27
32
|
|
|
28
33
|
const ICON_PATH = path.join(__dirname, 'assets', 'icon.png');
|
|
29
34
|
|
|
@@ -39,8 +44,10 @@ process.stderr.on('error', (err) => {
|
|
|
39
44
|
if (err.code === 'EPIPE') { return; }
|
|
40
45
|
});
|
|
41
46
|
process.on('uncaughtException', (err) => {
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
// EPIPE stays a benign no-op; any other uncaught exception is logged and then
|
|
48
|
+
// quits the app so a genuinely unexpected error cannot leave a wedged,
|
|
49
|
+
// invisible shell hanging in the background (L10).
|
|
50
|
+
handleFatalException(err, { quit: () => app.quit() });
|
|
44
51
|
});
|
|
45
52
|
process.on('unhandledRejection', (reason) => {
|
|
46
53
|
console.error('Unhandled rejection:', reason);
|
|
@@ -135,13 +142,26 @@ function createAmicusWindow() {
|
|
|
135
142
|
mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(toolbarHtml)}`);
|
|
136
143
|
mainWindow.webContents.on('page-title-updated', (e) => e.preventDefault());
|
|
137
144
|
|
|
138
|
-
// BrowserView for OpenCode content
|
|
145
|
+
// BrowserView for OpenCode content. It uses a MINIMAL preload that exposes no
|
|
146
|
+
// privileged bridge — the OpenCode page must not be able to reach the fold /
|
|
147
|
+
// settings / update IPC (M9). The toolbar window keeps preload.js.
|
|
139
148
|
contentView = new BrowserView({
|
|
140
149
|
webPreferences: {
|
|
141
|
-
preload: path.join(__dirname, 'preload.js'),
|
|
150
|
+
preload: path.join(__dirname, 'preload-content.js'),
|
|
142
151
|
contextIsolation: true, nodeIntegration: false,
|
|
143
152
|
}
|
|
144
153
|
});
|
|
154
|
+
|
|
155
|
+
// Pin the content view to the OpenCode localhost origin: block any attempt to
|
|
156
|
+
// navigate it off-origin or open new windows (defense-in-depth, M9). data:
|
|
157
|
+
// URLs (the in-app load-error page) are still allowed by the guard.
|
|
158
|
+
contentView.webContents.on('will-navigate', (event, targetUrl) => {
|
|
159
|
+
if (!isAllowedContentNavigation(targetUrl, OPENCODE_URL)) {
|
|
160
|
+
logger.warn('Blocked content-view navigation', { targetUrl });
|
|
161
|
+
event.preventDefault();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
contentView.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
|
145
165
|
// Load OpenCode off-screen first; only attach BrowserView after rebranding
|
|
146
166
|
// to prevent the OpenCode logo/splash from flashing during load.
|
|
147
167
|
mainWindow.on('resize', updateContentBounds);
|
|
@@ -400,25 +420,35 @@ function rebrandUI() {
|
|
|
400
420
|
// IPC Handlers
|
|
401
421
|
// ============================================================================
|
|
402
422
|
|
|
423
|
+
// These handlers are privileged (fold/settings/update/resize). Only the toolbar
|
|
424
|
+
// window may invoke them — the OpenCode content BrowserView must not (M9). The
|
|
425
|
+
// content view no longer gets a bridge preload, but we still validate the
|
|
426
|
+
// sender as belt-and-suspenders in case a future preload change reintroduces one.
|
|
427
|
+
const fromToolbar = (event) => isPrivilegedSender(event, () => mainWindow);
|
|
428
|
+
|
|
403
429
|
// Amicus mode: fold
|
|
404
|
-
ipcMain.handle('sidecar:fold', () => {
|
|
430
|
+
ipcMain.handle('sidecar:fold', (event) => {
|
|
431
|
+
if (!fromToolbar(event)) { return; }
|
|
405
432
|
return foldHandler.triggerFold(mainWindow, contentView);
|
|
406
433
|
});
|
|
407
434
|
|
|
408
435
|
// Amicus mode: open settings in a child window
|
|
409
|
-
ipcMain.handle('sidecar:open-settings', () => {
|
|
436
|
+
ipcMain.handle('sidecar:open-settings', (event) => {
|
|
437
|
+
if (!fromToolbar(event)) { return; }
|
|
410
438
|
createSettingsChildWindow();
|
|
411
439
|
});
|
|
412
440
|
|
|
413
441
|
// Update check
|
|
414
|
-
ipcMain.handle('sidecar:get-update-info', async () => {
|
|
442
|
+
ipcMain.handle('sidecar:get-update-info', async (event) => {
|
|
443
|
+
if (!fromToolbar(event)) { return null; }
|
|
415
444
|
const { getUpdateInfo, initUpdateCheck } = require('../src/utils/updater');
|
|
416
445
|
await initUpdateCheck();
|
|
417
446
|
return getUpdateInfo();
|
|
418
447
|
});
|
|
419
448
|
|
|
420
449
|
// Perform update
|
|
421
|
-
ipcMain.handle('sidecar:perform-update', async () => {
|
|
450
|
+
ipcMain.handle('sidecar:perform-update', async (event) => {
|
|
451
|
+
if (!fromToolbar(event)) { return { success: false, error: 'unauthorized' }; }
|
|
422
452
|
const { performUpdate } = require('../src/utils/updater');
|
|
423
453
|
const result = await performUpdate();
|
|
424
454
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
@@ -428,7 +458,8 @@ ipcMain.handle('sidecar:perform-update', async () => {
|
|
|
428
458
|
});
|
|
429
459
|
|
|
430
460
|
// Resize toolbar area (called when update banner shows/hides)
|
|
431
|
-
ipcMain.handle('sidecar:resize-toolbar', (
|
|
461
|
+
ipcMain.handle('sidecar:resize-toolbar', (event, height) => {
|
|
462
|
+
if (!fromToolbar(event)) { return; }
|
|
432
463
|
currentToolbarH = height;
|
|
433
464
|
updateContentBounds();
|
|
434
465
|
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Preload - OpenCode BrowserView (minimal, no privileged bridge)
|
|
3
|
+
*
|
|
4
|
+
* The OpenCode Web UI is remote-ish content: it should NOT be able to reach the
|
|
5
|
+
* privileged sidecar IPC (fold/open-settings/perform-update/...). This preload
|
|
6
|
+
* therefore exposes NOTHING via contextBridge — it only injects the cosmetic
|
|
7
|
+
* branding CSS. The toolbar window keeps preload.js; the content view uses this.
|
|
8
|
+
*
|
|
9
|
+
* (M9 defense-in-depth: the toolbar and the content view no longer share the
|
|
10
|
+
* bridge-exposing preload.)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Inject CSS to hide OpenCode branding and match the window background color to
|
|
15
|
+
* prevent a white flash on load. Cosmetic only — must never throw.
|
|
16
|
+
*/
|
|
17
|
+
function injectBrandingCss() {
|
|
18
|
+
try {
|
|
19
|
+
const style = document.createElement('style');
|
|
20
|
+
style.textContent = [
|
|
21
|
+
'html, body { background-color: var(--bg, #0a0a0a) !important; }',
|
|
22
|
+
'#root > div > header { display: none !important; }',
|
|
23
|
+
'svg[viewBox="0 0 234 42"] { display: none !important; }',
|
|
24
|
+
].join('\n');
|
|
25
|
+
document.documentElement.appendChild(style);
|
|
26
|
+
} catch {
|
|
27
|
+
// cosmetic — a failed injection must not break the preload
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// documentElement is still null while the preload evaluates; defer until the
|
|
32
|
+
// DOM exists so the injection cannot null-deref.
|
|
33
|
+
if (document.documentElement) {
|
|
34
|
+
injectBrandingCss();
|
|
35
|
+
} else {
|
|
36
|
+
document.addEventListener('DOMContentLoaded', injectBrandingCss);
|
|
37
|
+
}
|
|
@@ -12,16 +12,23 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Pure function: no electron, no fs, no process state. Safe to unit-test.
|
|
14
14
|
*
|
|
15
|
+
* The directory is canonicalized before encoding so incidental separator
|
|
16
|
+
* differences (Windows '\\' vs '/', mixed/duplicate separators) produce the same
|
|
17
|
+
* route segment — otherwise OpenCode normalizing '/' vs '\\' would yield a
|
|
18
|
+
* mismatched base64url and a "session not found" route.
|
|
19
|
+
*
|
|
15
20
|
* @param {string} baseUrl - OpenCode server base URL (e.g. http://localhost:4096)
|
|
16
21
|
* @param {string} [sessionId] - OpenCode session id; falsy → return baseUrl only
|
|
17
22
|
* @param {string} sessionDirectory - The directory the session is scoped to
|
|
18
23
|
* @returns {string} Fully-qualified route URL, or baseUrl when no session id.
|
|
19
24
|
*/
|
|
25
|
+
const { canonicalProjectPath } = require('../src/utils/project-path');
|
|
26
|
+
|
|
20
27
|
function buildSessionRoute(baseUrl, sessionId, sessionDirectory) {
|
|
21
28
|
if (!sessionId) {
|
|
22
29
|
return baseUrl;
|
|
23
30
|
}
|
|
24
|
-
const seg = Buffer.from(sessionDirectory).toString('base64url');
|
|
31
|
+
const seg = Buffer.from(canonicalProjectPath(sessionDirectory)).toString('base64url');
|
|
25
32
|
return `${baseUrl}/${seg}/session/${sessionId}`;
|
|
26
33
|
}
|
|
27
34
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.7",
|
|
4
4
|
"description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const { formatCost } = require('../utils/pricing');
|
|
11
|
+
const { formatDuration } = require('../utils/format-duration');
|
|
11
12
|
const { TIER_ORDER, SYMBOL } = require('./report');
|
|
12
13
|
const { tokenCss } = require('../design/tokens');
|
|
13
14
|
|
|
@@ -29,7 +30,7 @@ function esc(s) {
|
|
|
29
30
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
30
31
|
}
|
|
31
32
|
function num(v) { return (v === null || v === undefined) ? '—' : v.toFixed(2); }
|
|
32
|
-
function dur(ms) { return (ms
|
|
33
|
+
function dur(ms) { return formatDuration(ms, '—'); }
|
|
33
34
|
|
|
34
35
|
function renderHtml(m) {
|
|
35
36
|
const h = m.header;
|
package/src/council/report.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
const { formatCost, sumWaveUsage } = require('../utils/pricing');
|
|
13
|
+
const { formatDuration } = require('../utils/format-duration');
|
|
13
14
|
|
|
14
15
|
const TIER_ORDER = ['Disputed', 'Contested', 'Confirmed', 'Singleton'];
|
|
15
16
|
const SYMBOL = { agree: '✓', dispute: '✗', neutral: '–' };
|
|
@@ -49,7 +50,7 @@ function toModel(verdict, wave) {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
function fmtNum(v) { return (v === null || v === undefined) ? '—' : v.toFixed(2); }
|
|
52
|
-
function fmtDur(ms) { return (ms
|
|
53
|
+
function fmtDur(ms) { return formatDuration(ms, '—'); }
|
|
53
54
|
|
|
54
55
|
function renderMd(m) {
|
|
55
56
|
const h = m.header;
|
package/src/council/tally.js
CHANGED
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
* Peers-only tier cascade. a/d are agree/dispute counts among PEER judges
|
|
6
6
|
* (the raiser's own adjudication is excluded by the caller).
|
|
7
7
|
* Exhaustive and mutually exclusive over all (a,d).
|
|
8
|
+
*
|
|
9
|
+
* Uncontested agreement is Confirmed: either a strong majority (a>=2 && a>d)
|
|
10
|
+
* or a lone corroborating peer with no dispute (a=1 && d===0). The latter must
|
|
11
|
+
* not rank weaker than a lone disputing peer (a=0,d=1, which is Contested); the
|
|
12
|
+
* `confidence` flag ('thin' when a+d<=1) is what separates single-peer
|
|
13
|
+
* corroboration from a multi-peer majority. Singleton is now reserved for the
|
|
14
|
+
* no-signal case (a=0,d=0).
|
|
8
15
|
* @param {number} a - peer agree count
|
|
9
16
|
* @param {number} d - peer dispute count
|
|
10
17
|
* @returns {{tier:string, confidence:'thin'|'solid'}}
|
|
@@ -12,7 +19,7 @@
|
|
|
12
19
|
function assignTier(a, d) {
|
|
13
20
|
let tier;
|
|
14
21
|
if (d >= 2 && d > a) { tier = 'Disputed'; }
|
|
15
|
-
else if (a >= 2 && a > d) { tier = 'Confirmed'; }
|
|
22
|
+
else if ((a >= 2 && a > d) || (a === 1 && d === 0)) { tier = 'Confirmed'; }
|
|
16
23
|
else if (d >= 1) { tier = 'Contested'; }
|
|
17
24
|
else { tier = 'Singleton'; }
|
|
18
25
|
const confidence = (a + d <= 1) ? 'thin' : 'solid';
|
|
@@ -82,9 +89,17 @@ function tally(input) {
|
|
|
82
89
|
}
|
|
83
90
|
const outFindings = findings.map(f => {
|
|
84
91
|
const votes = byFinding.get(f.id) || [];
|
|
85
|
-
|
|
92
|
+
// Only exclude the raiser's own vote when a raiser is known; the raiser is
|
|
93
|
+
// populated by the orchestrator (not the reviewer JSON), so an unset raiser
|
|
94
|
+
// must not silently drop a real peer vote (L8).
|
|
95
|
+
const peers = f.raiser ? votes.filter(v => v.judge !== f.raiser) : votes;
|
|
86
96
|
const basis = { a: 0, d: 0, n: 0 };
|
|
87
|
-
|
|
97
|
+
// Skip unknown verdict strings so a stray value can't corrupt the basis via
|
|
98
|
+
// basis[undefined] = NaN (L9).
|
|
99
|
+
for (const v of peers) {
|
|
100
|
+
const key = VERDICTS[v.verdict];
|
|
101
|
+
if (key !== undefined) { basis[key] += 1; }
|
|
102
|
+
}
|
|
88
103
|
const { tier, confidence } = assignTier(basis.a, basis.d);
|
|
89
104
|
return { id: f.id, raiser: f.raiser, severity: f.severity, tier, basis, confidence,
|
|
90
105
|
tierOverride: null, adjudications: votes };
|
package/src/jsonl-parser.js
CHANGED
|
@@ -94,16 +94,45 @@ function extractContent(message) {
|
|
|
94
94
|
return content;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
// Array content (Claude API format with text blocks)
|
|
97
|
+
// Array content (Claude API format with text/tool blocks). Text blocks pass
|
|
98
|
+
// through; non-text blocks (tool_use / tool_result) are summarized instead of
|
|
99
|
+
// dropped, so tool-only assistant turns don't render as an empty string.
|
|
98
100
|
if (Array.isArray(content)) {
|
|
99
101
|
return content
|
|
100
|
-
.map(block => block
|
|
102
|
+
.map(block => summarizeBlock(block))
|
|
101
103
|
.join('');
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
return '';
|
|
105
107
|
}
|
|
106
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Summarize a single content block to a display string.
|
|
111
|
+
* Text blocks return their text; tool_use/tool_result blocks return a short
|
|
112
|
+
* placeholder so they aren't silently collapsed to '' by extractContent.
|
|
113
|
+
* @param {object} block - A Claude API content block
|
|
114
|
+
* @returns {string} Display text for the block
|
|
115
|
+
*/
|
|
116
|
+
function summarizeBlock(block) {
|
|
117
|
+
if (!block || typeof block !== 'object') {
|
|
118
|
+
return '';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof block.text === 'string') {
|
|
122
|
+
return block.text;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (block.type === 'tool_use') {
|
|
126
|
+
return `[tool_use: ${block.name || 'unknown'}]`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (block.type === 'tool_result') {
|
|
130
|
+
return '[tool_result]';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return '';
|
|
134
|
+
}
|
|
135
|
+
|
|
107
136
|
/**
|
|
108
137
|
* Format a single message for context output
|
|
109
138
|
* Spec Reference: §5.3 Context Format
|