amicus 1.9.1 → 2.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 +200 -0
- package/README.md +40 -170
- package/bin/amicus.js +19 -107
- package/commands/council.md +7 -3
- package/electron/fold.js +10 -1
- package/electron/ipc-setup.js +10 -15
- package/electron/main.js +21 -16
- package/electron/preload-setup.js +0 -1
- package/electron/setup-ui-council.js +64 -10
- package/electron/setup-ui-styles.js +34 -3
- package/electron/setup-ui.js +44 -12
- package/package.json +2 -5
- package/skills/second-opinion/MODEL-NOTES.md +2 -2
- package/skills/second-opinion/SKILL.md +30 -28
- package/skills/sidecar/SKILL.md +20 -17
- package/src/cli-handlers-abort.js +244 -0
- package/src/cli-handlers-council.js +101 -1
- package/src/cli-handlers-doctor.js +20 -53
- package/src/cli-handlers-resume-continue.js +103 -0
- package/src/cli-handlers-run.js +9 -8
- package/src/cli-handlers-spend.js +198 -0
- package/src/cli-handlers.js +5 -120
- package/src/cli.js +55 -0
- package/src/council/presets-cli.js +141 -0
- package/src/headless.js +146 -38
- package/src/index.js +1 -9
- package/src/mcp-server.js +140 -113
- package/src/mcp-tools.js +58 -24
- package/src/mcp-wait.js +8 -5
- package/src/opencode-client.js +33 -10
- package/src/prompt-builder.js +32 -11
- package/src/session-manager.js +7 -14
- package/src/sidecar/continue.js +34 -12
- package/src/sidecar/conversation-mirror.js +22 -1
- package/src/sidecar/crash-handler.js +2 -1
- package/src/sidecar/fanout-leg.js +12 -3
- package/src/sidecar/fanout.js +27 -10
- package/src/sidecar/interactive-process.js +6 -17
- package/src/sidecar/interactive.js +5 -6
- package/src/sidecar/models.js +33 -4
- package/src/sidecar/progress.js +2 -1
- package/src/sidecar/read.js +4 -6
- package/src/sidecar/resume.js +41 -11
- package/src/sidecar/session-finalize.js +2 -1
- package/src/sidecar/session-utils.js +13 -35
- package/src/sidecar/setup-window.js +2 -3
- package/src/sidecar/start.js +22 -7
- package/src/utils/abort-coordinator.js +57 -7
- package/src/utils/abort-result.js +36 -0
- package/src/utils/api-key-store.js +2 -13
- package/src/utils/cli-preflight.js +43 -0
- package/src/utils/config.js +30 -43
- package/src/utils/council-presets.js +87 -0
- package/src/utils/doctor-mcp-checks.js +84 -0
- package/src/utils/env-loader.js +1 -2
- package/src/utils/fold-marker.js +79 -0
- package/src/utils/idle-watchdog.js +9 -12
- package/src/utils/input-validators.js +52 -1
- package/src/utils/lifecycle.js +1 -1
- package/src/utils/mcp-discovery.js +80 -19
- package/src/utils/mcp-self-identity.js +12 -5
- package/src/utils/model-catalog.js +54 -6
- package/src/utils/read-slice.js +73 -0
- package/src/utils/remediation-hints.js +9 -0
- package/src/utils/result-schema-version.js +14 -0
- package/src/utils/result-schema.js +18 -12
- package/src/utils/session-abort.js +1 -1
- package/src/utils/session-index-tmp-sweep.js +80 -0
- package/src/utils/session-index.js +4 -5
- package/src/utils/session-path.js +6 -10
- package/src/utils/shared-server.js +7 -5
- package/src/utils/spend-ledger.js +80 -0
- package/src/utils/updater.js +2 -3
- package/src/utils/env-compat.js +0 -38
package/bin/amicus.js
CHANGED
|
@@ -7,27 +7,16 @@
|
|
|
7
7
|
* Routes commands to appropriate handlers.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
// the canonical ~/.config/amicus (copy; legacy kept as a backup). Runs before
|
|
12
|
-
// any config/credential read so both resolve to the unified dir. Best-effort.
|
|
13
|
-
try {
|
|
14
|
-
const { migrateLegacyConfigDir } = require('../src/utils/config');
|
|
15
|
-
const _m = migrateLegacyConfigDir();
|
|
16
|
-
if (_m && _m.migrated) {
|
|
17
|
-
process.stderr.write(`[amicus] Migrated config ${_m.from} → ${_m.to} (legacy kept as a backup).\n`);
|
|
18
|
-
}
|
|
19
|
-
} catch { /* best-effort: never block startup on migration */ }
|
|
20
|
-
|
|
21
|
-
// Load API keys from all sources: process.env > sidecar .env > auth.json
|
|
10
|
+
// Load API keys from all sources: process.env > amicus .env > auth.json
|
|
22
11
|
const { loadCredentials } = require('../src/utils/env-loader');
|
|
23
12
|
loadCredentials();
|
|
24
13
|
|
|
25
|
-
const { parseArgs, getUsage } = require('../src/cli');
|
|
26
|
-
const { validateTaskId } = require('../src/utils/validators');
|
|
27
|
-
const { resolveModelFromArgs, validateFallbackModel } = require('../src/utils/start-helpers');
|
|
14
|
+
const { parseArgs, getUsage, getCommandNames } = require('../src/cli');
|
|
28
15
|
const { handleSetup, handleAbort, handleUpdate, handleMcp, handleKey } = require('../src/cli-handlers');
|
|
29
16
|
const { handleStart, handleFanout, handleRead } = require('../src/cli-handlers-run');
|
|
17
|
+
const { handleResume, handleContinue } = require('../src/cli-handlers-resume-continue');
|
|
30
18
|
const { isOneShotCommand, armExitWatchdog } = require('../src/utils/lifecycle');
|
|
19
|
+
const { suggestCommand } = require('../src/utils/input-validators');
|
|
31
20
|
const { logger } = require('../src/utils/logger');
|
|
32
21
|
|
|
33
22
|
const VERSION = require('../package.json').version;
|
|
@@ -123,6 +112,11 @@ async function main() {
|
|
|
123
112
|
exitCode = await handleDoctor(args);
|
|
124
113
|
break;
|
|
125
114
|
}
|
|
115
|
+
case 'spend': {
|
|
116
|
+
const { handleSpend } = require('../src/cli-handlers-spend');
|
|
117
|
+
exitCode = await handleSpend(args);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
126
120
|
case 'setup':
|
|
127
121
|
await handleSetup(args);
|
|
128
122
|
break;
|
|
@@ -130,7 +124,7 @@ async function main() {
|
|
|
130
124
|
await handleKey(args);
|
|
131
125
|
break;
|
|
132
126
|
case 'abort':
|
|
133
|
-
await handleAbort(args);
|
|
127
|
+
exitCode = await handleAbort(args);
|
|
134
128
|
break;
|
|
135
129
|
case 'mcp':
|
|
136
130
|
await handleMcp();
|
|
@@ -138,10 +132,16 @@ async function main() {
|
|
|
138
132
|
case 'update':
|
|
139
133
|
await handleUpdate();
|
|
140
134
|
break;
|
|
141
|
-
default:
|
|
135
|
+
default: {
|
|
142
136
|
console.error(`Unknown command: ${command}`);
|
|
137
|
+
// suggestCommand honors a cap-3 contract (up to 3 candidates, closest
|
|
138
|
+
// first) — print all of them, not just the closest, matching the
|
|
139
|
+
// join precedent in src/cli-handlers.js.
|
|
140
|
+
const candidates = suggestCommand(command, getCommandNames());
|
|
141
|
+
if (candidates.length > 0) { console.error(`Did you mean: ${candidates.join(', ')}`); }
|
|
143
142
|
console.log(getUsage());
|
|
144
143
|
process.exit(1);
|
|
144
|
+
}
|
|
145
145
|
}
|
|
146
146
|
} catch (err) {
|
|
147
147
|
console.error(`Error: ${err.message}`);
|
|
@@ -162,9 +162,9 @@ async function main() {
|
|
|
162
162
|
* Spec Reference: §4.2
|
|
163
163
|
*/
|
|
164
164
|
async function handleList(args) {
|
|
165
|
-
const {
|
|
165
|
+
const { listAmicus } = require('../src/index');
|
|
166
166
|
|
|
167
|
-
await
|
|
167
|
+
await listAmicus({
|
|
168
168
|
status: args.status,
|
|
169
169
|
all: args.all,
|
|
170
170
|
json: args.json,
|
|
@@ -172,94 +172,6 @@ async function handleList(args) {
|
|
|
172
172
|
});
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
/**
|
|
176
|
-
* Handle 'sidecar resume' command
|
|
177
|
-
* Spec Reference: §4.3
|
|
178
|
-
*/
|
|
179
|
-
async function handleResume(args) {
|
|
180
|
-
const taskId = args._[1];
|
|
181
|
-
|
|
182
|
-
if (!taskId) {
|
|
183
|
-
console.error('Error: task_id is required for resume');
|
|
184
|
-
console.error('Usage: sidecar resume <task_id>');
|
|
185
|
-
process.exit(1);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const taskIdCheck = validateTaskId(taskId);
|
|
189
|
-
if (!taskIdCheck.valid) {
|
|
190
|
-
console.error(taskIdCheck.error);
|
|
191
|
-
process.exit(1);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const { resumeSidecar } = require('../src/index');
|
|
195
|
-
|
|
196
|
-
return await resumeSidecar({
|
|
197
|
-
taskId,
|
|
198
|
-
project: args.cwd,
|
|
199
|
-
headless: args['no-ui'],
|
|
200
|
-
timeout: args.timeout
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Handle 'sidecar continue' command
|
|
206
|
-
* Spec Reference: §4.4
|
|
207
|
-
*/
|
|
208
|
-
async function handleContinue(args) {
|
|
209
|
-
const taskId = args._[1];
|
|
210
|
-
|
|
211
|
-
if (!taskId) {
|
|
212
|
-
console.error('Error: task_id is required for continue');
|
|
213
|
-
console.error('Usage: sidecar continue <task_id> --prompt "..."');
|
|
214
|
-
process.exit(1);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const taskIdCheck = validateTaskId(taskId);
|
|
218
|
-
if (!taskIdCheck.valid) {
|
|
219
|
-
console.error(taskIdCheck.error);
|
|
220
|
-
process.exit(1);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// BL-1: accept --prompt-file (XOR --prompt) so the MCP handler can pass a long
|
|
224
|
-
// follow-up prompt via file, dodging the ~32KB Windows command-line cap.
|
|
225
|
-
if (args['prompt-file'] !== undefined) {
|
|
226
|
-
const { resolvePromptSource } = require('../src/utils/prompt-source');
|
|
227
|
-
const promptRes = resolvePromptSource(args);
|
|
228
|
-
if (promptRes.error) {
|
|
229
|
-
console.error(promptRes.error);
|
|
230
|
-
process.exit(1);
|
|
231
|
-
}
|
|
232
|
-
args.prompt = promptRes.prompt;
|
|
233
|
-
delete args['prompt-file'];
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (!args.prompt && !args.briefing) {
|
|
237
|
-
console.error('Error: --prompt is required for continue');
|
|
238
|
-
process.exit(1);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// F5: an explicitly passed --model gets the same resolution+validation as start.
|
|
242
|
-
if (args.model !== undefined) {
|
|
243
|
-
const { model, alias } = resolveModelFromArgs(args);
|
|
244
|
-
args.model = model;
|
|
245
|
-
args.model = await validateFallbackModel(args, alias);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const { continueSidecar } = require('../src/index');
|
|
249
|
-
|
|
250
|
-
return await continueSidecar({
|
|
251
|
-
taskId,
|
|
252
|
-
newTaskId: args['task-id'],
|
|
253
|
-
briefing: args.prompt || args.briefing,
|
|
254
|
-
model: args.model,
|
|
255
|
-
project: args.cwd,
|
|
256
|
-
contextTurns: args['context-turns'],
|
|
257
|
-
contextMaxTokens: args['context-max-tokens'],
|
|
258
|
-
headless: args['no-ui'],
|
|
259
|
-
timeout: args.timeout
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
|
|
263
175
|
// Run main
|
|
264
176
|
main().catch(err => {
|
|
265
177
|
console.error(`Fatal error: ${err.message}`);
|
package/commands/council.md
CHANGED
|
@@ -17,6 +17,10 @@ the **analysis request**, and the **criteria**. If any of the three is missing o
|
|
|
17
17
|
ambiguous, ask for it before launching any model (the skill's Stage 0 covers this —
|
|
18
18
|
don't re-ask for what is already present).
|
|
19
19
|
|
|
20
|
-
Then follow the second-opinion skill end to end: Stage 0
|
|
21
|
-
setup, council selection with a cost estimate
|
|
22
|
-
|
|
20
|
+
Then follow the second-opinion skill end to end, in pipeline order: Stage 0
|
|
21
|
+
intake/prep and run-folder setup, then council selection with a cost estimate
|
|
22
|
+
and explicit user confirmation; Stage 1 independent reviews, running
|
|
23
|
+
`amicus council validate` on each leg's findings block as it lands; Stage 2
|
|
24
|
+
cross-review, followed by `amicus council tally` once cross-review settles;
|
|
25
|
+
Stage 3 council-chair synthesis; Stage 4 the accept/deny decision pass; and
|
|
26
|
+
Stage 5, which runs `amicus council verdict` to write the final `verdict.json`.
|
package/electron/fold.js
CHANGED
|
@@ -9,6 +9,7 @@ const { logger } = require('../src/utils/logger');
|
|
|
9
9
|
const { requestSummaryFromModel } = require('./summary');
|
|
10
10
|
const { getSummaryTemplate } = require('../src/prompt-builder');
|
|
11
11
|
const { tokenCss } = require('../src/design/tokens');
|
|
12
|
+
const { buildFoldMarker, generateFoldNonce } = require('../src/utils/fold-marker');
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Create a fold handler bound to the window state
|
|
@@ -19,9 +20,17 @@ const { tokenCss } = require('../src/design/tokens');
|
|
|
19
20
|
* @param {string} state.sessionId - OpenCode session ID
|
|
20
21
|
* @param {string} state.taskId - Sidecar task ID
|
|
21
22
|
* @param {number} state.port - OpenCode server port
|
|
23
|
+
* @param {string} [state.nonce] - Per-run fold nonce (15b.3, #BL-7 residual). Set by
|
|
24
|
+
* main.js from AMICUS_FOLD_NONCE — the SAME value baked into the system prompt's
|
|
25
|
+
* fold instruction, so a completion this handler writes matches what the model was
|
|
26
|
+
* actually asked to emit. Falls back to a freshly generated nonce when absent (e.g.
|
|
27
|
+
* a caller/test that doesn't thread one through) — this is purely defensive: the
|
|
28
|
+
* GUI fold path is exit-code driven, not marker-detected, so an un-advertised
|
|
29
|
+
* fallback nonce here cannot be exploited the way headless.js's detector could.
|
|
22
30
|
* @returns {{ triggerFold: Function, hasFolded: Function, isFolding: Function, hasCompleted: Function }}
|
|
23
31
|
*/
|
|
24
32
|
function createFoldHandler(state) {
|
|
33
|
+
const nonce = state.nonce || generateFoldNonce();
|
|
25
34
|
// `folded` is set synchronously at triggerFold ENTRY and covers both
|
|
26
35
|
// "in flight" and "done" — this is `hasFolded()`'s existing external
|
|
27
36
|
// contract (main.js wires it straight into createCloseGuard's `hasFolded`
|
|
@@ -52,7 +61,7 @@ function createFoldHandler(state) {
|
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
const output = [
|
|
55
|
-
|
|
64
|
+
buildFoldMarker(nonce),
|
|
56
65
|
`Model: ${state.model}`,
|
|
57
66
|
`Session: ${state.sessionId || state.taskId}`,
|
|
58
67
|
`Client: ${state.client}`,
|
package/electron/ipc-setup.js
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
* Extracted from main.js to keep file sizes under 300 lines.
|
|
5
5
|
* Registers all setup-mode IPC handlers: validate-key, save-key,
|
|
6
6
|
* remove-key, setup-done, save-config, get-config, get-api-keys,
|
|
7
|
-
*
|
|
7
|
+
* get-catalog, and refresh-catalog.
|
|
8
|
+
* (sidecar:fetch-models was retired in B33/#12 — Step 3's alias editor now
|
|
9
|
+
* shares the TTL-cached get-catalog data Step 2 loads instead of a second,
|
|
10
|
+
* uncached live fetch.)
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
const { ipcMain } = require('electron');
|
|
@@ -160,19 +163,6 @@ function registerSetupHandlers(getMainWindow) {
|
|
|
160
163
|
}
|
|
161
164
|
});
|
|
162
165
|
|
|
163
|
-
ipcMain.handle('sidecar:fetch-models', async () => {
|
|
164
|
-
try {
|
|
165
|
-
const { readApiKeyValues } = require('../src/utils/api-key-store');
|
|
166
|
-
const { fetchAllModels, groupModelsByFamily } = require('../src/utils/model-fetcher');
|
|
167
|
-
const keys = readApiKeyValues();
|
|
168
|
-
const models = await fetchAllModels(keys);
|
|
169
|
-
return groupModelsByFamily(models);
|
|
170
|
-
} catch (err) {
|
|
171
|
-
logger.error('fetch-models handler error', { error: err.message });
|
|
172
|
-
return [];
|
|
173
|
-
}
|
|
174
|
-
});
|
|
175
|
-
|
|
176
166
|
// F5: wizard Step 2 reads the catalog CACHE (self-refreshing when stale).
|
|
177
167
|
ipcMain.handle('sidecar:get-catalog', async () => {
|
|
178
168
|
try {
|
|
@@ -206,7 +196,12 @@ function registerSetupHandlers(getMainWindow) {
|
|
|
206
196
|
const catalog = await getCatalog();
|
|
207
197
|
const free = listFreeModels(catalog);
|
|
208
198
|
const suggested = new Set(suggestFreeCouncil(free, 3).map(r => r.id));
|
|
209
|
-
return free.map(r => ({
|
|
199
|
+
return free.map(r => ({
|
|
200
|
+
id: r.id,
|
|
201
|
+
suggested: suggested.has(r.id),
|
|
202
|
+
name: r.name,
|
|
203
|
+
vendor: r.id.split('/')[1] || '',
|
|
204
|
+
}));
|
|
210
205
|
} catch (_err) { return []; }
|
|
211
206
|
});
|
|
212
207
|
}
|
package/electron/main.js
CHANGED
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
const { app, BrowserWindow, BrowserView, globalShortcut, ipcMain, screen } = require('electron');
|
|
16
16
|
const path = require('path');
|
|
17
17
|
const { logger } = require('../src/utils/logger');
|
|
18
|
-
const { getCompatEnv } = require('../src/utils/env-compat');
|
|
19
18
|
const { buildToolbarHTML, TOOLBAR_H, getBrandName } = require('./toolbar');
|
|
20
19
|
const { createFoldHandler } = require('./fold');
|
|
21
20
|
const { createCloseGuard } = require('./close-guard');
|
|
@@ -58,20 +57,25 @@ process.on('unhandledRejection', (reason) => {
|
|
|
58
57
|
// Configuration from Environment (set by src/sidecar/start.js)
|
|
59
58
|
// ============================================================================
|
|
60
59
|
|
|
61
|
-
const MODE =
|
|
62
|
-
const TASK_ID =
|
|
63
|
-
const MODEL =
|
|
64
|
-
const CWD =
|
|
60
|
+
const MODE = process.env.AMICUS_MODE || 'sidecar';
|
|
61
|
+
const TASK_ID = process.env.AMICUS_TASK_ID || 'unknown';
|
|
62
|
+
const MODEL = process.env.AMICUS_MODEL || 'unknown';
|
|
63
|
+
const CWD = process.env.AMICUS_CWD || process.cwd();
|
|
65
64
|
// The directory the OpenCode session was actually scoped to (#45). Set by the
|
|
66
65
|
// interactive launcher as canonicalProjectPath(--cwd) so the Web-UI route is
|
|
67
66
|
// built from the SAME directory createSession used. Falls back to CWD for
|
|
68
67
|
// back-compat with launchers that predate this env var.
|
|
69
|
-
const SESSION_DIRECTORY =
|
|
70
|
-
const CLIENT =
|
|
71
|
-
const OPENCODE_PORT = parseInt(
|
|
72
|
-
const OPENCODE_SESSION_ID =
|
|
73
|
-
const FOLD_SHORTCUT =
|
|
74
|
-
const WINDOW_POSITION =
|
|
68
|
+
const SESSION_DIRECTORY = process.env.AMICUS_SESSION_DIRECTORY || CWD;
|
|
69
|
+
const CLIENT = process.env.AMICUS_CLIENT || 'code-local';
|
|
70
|
+
const OPENCODE_PORT = parseInt(process.env.AMICUS_OPENCODE_PORT || '4096', 10);
|
|
71
|
+
const OPENCODE_SESSION_ID = process.env.AMICUS_SESSION_ID;
|
|
72
|
+
const FOLD_SHORTCUT = process.env.AMICUS_FOLD_SHORTCUT || 'CommandOrControl+Shift+F';
|
|
73
|
+
const WINDOW_POSITION = process.env.AMICUS_WINDOW_POSITION || 'right';
|
|
74
|
+
// 15b.3: per-run fold nonce (#BL-7 residual). Set by the interactive launcher
|
|
75
|
+
// (src/sidecar/interactive-process.js buildElectronEnv) from the SAME value
|
|
76
|
+
// baked into the system prompt's fold instruction. undefined when a launcher
|
|
77
|
+
// predates this env var — fold.js falls back to the legacy bare marker.
|
|
78
|
+
const FOLD_NONCE = process.env.AMICUS_FOLD_NONCE;
|
|
75
79
|
|
|
76
80
|
const OPENCODE_URL = `http://localhost:${OPENCODE_PORT}`;
|
|
77
81
|
|
|
@@ -89,7 +93,8 @@ const foldHandler = createFoldHandler({
|
|
|
89
93
|
cwd: CWD,
|
|
90
94
|
sessionId: OPENCODE_SESSION_ID,
|
|
91
95
|
taskId: TASK_ID,
|
|
92
|
-
port: OPENCODE_PORT
|
|
96
|
+
port: OPENCODE_PORT,
|
|
97
|
+
nonce: FOLD_NONCE
|
|
93
98
|
});
|
|
94
99
|
// Auto-fold on close (backlog B01): a user-initiated window close with no
|
|
95
100
|
// fold yet run must not silently discard the session summary. See
|
|
@@ -128,7 +133,7 @@ function createAmicusWindow() {
|
|
|
128
133
|
|
|
129
134
|
// Check for updates: prefer env var from CLI (cache is one-shot), fallback to direct check
|
|
130
135
|
let updateInfo = null;
|
|
131
|
-
const updateInfoRaw =
|
|
136
|
+
const updateInfoRaw = process.env.AMICUS_UPDATE_INFO;
|
|
132
137
|
if (updateInfoRaw) {
|
|
133
138
|
try { updateInfo = JSON.parse(updateInfoRaw); } catch (_) {}
|
|
134
139
|
}
|
|
@@ -201,7 +206,7 @@ function createAmicusWindow() {
|
|
|
201
206
|
// silently hung process (the historical "Starting up... | 0 messages" bug).
|
|
202
207
|
const failsafe = attachLoadFailsafe({
|
|
203
208
|
webContents: contentView.webContents,
|
|
204
|
-
timeoutMs: parseInt(
|
|
209
|
+
timeoutMs: parseInt(process.env.AMICUS_GUI_LOAD_TIMEOUT_MS || '', 10) || undefined,
|
|
205
210
|
onFail: ({ reason, errorCode, errorDescription, validatedURL }) => {
|
|
206
211
|
logger.error('OpenCode UI failed to load', {
|
|
207
212
|
reason, errorCode, errorDescription, validatedURL, url: contentUrl
|
|
@@ -217,7 +222,7 @@ function createAmicusWindow() {
|
|
|
217
222
|
// On timeout, show whatever is in flight rather than aborting the load.
|
|
218
223
|
mainWindow.addBrowserView(contentView);
|
|
219
224
|
updateContentBounds();
|
|
220
|
-
if (!process.env.
|
|
225
|
+
if (!process.env.AMICUS_HEADLESS_TEST) {
|
|
221
226
|
mainWindow.show();
|
|
222
227
|
}
|
|
223
228
|
}
|
|
@@ -234,7 +239,7 @@ function createAmicusWindow() {
|
|
|
234
239
|
failsafe.cancel();
|
|
235
240
|
mainWindow.addBrowserView(contentView);
|
|
236
241
|
updateContentBounds();
|
|
237
|
-
if (!process.env.
|
|
242
|
+
if (!process.env.AMICUS_HEADLESS_TEST) {
|
|
238
243
|
mainWindow.show();
|
|
239
244
|
}
|
|
240
245
|
});
|
|
@@ -38,23 +38,77 @@ function buildCouncilScript() {
|
|
|
38
38
|
else if (meta && !loaded) { meta.textContent = ''; }
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
// Rows arrive pre-sorted by vendor (listFreeModels); group consecutive
|
|
42
|
+
// same-vendor runs into collapsible sections, mirroring the Step-3
|
|
43
|
+
// alias editor's .alias-group <details>/<summary> precedent.
|
|
44
|
+
function groupByVendor(rows) {
|
|
45
|
+
var groups = [];
|
|
46
|
+
var byVendor = {};
|
|
47
|
+
(rows || []).forEach(function(r) {
|
|
48
|
+
var v = r.vendor || (r.id.split('/')[1] || '');
|
|
49
|
+
if (!byVendor[v]) {
|
|
50
|
+
byVendor[v] = { vendor: v, rows: [] };
|
|
51
|
+
groups.push(byVendor[v]);
|
|
52
|
+
}
|
|
53
|
+
byVendor[v].rows.push(r);
|
|
54
|
+
});
|
|
55
|
+
return groups;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function vendorLabel(vendor) {
|
|
59
|
+
if (!vendor) { return 'Other'; }
|
|
60
|
+
return vendor.charAt(0).toUpperCase() + vendor.slice(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function buildRow(r, idx) {
|
|
64
|
+
var id = 'fc-' + idx;
|
|
65
|
+
var row = document.createElement('label');
|
|
66
|
+
row.className = 'council-row';
|
|
67
|
+
var cb = document.createElement('input');
|
|
68
|
+
cb.type = 'checkbox'; cb.value = r.id; cb.id = id; cb.checked = !!r.suggested;
|
|
69
|
+
var text = document.createElement('span');
|
|
70
|
+
text.className = 'council-row-text';
|
|
71
|
+
var nameEl = document.createElement('span');
|
|
72
|
+
nameEl.className = 'council-row-name'; nameEl.textContent = r.name || r.id;
|
|
73
|
+
var idEl = document.createElement('span');
|
|
74
|
+
idEl.className = 'council-row-id'; idEl.textContent = r.id;
|
|
75
|
+
text.appendChild(nameEl); text.appendChild(idEl);
|
|
76
|
+
row.appendChild(cb); row.appendChild(text);
|
|
77
|
+
return row;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildGroup(group, startIdx) {
|
|
81
|
+
var details = document.createElement('details');
|
|
82
|
+
details.className = 'council-group';
|
|
83
|
+
details.open = true;
|
|
84
|
+
var summary = document.createElement('summary');
|
|
85
|
+
var summaryLabel = document.createElement('span');
|
|
86
|
+
summaryLabel.textContent = vendorLabel(group.vendor);
|
|
87
|
+
var count = document.createElement('span');
|
|
88
|
+
count.className = 'council-group-count';
|
|
89
|
+
count.textContent = '(' + group.rows.length + ')';
|
|
90
|
+
summary.appendChild(summaryLabel); summary.appendChild(count);
|
|
91
|
+
details.appendChild(summary);
|
|
92
|
+
group.rows.forEach(function(r, i) { details.appendChild(buildRow(r, startIdx + i)); });
|
|
93
|
+
return details;
|
|
94
|
+
}
|
|
95
|
+
|
|
41
96
|
async function loadFree() {
|
|
42
97
|
if (loaded) { return; }
|
|
43
98
|
try {
|
|
44
99
|
var rows = await window.sidecarSetup.invoke('sidecar:fetch-free-models');
|
|
45
100
|
loaded = true;
|
|
46
101
|
results.innerHTML = '';
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
cb.type = 'checkbox'; cb.value = r.id; cb.id = id; cb.checked = !!r.suggested;
|
|
53
|
-
var span = document.createElement('span'); span.textContent = r.id;
|
|
54
|
-
row.appendChild(cb); row.appendChild(span);
|
|
55
|
-
results.appendChild(row);
|
|
102
|
+
var groups = groupByVendor(rows);
|
|
103
|
+
var idx = 0;
|
|
104
|
+
groups.forEach(function(group) {
|
|
105
|
+
results.appendChild(buildGroup(group, idx));
|
|
106
|
+
idx += group.rows.length;
|
|
56
107
|
});
|
|
57
|
-
if (meta) {
|
|
108
|
+
if (meta) {
|
|
109
|
+
meta.textContent = (rows || []).length + ' free models across ' + groups.length +
|
|
110
|
+
' provider' + (groups.length === 1 ? '' : 's');
|
|
111
|
+
}
|
|
58
112
|
} catch (_e) { if (meta) { meta.textContent = 'Could not load free models.'; } }
|
|
59
113
|
}
|
|
60
114
|
|
|
@@ -357,9 +357,40 @@ function __rawWizardCSS() {
|
|
|
357
357
|
/* Free council picker (Step 2) */
|
|
358
358
|
.council-section { margin-top: 14px; }
|
|
359
359
|
.council-toggle { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-muted); cursor: pointer; }
|
|
360
|
-
.council-results {
|
|
361
|
-
|
|
362
|
-
|
|
360
|
+
.council-results {
|
|
361
|
+
max-height: 220px; overflow-y: auto; margin-top: 6px;
|
|
362
|
+
border: 1px solid var(--border); border-radius: var(--r-6);
|
|
363
|
+
}
|
|
364
|
+
.council-results:empty { border: none; }
|
|
365
|
+
.council-group { margin: 0; border-bottom: 1px solid var(--border); }
|
|
366
|
+
.council-group:last-child { border-bottom: none; }
|
|
367
|
+
.council-group summary {
|
|
368
|
+
display: flex; align-items: center; gap: 6px; padding: 6px 10px;
|
|
369
|
+
cursor: pointer; font-size: 12px; font-weight: 500; color: var(--text-muted);
|
|
370
|
+
list-style: none; transition: color var(--dur-fast);
|
|
371
|
+
}
|
|
372
|
+
.council-group summary::-webkit-details-marker { display: none; }
|
|
373
|
+
.council-group summary::before {
|
|
374
|
+
content: '\\25B6'; font-size: 8px; color: var(--text-faint); transition: transform var(--dur-fast);
|
|
375
|
+
}
|
|
376
|
+
.council-group[open] summary::before { transform: rotate(90deg); }
|
|
377
|
+
.council-group summary:hover { color: var(--accent); }
|
|
378
|
+
.council-group-count { color: var(--text-faint); font-weight: 400; }
|
|
379
|
+
.council-row {
|
|
380
|
+
display: flex; align-items: center; gap: 8px;
|
|
381
|
+
padding: 5px 10px 5px 24px; font-size: 12px; color: var(--text); cursor: pointer;
|
|
382
|
+
}
|
|
383
|
+
.council-row:hover { background: var(--surface-hover); }
|
|
384
|
+
.council-row input[type="checkbox"] { accent-color: var(--accent); flex-shrink: 0; }
|
|
385
|
+
.council-row-text { display: flex; flex-direction: column; min-width: 0; }
|
|
386
|
+
.council-row-name {
|
|
387
|
+
color: var(--text); font-size: 12px; overflow: hidden;
|
|
388
|
+
text-overflow: ellipsis; white-space: nowrap;
|
|
389
|
+
}
|
|
390
|
+
.council-row-id {
|
|
391
|
+
color: var(--text-muted); font-size: 11px; font-family: var(--font-mono);
|
|
392
|
+
margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
393
|
+
}
|
|
363
394
|
.council-note { font-size: 10px; color: var(--text-faint); margin-top: 6px; line-height: 1.4; }
|
|
364
395
|
|
|
365
396
|
/* Alias example-icon strokes — driven by class rules (var() is invalid as an SVG attribute) */
|
package/electron/setup-ui.js
CHANGED
|
@@ -9,6 +9,7 @@ const { buildCouncilSectionHTML, buildCouncilScript } = require('./setup-ui-coun
|
|
|
9
9
|
const { getDefaultAliases } = require('../src/utils/config');
|
|
10
10
|
const { getBrandName } = require('./toolbar');
|
|
11
11
|
const { resolveQuickPicks } = require('../src/utils/quick-picks');
|
|
12
|
+
const { PROVIDER_FAMILY_NAMES } = require('../src/utils/model-fetcher');
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* @param {object} [options={}]
|
|
@@ -30,6 +31,7 @@ function buildSetupHTML(options = {}) {
|
|
|
30
31
|
const modelChoicesJson = JSON.stringify(quickPicks);
|
|
31
32
|
const providerNamesJson = JSON.stringify(PROVIDER_NAMES);
|
|
32
33
|
const defaultAliasesJson = JSON.stringify(getDefaultAliases());
|
|
34
|
+
const familyNamesJson = JSON.stringify(PROVIDER_FAMILY_NAMES);
|
|
33
35
|
return `<!DOCTYPE html>
|
|
34
36
|
<html><head><meta charset="utf-8"><title>Amicus Setup</title>
|
|
35
37
|
<style>${css}</style></head><body>
|
|
@@ -51,11 +53,11 @@ function buildSetupHTML(options = {}) {
|
|
|
51
53
|
</div>
|
|
52
54
|
</div>
|
|
53
55
|
<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>
|
|
54
|
-
${buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson)}
|
|
56
|
+
${buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson)}
|
|
55
57
|
</body></html>`;
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson) {
|
|
60
|
+
function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, defaultAliasesJson, familyNamesJson) {
|
|
59
61
|
const keysJs = buildKeysScript();
|
|
60
62
|
const aliasJs = buildAliasScript();
|
|
61
63
|
const councilJs = buildCouncilScript();
|
|
@@ -69,6 +71,7 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
69
71
|
var modelChoicesData = ${modelChoicesJson};
|
|
70
72
|
var providerNamesData = ${providerNamesJson};
|
|
71
73
|
var defaultAliases = ${defaultAliasesJson};
|
|
74
|
+
var PROVIDER_FAMILY_NAMES = ${familyNamesJson};
|
|
72
75
|
var routingChoices = {};
|
|
73
76
|
var aliasEdits = {};
|
|
74
77
|
var aliasDisplay = {};
|
|
@@ -178,7 +181,10 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
178
181
|
if (step === 2) { updateRoutingPills(); ensureCatalogLoaded(); window.refreshCouncilGating && window.refreshCouncilGating(); }
|
|
179
182
|
if (step === 3) {
|
|
180
183
|
updateAliasRoutes();
|
|
181
|
-
|
|
184
|
+
// B33 / #12: Step 3 shares Step 2's TTL-cached catalog load (single
|
|
185
|
+
// in-page cache: ensureCatalogLoaded no-ops if Step 2 already loaded
|
|
186
|
+
// it) instead of a separate live sidecar:fetch-models round-trip.
|
|
187
|
+
ensureCatalogLoaded();
|
|
182
188
|
}
|
|
183
189
|
updateNextState();
|
|
184
190
|
}
|
|
@@ -393,15 +399,11 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
393
399
|
} catch (_e) { finishBtn.disabled = false; finishBtn.textContent = 'Finish'; }
|
|
394
400
|
});
|
|
395
401
|
|
|
396
|
-
|
|
397
|
-
try {
|
|
398
|
-
var groups = await window.sidecarSetup.invoke('sidecar:fetch-models');
|
|
399
|
-
if (groups && groups.length > 0) { window.availableModels = groups; }
|
|
400
|
-
} catch (_e) {}
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// ===== F5: searchable catalog picker (Step 2) =====
|
|
402
|
+
// ===== F5: searchable catalog picker (Step 2) + B33/#12: shared with Step 3 =====
|
|
404
403
|
var catalogRows = null, catalogFetchedAt = null;
|
|
404
|
+
// #13: last-refresh outcome, so a stale cache (refresh keeps failing) is
|
|
405
|
+
// shown honestly instead of looking current.
|
|
406
|
+
var catalogLastRefreshAttempt = null, catalogLastRefreshError = null;
|
|
405
407
|
window.customDefaultModel = null;
|
|
406
408
|
|
|
407
409
|
async function ensureCatalogLoaded() {
|
|
@@ -412,9 +414,32 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
412
414
|
} catch (_e) {}
|
|
413
415
|
}
|
|
414
416
|
|
|
417
|
+
// Re-derive Step 3's grouped {family, models} shape from the flat catalog
|
|
418
|
+
// rows client-side (mirrors src/utils/model-fetcher.js groupModelsByFamily
|
|
419
|
+
// keying: family name from the id prefix, falling back to the prefix
|
|
420
|
+
// itself for any provider not in PROVIDER_FAMILY_NAMES).
|
|
421
|
+
function groupCatalogByFamily(rows) {
|
|
422
|
+
if (!rows || rows.length === 0) { return []; }
|
|
423
|
+
var order = [], byFamily = {};
|
|
424
|
+
rows.forEach(function(m) {
|
|
425
|
+
var prefix = m.id.split('/')[0];
|
|
426
|
+
var family = PROVIDER_FAMILY_NAMES[prefix] || prefix;
|
|
427
|
+
if (!byFamily[family]) { byFamily[family] = []; order.push(family); }
|
|
428
|
+
byFamily[family].push(m);
|
|
429
|
+
});
|
|
430
|
+
return order.map(function(family) { return { family: family, models: byFamily[family] }; });
|
|
431
|
+
}
|
|
432
|
+
|
|
415
433
|
function applyCatalog(info) {
|
|
416
434
|
catalogRows = (info && info.models) || [];
|
|
417
435
|
catalogFetchedAt = info && info.fetchedAt;
|
|
436
|
+
catalogLastRefreshAttempt = info && info.lastRefreshAttempt;
|
|
437
|
+
catalogLastRefreshError = info && info.lastRefreshError;
|
|
438
|
+
// Single shared in-page cache: Step 3's alias dropdown (buildModelSelect)
|
|
439
|
+
// reads window.availableModels, re-derived from the same catalog load
|
|
440
|
+
// Step 2 uses — no second get-catalog round-trip, and the refresh
|
|
441
|
+
// button (Step 2) re-applying here keeps Step 3's dropdown data current.
|
|
442
|
+
window.availableModels = groupCatalogByFamily(catalogRows);
|
|
418
443
|
renderSearchMeta();
|
|
419
444
|
renderSearchResults();
|
|
420
445
|
if (catalogRows.length === 0) {
|
|
@@ -427,7 +452,14 @@ function buildWizardScript(providersJson, modelChoicesJson, providerNamesJson, d
|
|
|
427
452
|
var meta = $('model-search-meta');
|
|
428
453
|
if (!meta) { return; }
|
|
429
454
|
var when = catalogFetchedAt ? new Date(catalogFetchedAt).toLocaleString() : 'never';
|
|
430
|
-
|
|
455
|
+
var text = catalogRows.length + ' models \\u00b7 catalog fetched ' + when;
|
|
456
|
+
// #13: one-line stale hint when the last refresh attempt failed AFTER
|
|
457
|
+
// the data currently shown was fetched (don't redesign Step 2 for this).
|
|
458
|
+
if (catalogLastRefreshError && catalogLastRefreshAttempt &&
|
|
459
|
+
(!catalogFetchedAt || catalogLastRefreshAttempt > catalogFetchedAt)) {
|
|
460
|
+
text += ' \\u2014 \\u26a0 refresh failed, showing last-known data';
|
|
461
|
+
}
|
|
462
|
+
meta.textContent = text;
|
|
431
463
|
}
|
|
432
464
|
|
|
433
465
|
function fmtCtx(n) { return n == null ? '' : ' \\u00b7 ctx ' + n; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "2.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": [
|
|
@@ -28,9 +28,7 @@
|
|
|
28
28
|
"homepage": "https://bourbondog.github.io/amicus/",
|
|
29
29
|
"bin": {
|
|
30
30
|
"amicus": "./bin/amicus.js",
|
|
31
|
-
"am": "./bin/amicus.js"
|
|
32
|
-
"sidecar": "./bin/amicus.js",
|
|
33
|
-
"claude-sidecar": "./bin/amicus.js"
|
|
31
|
+
"am": "./bin/amicus.js"
|
|
34
32
|
},
|
|
35
33
|
"main": "src/index.js",
|
|
36
34
|
"exports": {
|
|
@@ -85,7 +83,6 @@
|
|
|
85
83
|
"devDependencies": {
|
|
86
84
|
"chrome-remote-interface": "^0.33.3",
|
|
87
85
|
"eslint": "^8.0.0",
|
|
88
|
-
"husky": "^9.1.7",
|
|
89
86
|
"jest": "^29.0.0",
|
|
90
87
|
"lint-staged": "^16.3.2",
|
|
91
88
|
"puppeteer": "^24.36.0",
|
|
@@ -25,8 +25,8 @@ injection, long-read failures, judge tool-wander; see changelog)._
|
|
|
25
25
|
- **Transient provider errors** (502s, connection drops): re-run the affected leg (solo
|
|
26
26
|
`amicus start --json`, same briefing file) or the wave — see per-model notes for
|
|
27
27
|
model-specific signals. Never present a half-finished run as an answer.
|
|
28
|
-
- **Credentials:** keys live in `~/.config/amicus/.env
|
|
29
|
-
|
|
28
|
+
- **Credentials:** keys live in `~/.config/amicus/.env`. The legacy `~/.config/sidecar/.env`
|
|
29
|
+
fallback was removed in v2.0.0 (see `docs/SHIMS.md`). Configure with `amicus setup`.
|
|
30
30
|
- **PowerShell `--models` quoting (Windows):** always quote comma-separated model lists —
|
|
31
31
|
`--models "gemini,gpt,deepseek"`. Unquoted, PowerShell splits on commas and amicus receives one
|
|
32
32
|
mangled alias → instant arg-parse failure. (Now baked into every SKILL.md example.)
|