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.
Files changed (75) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +200 -0
  3. package/README.md +40 -170
  4. package/bin/amicus.js +19 -107
  5. package/commands/council.md +7 -3
  6. package/electron/fold.js +10 -1
  7. package/electron/ipc-setup.js +10 -15
  8. package/electron/main.js +21 -16
  9. package/electron/preload-setup.js +0 -1
  10. package/electron/setup-ui-council.js +64 -10
  11. package/electron/setup-ui-styles.js +34 -3
  12. package/electron/setup-ui.js +44 -12
  13. package/package.json +2 -5
  14. package/skills/second-opinion/MODEL-NOTES.md +2 -2
  15. package/skills/second-opinion/SKILL.md +30 -28
  16. package/skills/sidecar/SKILL.md +20 -17
  17. package/src/cli-handlers-abort.js +244 -0
  18. package/src/cli-handlers-council.js +101 -1
  19. package/src/cli-handlers-doctor.js +20 -53
  20. package/src/cli-handlers-resume-continue.js +103 -0
  21. package/src/cli-handlers-run.js +9 -8
  22. package/src/cli-handlers-spend.js +198 -0
  23. package/src/cli-handlers.js +5 -120
  24. package/src/cli.js +55 -0
  25. package/src/council/presets-cli.js +141 -0
  26. package/src/headless.js +146 -38
  27. package/src/index.js +1 -9
  28. package/src/mcp-server.js +140 -113
  29. package/src/mcp-tools.js +58 -24
  30. package/src/mcp-wait.js +8 -5
  31. package/src/opencode-client.js +33 -10
  32. package/src/prompt-builder.js +32 -11
  33. package/src/session-manager.js +7 -14
  34. package/src/sidecar/continue.js +34 -12
  35. package/src/sidecar/conversation-mirror.js +22 -1
  36. package/src/sidecar/crash-handler.js +2 -1
  37. package/src/sidecar/fanout-leg.js +12 -3
  38. package/src/sidecar/fanout.js +27 -10
  39. package/src/sidecar/interactive-process.js +6 -17
  40. package/src/sidecar/interactive.js +5 -6
  41. package/src/sidecar/models.js +33 -4
  42. package/src/sidecar/progress.js +2 -1
  43. package/src/sidecar/read.js +4 -6
  44. package/src/sidecar/resume.js +41 -11
  45. package/src/sidecar/session-finalize.js +2 -1
  46. package/src/sidecar/session-utils.js +13 -35
  47. package/src/sidecar/setup-window.js +2 -3
  48. package/src/sidecar/start.js +22 -7
  49. package/src/utils/abort-coordinator.js +57 -7
  50. package/src/utils/abort-result.js +36 -0
  51. package/src/utils/api-key-store.js +2 -13
  52. package/src/utils/cli-preflight.js +43 -0
  53. package/src/utils/config.js +30 -43
  54. package/src/utils/council-presets.js +87 -0
  55. package/src/utils/doctor-mcp-checks.js +84 -0
  56. package/src/utils/env-loader.js +1 -2
  57. package/src/utils/fold-marker.js +79 -0
  58. package/src/utils/idle-watchdog.js +9 -12
  59. package/src/utils/input-validators.js +52 -1
  60. package/src/utils/lifecycle.js +1 -1
  61. package/src/utils/mcp-discovery.js +80 -19
  62. package/src/utils/mcp-self-identity.js +12 -5
  63. package/src/utils/model-catalog.js +54 -6
  64. package/src/utils/read-slice.js +73 -0
  65. package/src/utils/remediation-hints.js +9 -0
  66. package/src/utils/result-schema-version.js +14 -0
  67. package/src/utils/result-schema.js +18 -12
  68. package/src/utils/session-abort.js +1 -1
  69. package/src/utils/session-index-tmp-sweep.js +80 -0
  70. package/src/utils/session-index.js +4 -5
  71. package/src/utils/session-path.js +6 -10
  72. package/src/utils/shared-server.js +7 -5
  73. package/src/utils/spend-ledger.js +80 -0
  74. package/src/utils/updater.js +2 -3
  75. 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
- // One-time, non-destructive migration of the legacy ~/.config/sidecar dir onto
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 { listSidecars } = require('../src/index');
165
+ const { listAmicus } = require('../src/index');
166
166
 
167
- await listSidecars({
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}`);
@@ -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 intake/prep and run-folder
21
- setup, council selection with a cost estimate and explicit user confirmation, the
22
- three review waves, `amicus council tally`, and the accept/deny decision pass.
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
- '[SIDECAR_FOLD]',
64
+ buildFoldMarker(nonce),
56
65
  `Model: ${state.model}`,
57
66
  `Session: ${state.sessionId || state.taskId}`,
58
67
  `Client: ${state.client}`,
@@ -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
- * fetch-models, get-catalog, and refresh-catalog.
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 => ({ id: r.id, suggested: suggested.has(r.id) }));
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 = getCompatEnv('MODE') || 'sidecar';
62
- const TASK_ID = getCompatEnv('TASK_ID') || 'unknown';
63
- const MODEL = getCompatEnv('MODEL') || 'unknown';
64
- const CWD = getCompatEnv('CWD') || process.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 = getCompatEnv('SESSION_DIRECTORY') || CWD;
70
- const CLIENT = getCompatEnv('CLIENT') || 'code-local';
71
- const OPENCODE_PORT = parseInt(getCompatEnv('OPENCODE_PORT') || '4096', 10);
72
- const OPENCODE_SESSION_ID = getCompatEnv('SESSION_ID');
73
- const FOLD_SHORTCUT = getCompatEnv('FOLD_SHORTCUT') || 'CommandOrControl+Shift+F';
74
- const WINDOW_POSITION = getCompatEnv('WINDOW_POSITION') || 'right';
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 = getCompatEnv('UPDATE_INFO');
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(getCompatEnv('GUI_LOAD_TIMEOUT_MS') || '', 10) || undefined,
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.SIDECAR_HEADLESS_TEST) {
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.SIDECAR_HEADLESS_TEST) {
242
+ if (!process.env.AMICUS_HEADLESS_TEST) {
238
243
  mainWindow.show();
239
244
  }
240
245
  });
@@ -20,7 +20,6 @@ contextBridge.exposeInMainWorld('sidecarSetup', {
20
20
  'sidecar:save-config',
21
21
  'sidecar:get-config',
22
22
  'sidecar:get-api-keys',
23
- 'sidecar:fetch-models',
24
23
  'sidecar:get-catalog',
25
24
  'sidecar:refresh-catalog',
26
25
  'sidecar:fetch-free-models'
@@ -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
- (rows || []).forEach(function(r, i) {
48
- var id = 'fc-' + i;
49
- var row = document.createElement('label');
50
- row.className = 'council-row';
51
- var cb = document.createElement('input');
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) { meta.textContent = (rows || []).length + ' free models'; }
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 { max-height: 160px; overflow-y: auto; margin-top: 6px; }
361
- .council-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 12px; color: var(--text); font-family: var(--font-mono); }
362
- .council-row input[type="checkbox"] { accent-color: var(--accent); }
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) */
@@ -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
- if (!window.availableModels) { fetchAvailableModels(); }
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
- async function fetchAvailableModels() {
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
- meta.textContent = catalogRows.length + ' models \\u00b7 catalog fetched ' + when;
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.9.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` (legacy `~/.config/sidecar/.env` still
29
- read). Configure with `amicus setup`.
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.)