amicus 1.0.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 (93) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +477 -0
  4. package/bin/amicus.js +382 -0
  5. package/electron/assets/icon.png +0 -0
  6. package/electron/assets/icon.svg +5 -0
  7. package/electron/fold.js +163 -0
  8. package/electron/ipc-setup.js +176 -0
  9. package/electron/load-failsafe.js +85 -0
  10. package/electron/main.js +468 -0
  11. package/electron/preload-setup.js +38 -0
  12. package/electron/preload.js +33 -0
  13. package/electron/setup-ui-alias-script.js +218 -0
  14. package/electron/setup-ui-aliases.js +85 -0
  15. package/electron/setup-ui-keys-script.js +115 -0
  16. package/electron/setup-ui-keys.js +97 -0
  17. package/electron/setup-ui-model.js +138 -0
  18. package/electron/setup-ui-styles.js +327 -0
  19. package/electron/setup-ui.js +465 -0
  20. package/electron/summary.js +118 -0
  21. package/electron/toolbar.js +229 -0
  22. package/electron/window-position.js +35 -0
  23. package/package.json +98 -0
  24. package/scripts/postinstall.js +193 -0
  25. package/scripts/setup-hooks.js +42 -0
  26. package/skill/SKILL.md +976 -0
  27. package/skills/second-opinion/COUNCIL-DESIGN.md +227 -0
  28. package/skills/second-opinion/MODEL-NOTES.md +104 -0
  29. package/skills/second-opinion/SKILL.md +389 -0
  30. package/src/cli-handlers.js +188 -0
  31. package/src/cli.js +400 -0
  32. package/src/conflict.js +144 -0
  33. package/src/context-compression.js +102 -0
  34. package/src/context.js +199 -0
  35. package/src/drift.js +144 -0
  36. package/src/environment.js +157 -0
  37. package/src/headless.js +742 -0
  38. package/src/index.js +106 -0
  39. package/src/jsonl-parser.js +180 -0
  40. package/src/mcp-server.js +625 -0
  41. package/src/mcp-tools.js +407 -0
  42. package/src/opencode-client.js +615 -0
  43. package/src/prompt-builder.js +355 -0
  44. package/src/prompts/cowork-agent-prompt.js +118 -0
  45. package/src/session-manager.js +414 -0
  46. package/src/session.js +180 -0
  47. package/src/sidecar/context-builder.js +297 -0
  48. package/src/sidecar/continue.js +212 -0
  49. package/src/sidecar/crash-handler.js +56 -0
  50. package/src/sidecar/fanout-leg.js +107 -0
  51. package/src/sidecar/fanout-output.js +46 -0
  52. package/src/sidecar/fanout.js +236 -0
  53. package/src/sidecar/interactive.js +217 -0
  54. package/src/sidecar/models.js +135 -0
  55. package/src/sidecar/progress.js +218 -0
  56. package/src/sidecar/read.js +183 -0
  57. package/src/sidecar/resume.js +221 -0
  58. package/src/sidecar/session-utils.js +288 -0
  59. package/src/sidecar/setup-window.js +79 -0
  60. package/src/sidecar/setup.js +280 -0
  61. package/src/sidecar/start.js +251 -0
  62. package/src/utils/agent-mapping.js +138 -0
  63. package/src/utils/alias-audit.js +98 -0
  64. package/src/utils/alias-resolver.js +77 -0
  65. package/src/utils/api-key-store.js +259 -0
  66. package/src/utils/api-key-validation.js +97 -0
  67. package/src/utils/auth-json.js +109 -0
  68. package/src/utils/config.js +291 -0
  69. package/src/utils/curated-models.js +82 -0
  70. package/src/utils/env-compat.js +38 -0
  71. package/src/utils/env-loader.js +54 -0
  72. package/src/utils/idle-watchdog.js +225 -0
  73. package/src/utils/input-validators.js +127 -0
  74. package/src/utils/lifecycle.js +43 -0
  75. package/src/utils/logger.js +84 -0
  76. package/src/utils/mcp-discovery.js +194 -0
  77. package/src/utils/mcp-validators.js +78 -0
  78. package/src/utils/model-catalog.js +103 -0
  79. package/src/utils/model-fetcher.js +179 -0
  80. package/src/utils/model-validator.js +207 -0
  81. package/src/utils/path-setup.js +41 -0
  82. package/src/utils/port-pid.js +39 -0
  83. package/src/utils/prompt-source.js +53 -0
  84. package/src/utils/result-schema.js +261 -0
  85. package/src/utils/server-setup.js +93 -0
  86. package/src/utils/session-abort.js +53 -0
  87. package/src/utils/session-lock.js +95 -0
  88. package/src/utils/shared-server.js +216 -0
  89. package/src/utils/start-helpers.js +76 -0
  90. package/src/utils/thinking-validators.js +92 -0
  91. package/src/utils/update-notifier-loader.js +18 -0
  92. package/src/utils/updater.js +157 -0
  93. package/src/utils/validators.js +300 -0
@@ -0,0 +1,46 @@
1
+ // src/sidecar/fanout-output.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module fanout-output
6
+ * Human-readable rendering of a wave document (the non-JSON default for
7
+ * `amicus fanout` stdout and `amicus read <waveId>`).
8
+ */
9
+
10
+ /** Format ms as "1m5s" / "42s". */
11
+ function fmtDuration(ms) {
12
+ if (ms === null || ms === undefined) { return '-'; }
13
+ const s = Math.round(ms / 1000);
14
+ const m = Math.floor(s / 60);
15
+ return m > 0 ? `${m}m${s % 60}s` : `${s}s`;
16
+ }
17
+
18
+ /**
19
+ * Render a wave document for humans: per-leg sections in order, then a footer.
20
+ * @param {object} wave - Wave document (result-schema shape)
21
+ * @returns {string}
22
+ */
23
+ function formatWaveHuman(wave) {
24
+ const lines = [];
25
+ for (const leg of wave.legs) {
26
+ const label = leg.modelInput || leg.model || leg.taskId;
27
+ lines.push(`${'─'.repeat(8)} ${label} (${leg.taskId}) ${'─'.repeat(8)}`);
28
+ if (leg.summary && leg.summary.trim()) {
29
+ lines.push(leg.summary.trim());
30
+ } else {
31
+ lines.push(`(no output) [${leg.status}${leg.error ? `: ${leg.error}` : ''}]`);
32
+ }
33
+ lines.push('');
34
+ }
35
+ if (wave.error) { lines.push(`Error: ${wave.error}`); }
36
+ lines.push('─'.repeat(40));
37
+ const counts = wave.counts || { complete: '?', total: '?' };
38
+ lines.push(`Wave ${wave.waveId}: ${wave.status} — ${counts.complete}/${counts.total} complete in ${fmtDuration(wave.durationMs)}`);
39
+ for (const leg of wave.legs) {
40
+ const label = leg.modelInput || leg.model || leg.taskId;
41
+ lines.push(` ${leg.taskId} ${String(label).padEnd(12)} ${String(leg.status).padEnd(9)} ${fmtDuration(leg.durationMs)}`);
42
+ }
43
+ return lines.join('\n');
44
+ }
45
+
46
+ module.exports = { formatWaveHuman, fmtDuration };
@@ -0,0 +1,236 @@
1
+ // src/sidecar/fanout.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module fanout
6
+ * F4 council-native fan-out: run N models on the same prompt concurrently on
7
+ * ONE shared OpenCode server (runHeadless external-server mode). Each leg is
8
+ * an ordinary session (parentWave metadata); results aggregate into a wave
9
+ * document persisted as wave.json in the wave session dir.
10
+ * Spec: docs/superpowers/specs/2026-06-09-f4-fanout-json-design.md
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { logger } = require('../utils/logger');
16
+ const { runLeg } = require('./fanout-leg');
17
+
18
+ /** Default max legs per wave (env-overridable). */
19
+ const DEFAULT_MAX_LEGS = 10;
20
+
21
+ /**
22
+ * Split a --models value into trimmed, non-empty entries (duplicates allowed).
23
+ * @param {string|boolean|undefined} modelsArg
24
+ * @returns {string[]}
25
+ */
26
+ function parseModelsList(modelsArg) {
27
+ if (typeof modelsArg !== 'string') { return []; }
28
+ return modelsArg.split(',').map(s => s.trim()).filter(Boolean);
29
+ }
30
+
31
+ /**
32
+ * Derive leg task IDs: <waveId>-1 .. <waveId>-N (matches TASK_ID_PATTERN).
33
+ * @param {string} waveId
34
+ * @param {number} count
35
+ * @returns {string[]}
36
+ */
37
+ function deriveLegIds(waveId, count) {
38
+ return Array.from({ length: count }, (_, i) => `${waveId}-${i + 1}`);
39
+ }
40
+
41
+ /**
42
+ * Fail-fast validation of the whole model list BEFORE any leg launches:
43
+ * alias resolution, API-key presence, live-catalog validation (F3 machinery).
44
+ * @param {string} modelsArg - Raw --models value
45
+ * @param {{noValidateModel?: boolean}} [opts]
46
+ * @returns {Promise<{legs: Array<{modelInput: string, model: string}>} | {error: string}>}
47
+ */
48
+ async function validateFanoutModels(modelsArg, opts = {}) {
49
+ const raw = parseModelsList(modelsArg);
50
+ if (raw.length === 0) {
51
+ return { error: 'Error: --models requires a comma-separated list (e.g. gemini,gpt,deepseek)' };
52
+ }
53
+ // Invalid or non-positive AMICUS_FANOUT_MAX_LEGS (0, negative, garbage) falls back to the default.
54
+ const envCap = Number(process.env.AMICUS_FANOUT_MAX_LEGS);
55
+ const maxLegs = (Number.isInteger(envCap) && envCap > 0) ? envCap : DEFAULT_MAX_LEGS;
56
+ if (raw.length > maxLegs) {
57
+ return { error: `Error: --models exceeds the fan-out cap of ${maxLegs} legs (set AMICUS_FANOUT_MAX_LEGS to raise)` };
58
+ }
59
+
60
+ const { tryResolveModel } = require('../utils/config');
61
+ const { validateApiKey } = require('../utils/validators');
62
+ const { validateAgainstCatalog } = require('../utils/model-validator');
63
+ const legs = [];
64
+ for (const modelInput of raw) {
65
+ const resolved = tryResolveModel(modelInput);
66
+ if (resolved.error) {
67
+ return { error: `Error: model '${modelInput}': ${resolved.error}` };
68
+ }
69
+ let model = resolved.model;
70
+ const keyCheck = validateApiKey(model);
71
+ if (!keyCheck.valid) {
72
+ return { error: keyCheck.error };
73
+ }
74
+ if (!opts.noValidateModel) {
75
+ const alias = modelInput.includes('/') ? undefined : modelInput;
76
+ try {
77
+ model = await validateAgainstCatalog(model, alias);
78
+ } catch (err) {
79
+ return { error: err.message };
80
+ }
81
+ }
82
+ legs.push({ modelInput, model });
83
+ }
84
+ return { legs };
85
+ }
86
+
87
+ /** Write/merge wave metadata (preserves fields an MCP pre-spawn handler wrote). */
88
+ function writeWaveMetadata(waveDir, patch) {
89
+ const metaPath = path.join(waveDir, 'metadata.json');
90
+ let existing = {};
91
+ if (fs.existsSync(metaPath)) {
92
+ try { existing = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); } catch { /* corrupt → rewrite */ }
93
+ }
94
+ const merged = { ...existing, ...patch };
95
+ fs.writeFileSync(metaPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
96
+ return merged;
97
+ }
98
+
99
+ /**
100
+ * Run a fan-out wave. Spec §4.3.
101
+ * @param {object} options - models, prompt, promptMeta, waveId?, project, agent?,
102
+ * thinking?, timeout? (minutes), summaryLength?, includeContext?, sessionId?,
103
+ * contextTurns?, contextSince?, contextMaxTokens?, mcp?, mcpConfig?, noMcp?,
104
+ * excludeMcp?, noValidateModel?, json?, client?, quiet? (suppress stdout — tests)
105
+ * @returns {Promise<{wave: object, exitCode: number}>} Never rejects for leg errors.
106
+ */
107
+ async function runFanout(options) {
108
+ const { buildWaveResult, waveExitCode } = require('../utils/result-schema');
109
+ const { generateTaskId, buildMcpConfig } = require('./start');
110
+ const { startOpenCodeServer, createHeartbeat, HEARTBEAT_INTERVAL } = require('./session-utils');
111
+ const { buildContext } = require('./context-builder');
112
+ const { buildPrompts } = require('../prompt-builder');
113
+ const { installSignalAbort, markAborted } = require('../utils/session-abort');
114
+ const { getSessionDir } = require('../session-manager');
115
+
116
+ const project = options.project || process.cwd();
117
+ const createdAt = new Date().toISOString();
118
+ const emit = (doc) => {
119
+ if (options.quiet) { return; }
120
+ if (options.json) {
121
+ console.log(JSON.stringify(doc, null, 2));
122
+ } else {
123
+ const { formatWaveHuman } = require('./fanout-output');
124
+ console.log(formatWaveHuman(doc));
125
+ }
126
+ };
127
+ const errorWave = (waveId, message) => {
128
+ const doc = buildWaveResult({ waveId: waveId || null, legs: [], promptMeta: options.promptMeta || null, createdAt, completedAt: new Date().toISOString(), status: 'error' });
129
+ doc.error = message;
130
+ emit(doc);
131
+ return { wave: doc, exitCode: 1 };
132
+ };
133
+
134
+ // 1. Fail-fast validation
135
+ const validated = await validateFanoutModels(options.models, { noValidateModel: options.noValidateModel });
136
+ if (validated.error) { return errorWave(options.waveId, validated.error); }
137
+ const legs = validated.legs;
138
+
139
+ // 2. Wave record
140
+ const waveId = options.waveId || generateTaskId();
141
+ const legIds = deriveLegIds(waveId, legs.length);
142
+ const waveDir = getSessionDir(project, waveId);
143
+ fs.mkdirSync(waveDir, { recursive: true, mode: 0o700 });
144
+ fs.writeFileSync(path.join(waveDir, 'briefing.md'), options.prompt, { mode: 0o600 });
145
+ writeWaveMetadata(waveDir, {
146
+ taskId: waveId, type: 'wave', status: 'running', mode: 'headless',
147
+ models: legs.map(l => l.model), legs: legIds,
148
+ briefing: String(options.prompt).slice(0, 200),
149
+ promptMeta: options.promptMeta || null,
150
+ pid: process.pid, project, createdAt,
151
+ });
152
+
153
+ // 3. Context + prompts built ONCE (model-independent)
154
+ const context = options.includeContext !== false
155
+ ? buildContext(project, options.sessionId || 'current', {
156
+ contextTurns: options.contextTurns, contextSince: options.contextSince,
157
+ contextMaxTokens: options.contextMaxTokens, client: options.client,
158
+ })
159
+ : '[Context excluded by caller - briefing is self-contained]';
160
+ const { system: systemPrompt, userMessage } = buildPrompts(
161
+ options.prompt, context, project, true, options.agent || 'build', options.summaryLength, options.client
162
+ );
163
+
164
+ // 4. One shared OpenCode server
165
+ const mcpServers = buildMcpConfig({
166
+ mcp: options.mcp, mcpConfig: options.mcpConfig, clientType: options.client,
167
+ noMcp: options.noMcp, excludeMcp: options.excludeMcp,
168
+ });
169
+ let client, server;
170
+ try {
171
+ ({ client, server } = await startOpenCodeServer(mcpServers));
172
+ } catch (err) {
173
+ writeWaveMetadata(waveDir, { status: 'error', reason: err.message, completedAt: new Date().toISOString() });
174
+ return errorWave(waveId, `Failed to start server: ${err.message}`);
175
+ }
176
+ if (server.goPid) { writeWaveMetadata(waveDir, { goPid: server.goPid }); }
177
+
178
+ // 5. Signal abort: mark wave + all legs aborted, close the server, then let
179
+ // NORMAL control flow finalize — legs see their abort marker within one poll
180
+ // (~2s) and settle, so step 7 still writes wave.json and emits a parseable
181
+ // aborted document. An unref'd force-exit watchdog backstops a wedged leg.
182
+ const legDirs = legIds.map(id => getSessionDir(project, id));
183
+ let signalled = null;
184
+ const uninstallSignals = installSignalAbort({
185
+ onAbort: (signal) => {
186
+ const code = signal === 'SIGINT' ? 130 : 143;
187
+ if (signalled) { process.exit(code); } // second signal: exit NOW
188
+ signalled = signal;
189
+ logger.warn('Signal received — aborting wave', { waveId, signal });
190
+ markAborted(waveDir, signal);
191
+ for (const dir of legDirs) { markAborted(dir, signal); }
192
+ try { server.close(); } catch { /* best-effort */ }
193
+ const { armExitWatchdog } = require('../utils/lifecycle');
194
+ armExitWatchdog(code, 10000, { log: (m, meta) => logger.debug(m, meta) });
195
+ },
196
+ });
197
+
198
+ // 6. Launch all legs concurrently (runLeg never rejects)
199
+ const heartbeat = options.quiet ? { stop() {} } : createHeartbeat(HEARTBEAT_INTERVAL);
200
+ const timeoutMs = (options.timeout || 15) * 60 * 1000;
201
+ const reasoning = options.thinking ? { effort: options.thinking } : undefined;
202
+ let legDocs;
203
+ try {
204
+ legDocs = await Promise.all(legs.map((leg, i) => runLeg({
205
+ leg, legId: legIds[i], waveId, project, systemPrompt, userMessage,
206
+ timeoutMs, agent: options.agent, client, server,
207
+ summaryLength: options.summaryLength, reasoning, quiet: options.quiet,
208
+ })));
209
+ } finally {
210
+ heartbeat.stop();
211
+ uninstallSignals();
212
+ try { server.close(); } catch { /* already closed on signal */ }
213
+ }
214
+
215
+ // 7. Aggregate, persist (atomic: tmp + rename), finalize, emit
216
+ const completedAt = new Date().toISOString();
217
+ const wave = buildWaveResult({
218
+ waveId, legs: legDocs, promptMeta: options.promptMeta || null, createdAt, completedAt,
219
+ status: signalled ? 'aborted' : null,
220
+ });
221
+ const wavePath = path.join(waveDir, 'wave.json');
222
+ const waveTmp = `${wavePath}.tmp`;
223
+ fs.writeFileSync(waveTmp, JSON.stringify(wave, null, 2), { mode: 0o600 });
224
+ fs.renameSync(waveTmp, wavePath);
225
+ writeWaveMetadata(waveDir, { status: wave.status, completedAt });
226
+ emit(wave);
227
+ const exitCode = signalled
228
+ ? (signalled === 'SIGINT' ? 130 : 143)
229
+ : waveExitCode(wave.status);
230
+ return { wave, exitCode };
231
+ }
232
+
233
+ module.exports = {
234
+ parseModelsList, deriveLegIds, validateFanoutModels, DEFAULT_MAX_LEGS,
235
+ runFanout, runLeg, writeWaveMetadata,
236
+ };
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Sidecar Interactive Mode - Electron GUI session management
3
+ * Extracted from start.js for file size compliance (< 300 lines).
4
+ */
5
+
6
+ const path = require('path');
7
+ const { spawn } = require('child_process');
8
+
9
+ const { startOpenCodeServer } = require('./session-utils');
10
+ const { createSession, sendPromptAsync } = require('../opencode-client');
11
+ const { mapAgentToOpenCode } = require('../utils/agent-mapping');
12
+ const { logger } = require('../utils/logger');
13
+ const { getCompatEnv } = require('../utils/env-compat');
14
+
15
+ /** Get the Electron binary path via require('electron').
16
+ * Works in all install contexts (global, local, npx hoisted).
17
+ * @returns {string|null} Full path to Electron binary, or null if not installed */
18
+ function getElectronPath() {
19
+ try {
20
+ return require('electron');
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ /** Check if Electron is available (lazy loading guard) */
27
+ function checkElectronAvailable() {
28
+ return getElectronPath() !== null;
29
+ }
30
+
31
+ /** Build environment variables for Electron process */
32
+ function buildElectronEnv(taskId, model, project, nodeModulesBin, existingPath, options = {}) {
33
+ const { agent, isResume, conversation, mcp, client, windowPosition } = options;
34
+ const env = {
35
+ ...process.env,
36
+ PATH: `${nodeModulesBin}${path.delimiter}${existingPath}`,
37
+ AMICUS_TASK_ID: taskId,
38
+ AMICUS_MODEL: model,
39
+ SIDECAR_PROJECT: project
40
+ };
41
+
42
+ if (client) { env.AMICUS_CLIENT = client; }
43
+ if (windowPosition) { env.AMICUS_WINDOW_POSITION = windowPosition; }
44
+
45
+ if (agent) {
46
+ const agentConfig = mapAgentToOpenCode(agent);
47
+ env.SIDECAR_AGENT = agentConfig.agent;
48
+ if (agentConfig.permissions) { env.SIDECAR_PERMISSIONS = agentConfig.permissions; }
49
+ }
50
+
51
+ if (isResume) {
52
+ env.SIDECAR_RESUME = 'true';
53
+ if (conversation) { env.SIDECAR_CONVERSATION = conversation; }
54
+ }
55
+
56
+ if (mcp) { env.SIDECAR_MCP_CONFIG = JSON.stringify(mcp); }
57
+
58
+ return env;
59
+ }
60
+
61
+ /** Handle Electron process stdout/stderr and exit */
62
+ function handleElectronProcess(electronProcess, taskId, resolve) {
63
+ let stdout = '';
64
+
65
+ electronProcess.stdout.on('data', (data) => { stdout += data.toString(); });
66
+
67
+ electronProcess.stderr.on('data', (data) => {
68
+ data.toString().trim().split('\n').filter(l => l.trim())
69
+ .forEach(line => logger.debug('Electron', { output: line.trim() }));
70
+ });
71
+
72
+ electronProcess.on('error', (error) => {
73
+ logger.error('Electron process error', { error: error.message });
74
+ resolve({
75
+ summary: '', completed: false, timedOut: false, taskId,
76
+ error: `Failed to start Electron: ${error.message}`
77
+ });
78
+ });
79
+
80
+ electronProcess.on('close', (code) => {
81
+ logger.debug('Electron closed', { code, stdoutLength: stdout.length });
82
+ resolve({
83
+ summary: stdout.trim() || 'Session ended without summary.',
84
+ completed: code === 0, timedOut: false, taskId, exitCode: code
85
+ });
86
+ });
87
+ }
88
+
89
+ /** Run sidecar in interactive mode (Electron GUI) */
90
+ async function runInteractive(model, systemPrompt, userMessage, taskId, project, options = {}) {
91
+ if (!checkElectronAvailable()) {
92
+ logger.error('Electron not installed — interactive mode unavailable');
93
+ return {
94
+ summary: '', completed: false, timedOut: false, taskId,
95
+ error: 'Interactive mode requires electron. Install with: npm install -g amicus (or use --no-ui for headless mode)'
96
+ };
97
+ }
98
+
99
+ const { agent, isResume, conversation, mcp, reasoning, opencodeSessionId, client } = options;
100
+
101
+ // Start OpenCode server with system prompt baked into agent config.
102
+ // Agent config prompts are hidden from the UI, unlike promptAsync's system field.
103
+ const agentConfig = mapAgentToOpenCode(agent);
104
+ let ocClient, server;
105
+ try {
106
+ const result = await startOpenCodeServer(mcp, {
107
+ client, systemPrompt, agentName: agentConfig.agent
108
+ });
109
+ ocClient = result.client;
110
+ server = result.server;
111
+ } catch (error) {
112
+ logger.error('Failed to start OpenCode server', { error: error.message });
113
+ return {
114
+ summary: '', completed: false, timedOut: false, taskId,
115
+ error: `Failed to start server: ${error.message}`
116
+ };
117
+ }
118
+
119
+ // Create or reconnect to session
120
+ let sessionId;
121
+ try {
122
+ if (isResume && opencodeSessionId) {
123
+ // Resume: reconnect to existing OpenCode session
124
+ sessionId = opencodeSessionId;
125
+ logger.info('Reconnecting to existing session', { sessionId });
126
+ } else {
127
+ // New session: create and send initial prompt
128
+ sessionId = await createSession(ocClient);
129
+
130
+ // System prompt is set on agent config (hidden from UI).
131
+ // Do NOT pass system here — promptAsync's system field is visible in the UI.
132
+ const promptOptions = {
133
+ model,
134
+ parts: [{ type: 'text', text: userMessage }]
135
+ };
136
+
137
+ // Always set agent — defaults to 'chat' when not specified
138
+ promptOptions.agent = agentConfig.agent;
139
+ if (reasoning) { promptOptions.reasoning = reasoning; }
140
+
141
+ await sendPromptAsync(ocClient, sessionId, promptOptions);
142
+ }
143
+ logger.debug('Interactive session ready', { sessionId, isResume: !!isResume });
144
+ } catch (error) {
145
+ server.close();
146
+ return {
147
+ summary: '', completed: false, timedOut: false, taskId,
148
+ error: `Session setup failed: ${error.message}`
149
+ };
150
+ }
151
+
152
+ const serverPort = new URL(server.url).port;
153
+
154
+ // Start idle watchdog for interactive mode (60-min default timeout)
155
+ const { IdleWatchdog } = require('../utils/idle-watchdog');
156
+ const watchdog = new IdleWatchdog({
157
+ mode: 'interactive',
158
+ onTimeout: () => {
159
+ logger.info('Interactive idle timeout - shutting down', { taskId });
160
+ },
161
+ }).start();
162
+
163
+ return new Promise((resolve, _reject) => {
164
+ const electronPath = getElectronPath();
165
+ const mainPath = path.join(__dirname, '..', '..', 'electron', 'main.js');
166
+
167
+ const nodeModulesBin = path.join(__dirname, '..', '..', 'node_modules', '.bin');
168
+ const existingPath = process.env.PATH || '';
169
+ const env = buildElectronEnv(
170
+ taskId, model, project, nodeModulesBin, existingPath,
171
+ { agent, isResume, conversation, mcp, client }
172
+ );
173
+
174
+ // Pass OpenCode server info to Electron
175
+ env.AMICUS_OPENCODE_PORT = serverPort;
176
+ env.AMICUS_SESSION_ID = sessionId;
177
+
178
+ const debugPort = getCompatEnv('DEBUG_PORT') || '9222';
179
+ logger.debug('Launching Electron', { taskId, model, debugPort, serverPort, sessionId });
180
+
181
+ const electronProcess = spawn(electronPath, [
182
+ `--remote-debugging-port=${debugPort}`,
183
+ mainPath
184
+ ], { cwd: project, env, stdio: ['ignore', 'pipe', 'pipe'] });
185
+
186
+ // Touch watchdog on Electron stdout activity
187
+ electronProcess.stdout.on('data', () => {
188
+ watchdog.touch();
189
+ });
190
+
191
+ // Update watchdog onTimeout now that electronProcess is available
192
+ watchdog.onTimeout = () => {
193
+ logger.info('Interactive idle timeout - shutting down', { taskId });
194
+ if (!electronProcess.killed) {
195
+ electronProcess.kill('SIGTERM');
196
+ }
197
+ };
198
+
199
+ // Clean up server when Electron exits
200
+ const originalResolve = resolve;
201
+ handleElectronProcess(electronProcess, taskId, (result) => {
202
+ watchdog.cancel();
203
+ server.close();
204
+ logger.debug('OpenCode server closed after Electron exit');
205
+ result.opencodeSessionId = sessionId;
206
+ originalResolve(result);
207
+ });
208
+ });
209
+ }
210
+
211
+ module.exports = {
212
+ getElectronPath,
213
+ checkElectronAvailable,
214
+ buildElectronEnv,
215
+ handleElectronProcess,
216
+ runInteractive
217
+ };
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `amicus models` (F5) — list/search the catalog, refresh it, audit aliases.
3
+ *
4
+ * amicus models list (curated aliases marked)
5
+ * amicus models --search <q> substring filter over id+name
6
+ * amicus models --refresh force-refresh the cache
7
+ * amicus models --check stale-alias audit (exit = stale count, max 100)
8
+ * --json on all of the above versioned documents (result-schema)
9
+ *
10
+ * Returns an exit code; bin/amicus.js plumbs it like fanout's.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const { getCatalogInfo, refreshCatalog, catalogPath } = require('../utils/model-catalog');
16
+ const { collectAliasSources, findStaleAliases, suggestReplacements } = require('../utils/alias-audit');
17
+ const { buildCatalogDoc, buildAuditDoc } = require('../utils/result-schema');
18
+
19
+ const CHECK_EXIT_CAP = 100;
20
+
21
+ /** '0.000003' per token → '3.00' per Mtok; '—' when unknown */
22
+ function perMtok(perToken) {
23
+ if (perToken === null || perToken === undefined) { return '—'; }
24
+ const n = Number(perToken);
25
+ return Number.isNaN(n) ? '—' : (n * 1e6).toFixed(2);
26
+ }
27
+
28
+ function fmtRow(m, aliasesById) {
29
+ const alias = aliasesById.get(m.id);
30
+ const aliasCol = alias ? `[${alias}] ` : '';
31
+ const ctx = m.contextLength ?? '—';
32
+ const pIn = perMtok(m.pricing && m.pricing.prompt);
33
+ const pOut = perMtok(m.pricing && m.pricing.completion);
34
+ return `${aliasCol}${m.id}\n ${m.name} ctx ${ctx} $/Mtok in ${pIn} out ${pOut}`;
35
+ }
36
+
37
+ /** alias marks: id → comma-joined alias names (defaults only — the curated view) */
38
+ function aliasMarks() {
39
+ const { getDefaultAliases } = require('../utils/config');
40
+ const map = new Map();
41
+ for (const [alias, model] of Object.entries(getDefaultAliases())) {
42
+ map.set(model, map.has(model) ? `${map.get(model)},${alias}` : alias);
43
+ }
44
+ return map;
45
+ }
46
+
47
+ async function runList(args) {
48
+ const { models, fetchedAt } = await getCatalogInfo();
49
+ const q = typeof args.search === 'string' ? args.search.toLowerCase() : null;
50
+ const filtered = q
51
+ ? models.filter(m => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q))
52
+ : models;
53
+ if (args.json) {
54
+ process.stdout.write(JSON.stringify(buildCatalogDoc({
55
+ models: filtered, fetchedAt, search: q
56
+ }), null, 2) + '\n');
57
+ return 0;
58
+ }
59
+ const marks = aliasMarks();
60
+ // Curated (alias-marked) rows first, then the rest.
61
+ const curated = filtered.filter(m => marks.has(m.id));
62
+ const rest = filtered.filter(m => !marks.has(m.id));
63
+ for (const m of [...curated, ...rest]) {
64
+ process.stdout.write(fmtRow(m, marks) + '\n');
65
+ }
66
+ const when = fetchedAt ? new Date(fetchedAt).toISOString() : 'never';
67
+ process.stdout.write(`(${filtered.length} models, catalog fetched ${when})\n`);
68
+ if (filtered.length === 0 && models.length === 0) {
69
+ process.stdout.write('Catalog unavailable (offline or first run) — try: amicus models --refresh\n');
70
+ }
71
+ return 0;
72
+ }
73
+
74
+ async function runRefresh(args) {
75
+ const models = await refreshCatalog();
76
+ if (args.json) {
77
+ process.stdout.write(JSON.stringify(buildCatalogDoc({
78
+ models, fetchedAt: models.length > 0 ? Date.now() : null, refreshed: true
79
+ }), null, 2) + '\n');
80
+ return 0;
81
+ }
82
+ process.stdout.write(`Refreshed catalog: ${models.length} models.\n`);
83
+ process.stdout.write(`Cache: ${catalogPath()}\n`);
84
+ return 0;
85
+ }
86
+
87
+ async function runCheck(args) {
88
+ const { models: catalog } = await getCatalogInfo();
89
+ if (!catalog || catalog.length === 0) {
90
+ if (args.json) {
91
+ process.stdout.write(JSON.stringify(buildAuditDoc({
92
+ stale: [], catalogAvailable: false
93
+ }), null, 2) + '\n');
94
+ } else {
95
+ process.stdout.write('Catalog unavailable (offline or no providers reachable); cannot check.\n');
96
+ }
97
+ return 0;
98
+ }
99
+ const sources = collectAliasSources();
100
+ const stale = findStaleAliases(sources, catalog)
101
+ .map(s => ({ ...s, suggestions: suggestReplacements(s.model, catalog) }));
102
+ if (args.json) {
103
+ process.stdout.write(JSON.stringify(buildAuditDoc({
104
+ stale, catalogAvailable: true
105
+ }), null, 2) + '\n');
106
+ return Math.min(stale.length, CHECK_EXIT_CAP);
107
+ }
108
+ if (stale.length === 0) {
109
+ process.stdout.write(`All aliases resolve to catalog models (${sources.length} checked).\n`);
110
+ return 0;
111
+ }
112
+ for (const s of stale) {
113
+ process.stdout.write(`STALE: ${s.alias} -> ${s.model} (${s.source})\n`);
114
+ if (s.suggestions.length > 0) {
115
+ process.stdout.write(` candidates: ${s.suggestions.join(', ')}\n`);
116
+ process.stdout.write(` fix: amicus setup --add-alias ${s.alias}=${s.suggestions[0]}\n`);
117
+ } else {
118
+ process.stdout.write(' no same-vendor candidates in catalog\n');
119
+ }
120
+ }
121
+ return Math.min(stale.length, CHECK_EXIT_CAP);
122
+ }
123
+
124
+ /** @param {object} args parsed CLI args @returns {Promise<number>} exit code */
125
+ async function handleModels(args) {
126
+ if (args.search === true) {
127
+ process.stderr.write('Error: --search requires a value\n');
128
+ return 1;
129
+ }
130
+ if (args.refresh) { return runRefresh(args); }
131
+ if (args.check) { return runCheck(args); }
132
+ return runList(args);
133
+ }
134
+
135
+ module.exports = { handleModels };