amicus 1.7.4 → 1.7.6
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 +76 -0
- package/bin/amicus.js +13 -0
- 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/cli-handlers-run.js +4 -0
- package/src/cli.js +22 -0
- package/src/context-compression.js +2 -0
- package/src/context.js +2 -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/headless.js +35 -12
- package/src/jsonl-parser.js +31 -2
- package/src/mcp-server.js +85 -11
- package/src/opencode-client.js +63 -7
- package/src/project-root-allowlist.js +75 -0
- package/src/session-manager.js +16 -5
- package/src/session.js +0 -12
- package/src/sidecar/continue.js +5 -0
- package/src/sidecar/conversation-mirror.js +15 -2
- 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/setup-window.js +8 -0
- package/src/sidecar/start.js +10 -4
- package/src/utils/atomic-write.js +39 -0
- package/src/utils/auth-json.js +47 -7
- package/src/utils/format-duration.js +24 -0
- package/src/utils/server-setup.js +4 -13
- package/src/utils/session-abort.js +3 -1
- package/src/utils/shared-server.js +31 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.6",
|
|
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": { "name": "Christian Wagner" },
|
|
6
6
|
"homepage": "https://bourbondog.github.io/amicus/",
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,82 @@ All notable changes to Amicus are documented here. Format follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.7.6] - 2026-07-01
|
|
9
|
+
|
|
10
|
+
A second independent review (GLM 5.2), adversarially verified against source, then fixed across 11 lanes.
|
|
11
|
+
20 of 22 confirmed findings fixed; 2 partial (deferred as follow-ups). Full unit suite green.
|
|
12
|
+
|
|
13
|
+
### Security
|
|
14
|
+
- **The `project`/`cwd` MCP input is now sandboxed.** Previously any caller could pass an arbitrary directory
|
|
15
|
+
(e.g. a system path) and Amicus would create session files and spawn a sidecar there. A new project-root
|
|
16
|
+
allow-list rejects out-of-bounds paths **before** any filesystem write or spawn, while still allowing paths
|
|
17
|
+
under your home directory, the current working directory, `AMICUS_PROJECT_DIR`/`AMICUS_PROJECT_ROOTS`, or the
|
|
18
|
+
MCP client's advertised root — so legitimate `--cwd` use is unaffected.
|
|
19
|
+
- **Folded-back sidecar summaries are fenced as untrusted output.** `amicus_read`'s returned summary — produced
|
|
20
|
+
by an arbitrary model — is now wrapped in a read-only fence (mirroring the outbound conversation fence), so
|
|
21
|
+
model prose entering the orchestrator's context is marked as data, not instructions.
|
|
22
|
+
- **The Electron content view no longer shares the privileged bridge.** The embedded OpenCode web view gets a
|
|
23
|
+
minimal preload that exposes nothing privileged, and IPC handlers validate the sender, so only the toolbar can
|
|
24
|
+
trigger update/settings actions.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- **A crashed OpenCode server is now detected.** The shared-server crash/restart machinery was unreachable (no
|
|
28
|
+
exit listener was ever attached); a server exit is now wired to the restart path.
|
|
29
|
+
- **Session metadata is written atomically** (temp file + rename), so a crash mid-write can no longer corrupt
|
|
30
|
+
`metadata.json` and silently mask an abort marker.
|
|
31
|
+
- **Port lookup works on Windows.** The stale-process cleanup used a hardcoded `lsof` (a no-op on Windows); it
|
|
32
|
+
now uses the cross-platform `netstat`-based lookup.
|
|
33
|
+
- **A fan-out leg whose setup throws no longer sinks the whole wave** — the leg is turned into an error result
|
|
34
|
+
and `wave.json` is still written.
|
|
35
|
+
- **The setup window can't hang on a spawn failure** — a spawn error now resolves cleanly instead of leaving the
|
|
36
|
+
launch promise pending forever, and the Electron child is killed on parent exit.
|
|
37
|
+
- **Project-scoped `opencode.json` resolves against the target project**, not the launcher's working directory.
|
|
38
|
+
- Smaller correctness/cleanup fixes: single-peer-agreed council findings now count as corroborated; unknown
|
|
39
|
+
council verdicts are guarded; tool-call turns render a summary instead of blank; quote-aware `--mcp` command
|
|
40
|
+
parsing; a model-object shape guard; a single shared duration formatter; timed mirror teardown; a lock on the
|
|
41
|
+
continuation session; and canonical session-route separators.
|
|
42
|
+
|
|
43
|
+
### Known follow-ups
|
|
44
|
+
- Fencing `amicus_council_tally`/`amicus_verdict` (they return JSON records, so they need a field-level fence).
|
|
45
|
+
- Removing the now-dead top-level `tool_use` formatter branch (blocked on an unrelated test assertion).
|
|
46
|
+
|
|
47
|
+
## [1.7.5] - 2026-07-01
|
|
48
|
+
|
|
49
|
+
A batch of fixes from an independent DeepSeek V4 Pro code review, each verified against source.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
- **Long prompts no longer truncate on Windows.** The `amicus_start` and `amicus_continue` MCP
|
|
53
|
+
handlers passed the full prompt inline on the spawned command line, which silently truncated once
|
|
54
|
+
it crossed Windows's ~32 KB argument cap — so a sidecar could run against a corrupted briefing with
|
|
55
|
+
no error. Both paths now write the prompt to a `briefing.md` in the session directory and pass
|
|
56
|
+
`--prompt-file`, matching the existing fanout handler; the CLI `continue` command learned
|
|
57
|
+
`--prompt-file` as well.
|
|
58
|
+
- **`getMessages` no longer masks SDK error responses.** An error-shaped response with no `data`
|
|
59
|
+
array was indistinguishable from "zero messages" in the poll loop; it now logs a warning carrying
|
|
60
|
+
the session id and surfaced error while still returning `[]`.
|
|
61
|
+
- **`getSessionDir` rejects path-traversal task ids.** A defense-in-depth containment guard (the same
|
|
62
|
+
check style used elsewhere in the codebase) throws on a task id that would escape the sessions dir.
|
|
63
|
+
- **Cross-platform `auth.json` discovery.** The one-time OpenCode key-import path was hardcoded to the
|
|
64
|
+
Unix XDG location; it now probes `$XDG_DATA_HOME`, `~/.local/share`, and `%APPDATA%` (Windows) and
|
|
65
|
+
uses the first that exists.
|
|
66
|
+
|
|
67
|
+
### Changed
|
|
68
|
+
- **The fold-completion marker is harder to spoof.** A bare `[SIDECAR_FOLD]` echoed mid-output (e.g. a
|
|
69
|
+
model reproducing these instructions or summarizing a prior sidecar session) no longer forces a
|
|
70
|
+
premature fold — the marker now completes a run only when it is the final non-empty line of output,
|
|
71
|
+
with the existing idle/timeout fallbacks unchanged so a run can never hang.
|
|
72
|
+
- **The conversation-mirror tool-call buffer is bounded.** Capped at 2000 entries with a separate
|
|
73
|
+
dedup set, so a very long tool-heavy session can't grow it without limit.
|
|
74
|
+
- **Unknown `--no-*` flags are treated as boolean.** They no longer swallow the following positional
|
|
75
|
+
argument (`--no-x=value` still records its inline value; allowlisted flags are unchanged).
|
|
76
|
+
- **`--prompt-file` validation is order-independent.** `validateStartArgs` now resolves the prompt
|
|
77
|
+
source itself, so validation no longer depends on the handler having resolved it first.
|
|
78
|
+
|
|
79
|
+
### Docs
|
|
80
|
+
- **Corrected the `tiktoken` dependency note.** It is declared but unused; token sizing uses a
|
|
81
|
+
`length/4` heuristic. Added caveat comments at both estimators. (Removing the unused dependency is
|
|
82
|
+
tracked as a follow-up.)
|
|
83
|
+
|
|
8
84
|
## [1.7.4] - 2026-06-30
|
|
9
85
|
|
|
10
86
|
### Fixed
|
package/bin/amicus.js
CHANGED
|
@@ -215,6 +215,19 @@ async function handleContinue(args) {
|
|
|
215
215
|
process.exit(1);
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
// BL-1: accept --prompt-file (XOR --prompt) so the MCP handler can pass a long
|
|
219
|
+
// follow-up prompt via file, dodging the ~32KB Windows command-line cap.
|
|
220
|
+
if (args['prompt-file'] !== undefined) {
|
|
221
|
+
const { resolvePromptSource } = require('../src/utils/prompt-source');
|
|
222
|
+
const promptRes = resolvePromptSource(args);
|
|
223
|
+
if (promptRes.error) {
|
|
224
|
+
console.error(promptRes.error);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
args.prompt = promptRes.prompt;
|
|
228
|
+
delete args['prompt-file'];
|
|
229
|
+
}
|
|
230
|
+
|
|
218
231
|
if (!args.prompt && !args.briefing) {
|
|
219
232
|
console.error('Error: --prompt is required for continue');
|
|
220
233
|
process.exit(1);
|
|
@@ -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.6",
|
|
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",
|
package/src/cli-handlers-run.js
CHANGED
|
@@ -28,6 +28,10 @@ async function handleStart(args) {
|
|
|
28
28
|
const promptRes = resolvePromptSource(args);
|
|
29
29
|
if (promptRes.error) { process.exit(failJson(useJson, { code: ERROR_CODES.MISSING_PROMPT, message: promptRes.error })); }
|
|
30
30
|
args.prompt = promptRes.prompt;
|
|
31
|
+
// Drop --prompt-file now that it's resolved: validateStartArgs' self-contained
|
|
32
|
+
// guard would otherwise re-run resolvePromptSource with both prompt and
|
|
33
|
+
// prompt-file set and trip its mutually-exclusive branch.
|
|
34
|
+
delete args['prompt-file'];
|
|
31
35
|
}
|
|
32
36
|
if (args.json && !args['no-ui']) {
|
|
33
37
|
process.exit(failJson(useJson, { code: ERROR_CODES.BAD_ARGS, message: 'Error: --json requires --no-ui' }));
|
package/src/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
validateApiKey,
|
|
17
17
|
validateThinkingLevel
|
|
18
18
|
} = require('./utils/validators');
|
|
19
|
+
const { resolvePromptSource } = require('./utils/prompt-source');
|
|
19
20
|
const { logger } = require('./utils/logger');
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -78,6 +79,14 @@ function parseArgs(argv) {
|
|
|
78
79
|
continue;
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
// Unknown negation flags: known --no-* flags are already handled by
|
|
83
|
+
// isBooleanFlag() above; treat any *unregistered* --no-* token as a
|
|
84
|
+
// boolean so it can never swallow the following positional as a value.
|
|
85
|
+
if (key.startsWith('no-')) {
|
|
86
|
+
result[key] = true;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
81
90
|
// Options with values
|
|
82
91
|
if (next && !next.startsWith('--')) {
|
|
83
92
|
result[key] = parseValue(key, next);
|
|
@@ -153,6 +162,19 @@ function parseValue(key, value) {
|
|
|
153
162
|
* @returns {{ valid: boolean, error?: string }}
|
|
154
163
|
*/
|
|
155
164
|
function validateStartArgs(args) {
|
|
165
|
+
// Resolve the prompt source (--prompt XOR --prompt-file) here so validation
|
|
166
|
+
// is self-contained and order-independent: a caller need not have run
|
|
167
|
+
// resolvePromptSource() first. Skipped when args.prompt is already a plain
|
|
168
|
+
// string (the classic path / already-resolved by handleStart), so the empty
|
|
169
|
+
// '' case still falls through to the presence/content checks below.
|
|
170
|
+
if (args['prompt-file'] !== undefined || args.prompt === undefined || args.prompt === true) {
|
|
171
|
+
const res = resolvePromptSource(args);
|
|
172
|
+
if (res.error) {
|
|
173
|
+
return { valid: false, code: 'MISSING_PROMPT', error: res.error };
|
|
174
|
+
}
|
|
175
|
+
args.prompt = res.prompt;
|
|
176
|
+
}
|
|
177
|
+
|
|
156
178
|
// Required: --prompt (presence check)
|
|
157
179
|
if (!args.prompt) {
|
|
158
180
|
return { valid: false, error: 'Error: --prompt is required' };
|
|
@@ -28,6 +28,8 @@ function estimateTokenCount(text) {
|
|
|
28
28
|
return 0;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Intentional cheap heuristic — not a real BPE tokenizer; under/over-counts
|
|
32
|
+
// for CJK, code, and punctuation-dense text. (ceil, vs floor in context.js.)
|
|
31
33
|
return Math.ceil(text.length / 4);
|
|
32
34
|
}
|
|
33
35
|
|
package/src/context.js
CHANGED
|
@@ -52,7 +52,8 @@ function estimateTokens(text) {
|
|
|
52
52
|
if (!text || typeof text !== 'string') {
|
|
53
53
|
return 0;
|
|
54
54
|
}
|
|
55
|
-
//
|
|
55
|
+
// Intentional cheap heuristic (spec §5.3: ~4 chars/token). Not a real BPE
|
|
56
|
+
// tokenizer — under/over-counts for CJK, code, and punctuation-dense text.
|
|
56
57
|
return Math.floor(text.length / 4);
|
|
57
58
|
}
|
|
58
59
|
|
|
@@ -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/headless.js
CHANGED
|
@@ -21,6 +21,27 @@ const { createMirrorState, mirrorMessages, logMessage } = require('./sidecar/con
|
|
|
21
21
|
const FOLD_MARKER = '[SIDECAR_FOLD]';
|
|
22
22
|
const COMPLETE_MARKER = FOLD_MARKER; // backward compat
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* #BL-7: the fold marker is the fixed public string [SIDECAR_FOLD]. A model can
|
|
26
|
+
* legitimately emit it on its own line mid-output — summarizing a prior sidecar,
|
|
27
|
+
* reproducing these instructions, or from scraped content — which used to force a
|
|
28
|
+
* PREMATURE fold. Harden by requiring the marker to be the FINAL non-empty line
|
|
29
|
+
* of the output: a standalone marker followed by MORE content is treated as
|
|
30
|
+
* echoed prose, not a completion signal. Only the true trailing marker folds.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} output - Accumulated assistant output
|
|
33
|
+
* @returns {number} char index where the trailing marker line begins, or -1
|
|
34
|
+
*/
|
|
35
|
+
function findTrailingFoldMarker(output) {
|
|
36
|
+
if (!output) { return -1; }
|
|
37
|
+
// The marker must be the last non-empty line: it sits alone on its line
|
|
38
|
+
// (only intra-line whitespace around it) and NOTHING but whitespace follows
|
|
39
|
+
// to the end of the string. The `(?![\s\S]*\S)` lookahead pins it to the true
|
|
40
|
+
// end — a bare marker followed by more prose is echoed content, not a signal.
|
|
41
|
+
const m = /^[^\S\r\n]*\[SIDECAR_FOLD\][^\S\r\n]*$(?![\s\S]*\S)/m.exec(output);
|
|
42
|
+
return m ? m.index : -1;
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
/**
|
|
25
46
|
* Default timeout: 15 minutes per spec §6.2
|
|
26
47
|
*/
|
|
@@ -399,10 +420,11 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
399
420
|
elapsed: Date.now() - startTime
|
|
400
421
|
});
|
|
401
422
|
|
|
402
|
-
// Check for completion marker
|
|
403
|
-
// Models may
|
|
404
|
-
//
|
|
405
|
-
|
|
423
|
+
// Check for the completion marker as the FINAL non-empty line (#BL-7).
|
|
424
|
+
// Models may emit [SIDECAR_FOLD] on its own line mid-output (echoing a
|
|
425
|
+
// prior sidecar, these instructions, or scraped content) — only treat
|
|
426
|
+
// it as a completion signal when nothing but blank lines follow it.
|
|
427
|
+
if (findTrailingFoldMarker(mirror.output) !== -1) {
|
|
406
428
|
completed = true;
|
|
407
429
|
break;
|
|
408
430
|
}
|
|
@@ -608,7 +630,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
608
630
|
}
|
|
609
631
|
|
|
610
632
|
/**
|
|
611
|
-
* Extract summary from output (everything before [SIDECAR_FOLD])
|
|
633
|
+
* Extract summary from output (everything before the trailing [SIDECAR_FOLD])
|
|
612
634
|
* Spec Reference: §6.2 - Return summary (everything before [SIDECAR_FOLD])
|
|
613
635
|
*
|
|
614
636
|
* @param {string} output - Raw output from OpenCode
|
|
@@ -619,13 +641,13 @@ function extractSummary(output) {
|
|
|
619
641
|
return '';
|
|
620
642
|
}
|
|
621
643
|
|
|
622
|
-
// Split on the fold marker only when it
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
|
|
626
|
-
const
|
|
627
|
-
if (
|
|
628
|
-
return output.slice(0,
|
|
644
|
+
// Split on the fold marker only when it is the FINAL non-empty line (#BL-7).
|
|
645
|
+
// A [SIDECAR_FOLD] echoed mid-output (describing code, reproducing these
|
|
646
|
+
// instructions, or from scraped content) is NOT a delimiter — keep it as
|
|
647
|
+
// content. Only the true trailing marker is stripped.
|
|
648
|
+
const idx = findTrailingFoldMarker(output);
|
|
649
|
+
if (idx !== -1) {
|
|
650
|
+
return output.slice(0, idx).trim();
|
|
629
651
|
}
|
|
630
652
|
return output.trim();
|
|
631
653
|
}
|
|
@@ -659,6 +681,7 @@ module.exports = {
|
|
|
659
681
|
waitForServer,
|
|
660
682
|
withTimeout,
|
|
661
683
|
extractSummary,
|
|
684
|
+
findTrailingFoldMarker,
|
|
662
685
|
formatFoldOutput,
|
|
663
686
|
DEFAULT_TIMEOUT,
|
|
664
687
|
FOLD_MARKER,
|
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
|