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.
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +477 -0
- package/bin/amicus.js +382 -0
- package/electron/assets/icon.png +0 -0
- package/electron/assets/icon.svg +5 -0
- package/electron/fold.js +163 -0
- package/electron/ipc-setup.js +176 -0
- package/electron/load-failsafe.js +85 -0
- package/electron/main.js +468 -0
- package/electron/preload-setup.js +38 -0
- package/electron/preload.js +33 -0
- package/electron/setup-ui-alias-script.js +218 -0
- package/electron/setup-ui-aliases.js +85 -0
- package/electron/setup-ui-keys-script.js +115 -0
- package/electron/setup-ui-keys.js +97 -0
- package/electron/setup-ui-model.js +138 -0
- package/electron/setup-ui-styles.js +327 -0
- package/electron/setup-ui.js +465 -0
- package/electron/summary.js +118 -0
- package/electron/toolbar.js +229 -0
- package/electron/window-position.js +35 -0
- package/package.json +98 -0
- package/scripts/postinstall.js +193 -0
- package/scripts/setup-hooks.js +42 -0
- package/skill/SKILL.md +976 -0
- package/skills/second-opinion/COUNCIL-DESIGN.md +227 -0
- package/skills/second-opinion/MODEL-NOTES.md +104 -0
- package/skills/second-opinion/SKILL.md +389 -0
- package/src/cli-handlers.js +188 -0
- package/src/cli.js +400 -0
- package/src/conflict.js +144 -0
- package/src/context-compression.js +102 -0
- package/src/context.js +199 -0
- package/src/drift.js +144 -0
- package/src/environment.js +157 -0
- package/src/headless.js +742 -0
- package/src/index.js +106 -0
- package/src/jsonl-parser.js +180 -0
- package/src/mcp-server.js +625 -0
- package/src/mcp-tools.js +407 -0
- package/src/opencode-client.js +615 -0
- package/src/prompt-builder.js +355 -0
- package/src/prompts/cowork-agent-prompt.js +118 -0
- package/src/session-manager.js +414 -0
- package/src/session.js +180 -0
- package/src/sidecar/context-builder.js +297 -0
- package/src/sidecar/continue.js +212 -0
- package/src/sidecar/crash-handler.js +56 -0
- package/src/sidecar/fanout-leg.js +107 -0
- package/src/sidecar/fanout-output.js +46 -0
- package/src/sidecar/fanout.js +236 -0
- package/src/sidecar/interactive.js +217 -0
- package/src/sidecar/models.js +135 -0
- package/src/sidecar/progress.js +218 -0
- package/src/sidecar/read.js +183 -0
- package/src/sidecar/resume.js +221 -0
- package/src/sidecar/session-utils.js +288 -0
- package/src/sidecar/setup-window.js +79 -0
- package/src/sidecar/setup.js +280 -0
- package/src/sidecar/start.js +251 -0
- package/src/utils/agent-mapping.js +138 -0
- package/src/utils/alias-audit.js +98 -0
- package/src/utils/alias-resolver.js +77 -0
- package/src/utils/api-key-store.js +259 -0
- package/src/utils/api-key-validation.js +97 -0
- package/src/utils/auth-json.js +109 -0
- package/src/utils/config.js +291 -0
- package/src/utils/curated-models.js +82 -0
- package/src/utils/env-compat.js +38 -0
- package/src/utils/env-loader.js +54 -0
- package/src/utils/idle-watchdog.js +225 -0
- package/src/utils/input-validators.js +127 -0
- package/src/utils/lifecycle.js +43 -0
- package/src/utils/logger.js +84 -0
- package/src/utils/mcp-discovery.js +194 -0
- package/src/utils/mcp-validators.js +78 -0
- package/src/utils/model-catalog.js +103 -0
- package/src/utils/model-fetcher.js +179 -0
- package/src/utils/model-validator.js +207 -0
- package/src/utils/path-setup.js +41 -0
- package/src/utils/port-pid.js +39 -0
- package/src/utils/prompt-source.js +53 -0
- package/src/utils/result-schema.js +261 -0
- package/src/utils/server-setup.js +93 -0
- package/src/utils/session-abort.js +53 -0
- package/src/utils/session-lock.js +95 -0
- package/src/utils/shared-server.js +216 -0
- package/src/utils/start-helpers.js +76 -0
- package/src/utils/thinking-validators.js +92 -0
- package/src/utils/update-notifier-loader.js +18 -0
- package/src/utils/updater.js +157 -0
- 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 };
|