amicus 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +19 -0
- package/CHANGELOG.md +53 -0
- package/LICENSE +22 -1
- package/README.md +12 -2
- package/bin/amicus.js +13 -161
- package/package.json +6 -4
- package/scripts/postinstall.js +16 -7
- package/skills/second-opinion/COUNCIL-DESIGN.md +34 -34
- package/skills/second-opinion/MODEL-NOTES.md +23 -17
- package/skills/second-opinion/SKILL.md +77 -45
- package/src/cli-handlers-council.js +59 -0
- package/src/cli-handlers-doctor.js +173 -0
- package/src/cli-handlers-run.js +196 -0
- package/src/cli-handlers.js +1 -1
- package/src/cli.js +11 -2
- package/src/council/findings.js +48 -0
- package/src/council/ledger.js +82 -0
- package/src/council/tally.js +108 -0
- package/src/council/verdict.js +48 -0
- package/src/headless.js +43 -149
- package/src/mcp-server.js +6 -0
- package/src/sidecar/budget.js +83 -0
- package/src/sidecar/conversation-mirror.js +128 -0
- package/src/sidecar/fanout-leg.js +4 -1
- package/src/sidecar/fanout.js +34 -7
- package/src/sidecar/interactive-mirror.js +66 -0
- package/src/sidecar/interactive.js +35 -21
- package/src/sidecar/models.js +10 -9
- package/src/sidecar/session-finalize.js +26 -0
- package/src/sidecar/session-utils.js +5 -5
- package/src/sidecar/setup.js +2 -2
- package/src/sidecar/start.js +19 -6
- package/src/utils/activity-poller.js +47 -0
- package/src/utils/alias-resolver.js +1 -1
- package/src/utils/config.js +4 -4
- package/src/utils/error-doc.js +55 -0
- package/src/utils/lifecycle.js +1 -1
- package/src/utils/model-catalog.js +1 -1
- package/src/utils/pricing.js +93 -0
- package/src/utils/result-schema.js +21 -2
- package/src/utils/session-abort.js +40 -13
- package/src/utils/validators.js +17 -17
- /package/{skill → skills/sidecar}/SKILL.md +0 -0
package/src/sidecar/fanout.js
CHANGED
|
@@ -14,6 +14,7 @@ const fs = require('fs');
|
|
|
14
14
|
const path = require('path');
|
|
15
15
|
const { logger } = require('../utils/logger');
|
|
16
16
|
const { runLeg } = require('./fanout-leg');
|
|
17
|
+
const { ERROR_CODES } = require('../utils/error-doc');
|
|
17
18
|
|
|
18
19
|
/** Default max legs per wave (env-overridable). */
|
|
19
20
|
const DEFAULT_MAX_LEGS = 10;
|
|
@@ -48,38 +49,39 @@ function deriveLegIds(waveId, count) {
|
|
|
48
49
|
async function validateFanoutModels(modelsArg, opts = {}) {
|
|
49
50
|
const raw = parseModelsList(modelsArg);
|
|
50
51
|
if (raw.length === 0) {
|
|
51
|
-
return { error: 'Error: --models requires a comma-separated list (e.g. gemini,gpt,deepseek)' };
|
|
52
|
+
return { error: 'Error: --models requires a comma-separated list (e.g. gemini,gpt,deepseek)', code: 'BAD_ARGS' };
|
|
52
53
|
}
|
|
53
54
|
// Invalid or non-positive AMICUS_FANOUT_MAX_LEGS (0, negative, garbage) falls back to the default.
|
|
54
55
|
const envCap = Number(process.env.AMICUS_FANOUT_MAX_LEGS);
|
|
55
56
|
const maxLegs = (Number.isInteger(envCap) && envCap > 0) ? envCap : DEFAULT_MAX_LEGS;
|
|
56
57
|
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
|
+
return { error: `Error: --models exceeds the fan-out cap of ${maxLegs} legs (set AMICUS_FANOUT_MAX_LEGS to raise)`, code: 'BAD_ARGS' };
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
const { tryResolveModel } = require('../utils/config');
|
|
61
62
|
const { validateApiKey } = require('../utils/validators');
|
|
62
63
|
const { validateAgainstCatalog } = require('../utils/model-validator');
|
|
64
|
+
const { lookupPricing } = require('../utils/pricing');
|
|
63
65
|
const legs = [];
|
|
64
66
|
for (const modelInput of raw) {
|
|
65
67
|
const resolved = tryResolveModel(modelInput);
|
|
66
68
|
if (resolved.error) {
|
|
67
|
-
return { error: `Error: model '${modelInput}': ${resolved.error}
|
|
69
|
+
return { error: `Error: model '${modelInput}': ${resolved.error}`, code: 'BAD_MODEL' };
|
|
68
70
|
}
|
|
69
71
|
let model = resolved.model;
|
|
70
72
|
const keyCheck = validateApiKey(model);
|
|
71
73
|
if (!keyCheck.valid) {
|
|
72
|
-
return { error: keyCheck.error };
|
|
74
|
+
return { error: keyCheck.error, code: 'MISSING_KEY' };
|
|
73
75
|
}
|
|
74
76
|
if (!opts.noValidateModel) {
|
|
75
77
|
const alias = modelInput.includes('/') ? undefined : modelInput;
|
|
76
78
|
try {
|
|
77
79
|
model = await validateAgainstCatalog(model, alias);
|
|
78
80
|
} catch (err) {
|
|
79
|
-
return { error: err.message };
|
|
81
|
+
return { error: err.message, code: 'BAD_MODEL' };
|
|
80
82
|
}
|
|
81
83
|
}
|
|
82
|
-
legs.push({ modelInput, model });
|
|
84
|
+
legs.push({ modelInput, model, pricing: lookupPricing(model) });
|
|
83
85
|
}
|
|
84
86
|
return { legs };
|
|
85
87
|
}
|
|
@@ -131,11 +133,36 @@ async function runFanout(options) {
|
|
|
131
133
|
return { wave: doc, exitCode: 1 };
|
|
132
134
|
};
|
|
133
135
|
|
|
136
|
+
const failPre = (code, message, hint) => {
|
|
137
|
+
if (!options.quiet) {
|
|
138
|
+
if (options.json) {
|
|
139
|
+
const { buildErrorDoc } = require('../utils/error-doc');
|
|
140
|
+
console.log(JSON.stringify(buildErrorDoc({ code, message, hint }), null, 2));
|
|
141
|
+
} else {
|
|
142
|
+
console.error(hint ? `${message}\n${hint}` : message);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { wave: null, errorDoc: { code, message }, exitCode: 1 };
|
|
146
|
+
};
|
|
147
|
+
|
|
134
148
|
// 1. Fail-fast validation
|
|
135
149
|
const validated = await validateFanoutModels(options.models, { noValidateModel: options.noValidateModel });
|
|
136
|
-
if (validated.error) { return
|
|
150
|
+
if (validated.error) { return failPre(validated.code || 'BAD_ARGS', validated.error); }
|
|
137
151
|
const legs = validated.legs;
|
|
138
152
|
|
|
153
|
+
// 1b. Budget gate (pre-creation; refuse before spending)
|
|
154
|
+
if (!options.noCostGate) {
|
|
155
|
+
const { checkBudget, formatBudgetError } = require('./budget');
|
|
156
|
+
const { loadConfig } = require('../utils/config');
|
|
157
|
+
const cfg = loadConfig() || {};
|
|
158
|
+
const maxCostPerMtok = options.maxCostPerMtok !== undefined ? options.maxCostPerMtok : cfg.maxCostPerMtok;
|
|
159
|
+
const promptChars = (options.promptMeta && options.promptMeta.chars) || (options.prompt ? options.prompt.length : 0);
|
|
160
|
+
const budget = checkBudget(legs, { maxCostPerMtok, maxCost: options.maxCost !== null && options.maxCost !== undefined ? options.maxCost : cfg.maxCost, promptChars });
|
|
161
|
+
if (!budget.ok) {
|
|
162
|
+
return failPre(ERROR_CODES.BUDGET_EXCEEDED, 'Error: budget gate refused the wave', formatBudgetError(budget));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
139
166
|
// 2. Wave record
|
|
140
167
|
const waveId = options.waveId || generateTaskId();
|
|
141
168
|
const legIds = deriveLegIds(waveId, legs.length);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// src/sidecar/interactive-mirror.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { createMirrorState, mirrorMessages, logMessage } = require('./conversation-mirror');
|
|
6
|
+
const { writeProgress } = require('./progress');
|
|
7
|
+
const { sumPerMessageUsage } = require('../utils/pricing');
|
|
8
|
+
const { logger } = require('../utils/logger');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Poll the OpenCode session and mirror it to conversation.jsonl + progress.json
|
|
12
|
+
* live, exactly like headless. Best-effort and non-blocking — a poll/write error
|
|
13
|
+
* never throws into the GUI session.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} opts
|
|
16
|
+
* @param {() => Promise<Array>} opts.getMessages
|
|
17
|
+
* @param {string} opts.sessionDir
|
|
18
|
+
* @param {number} [opts.intervalMs=2000]
|
|
19
|
+
* @param {() => void} [opts.onActivity]
|
|
20
|
+
* @param {() => string} [opts.now]
|
|
21
|
+
* @returns {{ stop: () => Promise<{usage: object|null}> }}
|
|
22
|
+
*/
|
|
23
|
+
function startInteractiveMirror({ getMessages, sessionDir, intervalMs = 2000, onActivity, now }) {
|
|
24
|
+
const state = createMirrorState();
|
|
25
|
+
const conversationPath = path.join(sessionDir, 'conversation.jsonl');
|
|
26
|
+
let timer = null;
|
|
27
|
+
let stopped = false;
|
|
28
|
+
|
|
29
|
+
async function pollOnce() {
|
|
30
|
+
try {
|
|
31
|
+
const messages = await getMessages();
|
|
32
|
+
const mr = mirrorMessages(messages, state, { now });
|
|
33
|
+
mr.appendLines.forEach(line => logMessage(conversationPath, line));
|
|
34
|
+
mr.progressUpdates.forEach(p => writeProgress(sessionDir, p.stage, p.extra));
|
|
35
|
+
if (mr.appendLines.length > 0 && onActivity) { onActivity(); }
|
|
36
|
+
} catch (err) {
|
|
37
|
+
logger.debug('Interactive mirror poll failed (best-effort)', { error: err.message });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const schedule = () => {
|
|
42
|
+
if (stopped) { return; }
|
|
43
|
+
timer = setTimeout(tick, intervalMs);
|
|
44
|
+
if (timer.unref) { timer.unref(); }
|
|
45
|
+
};
|
|
46
|
+
async function tick() {
|
|
47
|
+
if (stopped) { return; }
|
|
48
|
+
await pollOnce();
|
|
49
|
+
schedule();
|
|
50
|
+
}
|
|
51
|
+
schedule();
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
async stop() {
|
|
55
|
+
stopped = true;
|
|
56
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
57
|
+
await pollOnce(); // final flush
|
|
58
|
+
try { writeProgress(sessionDir, 'complete'); } catch { /* best-effort */ }
|
|
59
|
+
let usage = null;
|
|
60
|
+
try { usage = sumPerMessageUsage(state.usageByMsg); } catch { /* best-effort */ }
|
|
61
|
+
return { usage };
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { startInteractiveMirror };
|
|
@@ -7,10 +7,12 @@ const path = require('path');
|
|
|
7
7
|
const { spawn } = require('child_process');
|
|
8
8
|
|
|
9
9
|
const { startOpenCodeServer } = require('./session-utils');
|
|
10
|
-
const { createSession, sendPromptAsync } = require('../opencode-client');
|
|
10
|
+
const { createSession, sendPromptAsync, getMessages } = require('../opencode-client');
|
|
11
11
|
const { mapAgentToOpenCode } = require('../utils/agent-mapping');
|
|
12
12
|
const { logger } = require('../utils/logger');
|
|
13
13
|
const { getCompatEnv } = require('../utils/env-compat');
|
|
14
|
+
const { startInteractiveMirror } = require('./interactive-mirror');
|
|
15
|
+
const { getSessionDir } = require('../session-manager');
|
|
14
16
|
|
|
15
17
|
/** Get the Electron binary path via require('electron').
|
|
16
18
|
* Works in all install contexts (global, local, npx hoisted).
|
|
@@ -151,15 +153,35 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
151
153
|
|
|
152
154
|
const serverPort = new URL(server.url).port;
|
|
153
155
|
|
|
154
|
-
// Start idle watchdog for interactive mode (60-min default timeout)
|
|
156
|
+
// Start idle watchdog for interactive mode (60-min default timeout).
|
|
157
|
+
// The real teardown handler is installed BEFORE start() (closes the startup
|
|
158
|
+
// race) and references a closure that is assigned once Electron spawns.
|
|
155
159
|
const { IdleWatchdog } = require('../utils/idle-watchdog');
|
|
160
|
+
const { createActivityPoller, killIfAlive } = require('../utils/activity-poller');
|
|
161
|
+
const { getSessionStatus } = require('../opencode-client');
|
|
162
|
+
let electronProcess = null;
|
|
156
163
|
const watchdog = new IdleWatchdog({
|
|
157
164
|
mode: 'interactive',
|
|
158
165
|
onTimeout: () => {
|
|
159
166
|
logger.info('Interactive idle timeout - shutting down', { taskId });
|
|
167
|
+
killIfAlive(electronProcess);
|
|
160
168
|
},
|
|
161
169
|
}).start();
|
|
162
170
|
|
|
171
|
+
// Keep the idle clock from killing an actively-working session: poll OpenCode
|
|
172
|
+
// session status and touch the watchdog on any non-idle (busy/retry) state.
|
|
173
|
+
const activityPoller = createActivityPoller({
|
|
174
|
+
getStatus: () => getSessionStatus(ocClient, sessionId),
|
|
175
|
+
onActivity: () => watchdog.touch(),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const sessionDir = getSessionDir(project, taskId);
|
|
179
|
+
const mirror = startInteractiveMirror({
|
|
180
|
+
getMessages: () => getMessages(ocClient, sessionId),
|
|
181
|
+
sessionDir,
|
|
182
|
+
onActivity: () => watchdog.touch(),
|
|
183
|
+
});
|
|
184
|
+
|
|
163
185
|
return new Promise((resolve, _reject) => {
|
|
164
186
|
const electronPath = getElectronPath();
|
|
165
187
|
const mainPath = path.join(__dirname, '..', '..', 'electron', 'main.js');
|
|
@@ -170,40 +192,32 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
170
192
|
taskId, model, project, nodeModulesBin, existingPath,
|
|
171
193
|
{ agent, isResume, conversation, mcp, client }
|
|
172
194
|
);
|
|
173
|
-
|
|
174
|
-
// Pass OpenCode server info to Electron
|
|
175
195
|
env.AMICUS_OPENCODE_PORT = serverPort;
|
|
176
196
|
env.AMICUS_SESSION_ID = sessionId;
|
|
177
197
|
|
|
178
198
|
const debugPort = getCompatEnv('DEBUG_PORT') || '9222';
|
|
179
199
|
logger.debug('Launching Electron', { taskId, model, debugPort, serverPort, sessionId });
|
|
180
200
|
|
|
181
|
-
|
|
201
|
+
electronProcess = spawn(electronPath, [
|
|
182
202
|
`--remote-debugging-port=${debugPort}`,
|
|
183
203
|
mainPath
|
|
184
204
|
], { cwd: project, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
185
205
|
|
|
186
|
-
//
|
|
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
|
-
};
|
|
206
|
+
// Belt-and-suspenders: also touch on raw Electron stdout activity.
|
|
207
|
+
electronProcess.stdout.on('data', () => { watchdog.touch(); });
|
|
198
208
|
|
|
199
|
-
// Clean up server when Electron exits
|
|
200
|
-
|
|
201
|
-
handleElectronProcess(electronProcess, taskId, (result) => {
|
|
209
|
+
// Clean up server + timers when Electron exits.
|
|
210
|
+
handleElectronProcess(electronProcess, taskId, async (result) => {
|
|
202
211
|
watchdog.cancel();
|
|
212
|
+
activityPoller.stop();
|
|
213
|
+
try {
|
|
214
|
+
const { usage } = await mirror.stop();
|
|
215
|
+
if (usage) { result.usage = usage; }
|
|
216
|
+
} catch (err) { logger.debug('mirror stop failed', { error: err.message }); }
|
|
203
217
|
server.close();
|
|
204
218
|
logger.debug('OpenCode server closed after Electron exit');
|
|
205
219
|
result.opencodeSessionId = sessionId;
|
|
206
|
-
|
|
220
|
+
resolve(result);
|
|
207
221
|
});
|
|
208
222
|
});
|
|
209
223
|
}
|
package/src/sidecar/models.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `amicus models` (F5) — list/search the catalog, refresh it, audit aliases.
|
|
3
3
|
*
|
|
4
|
-
* amicus models list (
|
|
4
|
+
* amicus models list (effective aliases marked)
|
|
5
5
|
* amicus models --search <q> substring filter over id+name
|
|
6
6
|
* amicus models --refresh force-refresh the cache
|
|
7
7
|
* amicus models --check stale-alias audit (exit = stale count, max 100)
|
|
@@ -20,11 +20,12 @@ const { pickCurrent } = require('../utils/quick-picks');
|
|
|
20
20
|
|
|
21
21
|
const CHECK_EXIT_CAP = 100;
|
|
22
22
|
|
|
23
|
-
/** '0.000003' per token → '3.00' per Mtok; '—' when unknown */
|
|
23
|
+
/** '0.000003' per token → '3.00' per Mtok; '—' when unknown or variable (-1) */
|
|
24
24
|
function perMtok(perToken) {
|
|
25
25
|
if (perToken === null || perToken === undefined) { return '—'; }
|
|
26
26
|
const n = Number(perToken);
|
|
27
|
-
|
|
27
|
+
if (Number.isNaN(n) || n < 0) { return '—'; }
|
|
28
|
+
return (n * 1e6).toFixed(2);
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
function fmtRow(m, aliasesById) {
|
|
@@ -36,11 +37,11 @@ function fmtRow(m, aliasesById) {
|
|
|
36
37
|
return `${aliasCol}${m.id}\n ${m.name} ctx ${ctx} $/Mtok in ${pIn} out ${pOut}`;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
/** alias marks: id → comma-joined alias names (
|
|
40
|
+
/** alias marks: id → comma-joined alias names (effective user aliases) */
|
|
40
41
|
function aliasMarks() {
|
|
41
|
-
const {
|
|
42
|
+
const { getEffectiveAliases } = require('../utils/config');
|
|
42
43
|
const map = new Map();
|
|
43
|
-
for (const [alias, model] of Object.entries(
|
|
44
|
+
for (const [alias, model] of Object.entries(getEffectiveAliases())) {
|
|
44
45
|
map.set(model, map.has(model) ? `${map.get(model)},${alias}` : alias);
|
|
45
46
|
}
|
|
46
47
|
return map;
|
|
@@ -59,10 +60,10 @@ async function runList(args) {
|
|
|
59
60
|
return 0;
|
|
60
61
|
}
|
|
61
62
|
const marks = aliasMarks();
|
|
62
|
-
//
|
|
63
|
-
const
|
|
63
|
+
// Effective aliases (alias-marked) rows first, then the rest.
|
|
64
|
+
const marked = filtered.filter(m => marks.has(m.id));
|
|
64
65
|
const rest = filtered.filter(m => !marks.has(m.id));
|
|
65
|
-
for (const m of [...
|
|
66
|
+
for (const m of [...marked, ...rest]) {
|
|
66
67
|
process.stdout.write(fmtRow(m, marks) + '\n');
|
|
67
68
|
}
|
|
68
69
|
const when = fetchedAt ? new Date(fetchedAt).toISOString() : 'never';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Map a runHeadless result to the canonical terminal status + process exit code.
|
|
5
|
+
* Single source of truth so start.js, the signal handler, and the idle backstop
|
|
6
|
+
* never disagree. Error wins over all other flags; signal callers pass the signal
|
|
7
|
+
* name for the 130/143 convention.
|
|
8
|
+
*
|
|
9
|
+
* @param {{completed?:boolean,timedOut?:boolean,aborted?:boolean,error?:any}|null} result
|
|
10
|
+
* @param {string} [signal] - 'SIGINT' | 'SIGTERM' | 'SIGBREAK' for signal aborts
|
|
11
|
+
* @returns {{status:'complete'|'error'|'timed-out'|'aborted', exitCode:number}}
|
|
12
|
+
*/
|
|
13
|
+
function resolveTerminalState(result, signal) {
|
|
14
|
+
if (!result || result.error) { return { status: 'error', exitCode: 1 }; }
|
|
15
|
+
if (result.aborted) {
|
|
16
|
+
const exitCode = signal === 'SIGINT' ? 130
|
|
17
|
+
: (signal === 'SIGTERM' || signal === 'SIGBREAK') ? 143
|
|
18
|
+
: 2;
|
|
19
|
+
return { status: 'aborted', exitCode };
|
|
20
|
+
}
|
|
21
|
+
if (result.timedOut) { return { status: 'timed-out', exitCode: 2 }; }
|
|
22
|
+
if (result.completed) { return { status: 'complete', exitCode: 0 }; }
|
|
23
|
+
return { status: 'error', exitCode: 1 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { resolveTerminalState };
|
|
@@ -90,12 +90,12 @@ function finalizeSession(sessionDir, summary, project, metadata, opts = {}) {
|
|
|
90
90
|
// Save summary
|
|
91
91
|
fs.writeFileSync(SessionPaths.summaryFile(sessionDir), summary, { mode: 0o600 });
|
|
92
92
|
|
|
93
|
-
// Update metadata to complete
|
|
94
|
-
metadata.status = 'complete';
|
|
93
|
+
// Update metadata to the resolved terminal status (default complete).
|
|
94
|
+
metadata.status = opts.status || 'complete';
|
|
95
95
|
metadata.completedAt = new Date().toISOString();
|
|
96
96
|
fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
|
97
97
|
|
|
98
|
-
logger.info('Session
|
|
98
|
+
logger.info('Session finalized', { taskId: metadata.taskId, status: metadata.status });
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
/** Output summary to stdout with standard formatting */
|
|
@@ -122,9 +122,9 @@ function createHeartbeat(interval = HEARTBEAT_INTERVAL, sessionDir) {
|
|
|
122
122
|
if (sessionDir) {
|
|
123
123
|
const { readProgress } = require('./progress');
|
|
124
124
|
const progress = readProgress(sessionDir);
|
|
125
|
-
process.stderr.write(`[
|
|
125
|
+
process.stderr.write(`[amicus] ${ts} | ${progress.messages} messages | ${progress.latest}\n`);
|
|
126
126
|
} else {
|
|
127
|
-
process.stderr.write(`[
|
|
127
|
+
process.stderr.write(`[amicus] still running... ${ts} elapsed\n`);
|
|
128
128
|
}
|
|
129
129
|
}, interval);
|
|
130
130
|
|
package/src/sidecar/setup.js
CHANGED
|
@@ -168,7 +168,7 @@ async function runReadlineSetup() {
|
|
|
168
168
|
|
|
169
169
|
try {
|
|
170
170
|
console.log('');
|
|
171
|
-
console.log('===
|
|
171
|
+
console.log('=== Amicus Setup Wizard ===');
|
|
172
172
|
console.log('');
|
|
173
173
|
|
|
174
174
|
const keys = detectApiKeys();
|
|
@@ -181,7 +181,7 @@ async function runReadlineSetup() {
|
|
|
181
181
|
console.log(`API keys detected: ${foundKeys.join(', ')}`);
|
|
182
182
|
} else {
|
|
183
183
|
console.log('No API keys detected.');
|
|
184
|
-
console.log('Set OPENROUTER_API_KEY to get started, or run:
|
|
184
|
+
console.log('Set OPENROUTER_API_KEY to get started, or run: amicus setup');
|
|
185
185
|
}
|
|
186
186
|
console.log('');
|
|
187
187
|
|
package/src/sidecar/start.js
CHANGED
|
@@ -218,15 +218,26 @@ async function startSidecar(options) {
|
|
|
218
218
|
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
//
|
|
222
|
-
|
|
221
|
+
// Map the run result to a definitive terminal status + exit code (single source of truth).
|
|
222
|
+
const { resolveTerminalState } = require('./session-finalize');
|
|
223
|
+
const terminal = resolveTerminalState(result);
|
|
224
|
+
if (terminal.status === 'error') {
|
|
223
225
|
meta.status = 'error';
|
|
224
|
-
meta.reason = result.error;
|
|
226
|
+
meta.reason = (result && result.error) ? String(result.error) : 'Incomplete';
|
|
225
227
|
meta.completedAt = new Date().toISOString();
|
|
226
228
|
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
|
227
|
-
logger.error('Session completed with error', { taskId, error:
|
|
229
|
+
logger.error('Session completed with error', { taskId, error: meta.reason });
|
|
228
230
|
} else {
|
|
229
|
-
|
|
231
|
+
// complete / timed-out / aborted: persist the (possibly partial) summary with the correct status.
|
|
232
|
+
finalizeSession(sessDir, summary, effectiveProject, meta, { quietStdout: json, status: terminal.status });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const { resolveUsage } = require('../utils/pricing');
|
|
236
|
+
const runUsage = result && result.usage ? resolveUsage({ model, usageTotals: result.usage }) : null;
|
|
237
|
+
if (runUsage) {
|
|
238
|
+
const m = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
239
|
+
m.usage = runUsage;
|
|
240
|
+
fs.writeFileSync(metaPath, JSON.stringify(m, null, 2), { mode: 0o600 });
|
|
230
241
|
}
|
|
231
242
|
|
|
232
243
|
if (json) {
|
|
@@ -234,10 +245,12 @@ async function startSidecar(options) {
|
|
|
234
245
|
const finalMeta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
235
246
|
const doc = buildRunResult({
|
|
236
247
|
taskId, metadata: finalMeta, result, summary,
|
|
237
|
-
modelInput, sessionDir: sessDir,
|
|
248
|
+
modelInput, sessionDir: sessDir, usage: runUsage,
|
|
238
249
|
});
|
|
239
250
|
console.log(JSON.stringify(doc, null, 2));
|
|
240
251
|
}
|
|
252
|
+
|
|
253
|
+
return terminal.exitCode;
|
|
241
254
|
}
|
|
242
255
|
|
|
243
256
|
module.exports = {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Poll an async status source on an interval and fire onActivity() on any non-idle state.
|
|
5
|
+
* Best-effort: getStatus errors are swallowed and polling continues. Timers are unref'd
|
|
6
|
+
* so the poller never keeps the process alive.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} opts
|
|
9
|
+
* @param {() => Promise<{type?:string}>} opts.getStatus
|
|
10
|
+
* @param {() => void} opts.onActivity
|
|
11
|
+
* @param {number} [opts.intervalMs=30000]
|
|
12
|
+
* @returns {{ stop: () => void }}
|
|
13
|
+
*/
|
|
14
|
+
function createActivityPoller({ getStatus, onActivity, intervalMs = 30000 }) {
|
|
15
|
+
let timer = null;
|
|
16
|
+
let stopped = false;
|
|
17
|
+
|
|
18
|
+
const schedule = () => {
|
|
19
|
+
if (stopped) { return; }
|
|
20
|
+
timer = setTimeout(tick, intervalMs);
|
|
21
|
+
if (timer.unref) { timer.unref(); }
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
async function tick() {
|
|
25
|
+
if (stopped) { return; }
|
|
26
|
+
try {
|
|
27
|
+
const status = await getStatus();
|
|
28
|
+
if (status && status.type && status.type !== 'idle') { onActivity(); }
|
|
29
|
+
} catch { /* best-effort */ }
|
|
30
|
+
schedule();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
schedule();
|
|
34
|
+
return {
|
|
35
|
+
stop() {
|
|
36
|
+
stopped = true;
|
|
37
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** SIGTERM a child process if it exists and isn't already killed. */
|
|
43
|
+
function killIfAlive(child) {
|
|
44
|
+
if (child && !child.killed) { child.kill('SIGTERM'); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { createActivityPoller, killIfAlive };
|
|
@@ -67,7 +67,7 @@ function autoRepairAlias(alias, config, defaultAliases, saveConfig) {
|
|
|
67
67
|
}
|
|
68
68
|
throw new Error(
|
|
69
69
|
`Alias '${alias}' is configured but has no model value. ` +
|
|
70
|
-
`Fix with:
|
|
70
|
+
`Fix with: amicus setup --add-alias ${alias}=provider/model`
|
|
71
71
|
);
|
|
72
72
|
}
|
|
73
73
|
|
package/src/utils/config.js
CHANGED
|
@@ -93,7 +93,7 @@ function getDefaultAliases() {
|
|
|
93
93
|
* Resolution order:
|
|
94
94
|
* 1. If modelArg contains '/' -> return as-is (full model string)
|
|
95
95
|
* 2. If modelArg is a key in config.aliases -> return resolved string
|
|
96
|
-
* 3. If modelArg is unknown alias -> throw Error mentioning '
|
|
96
|
+
* 3. If modelArg is unknown alias -> throw Error mentioning 'amicus setup'
|
|
97
97
|
* 4. If modelArg is undefined and config.default exists -> resolve that alias
|
|
98
98
|
* 5. If no default -> throw Error
|
|
99
99
|
*
|
|
@@ -124,14 +124,14 @@ function resolveModel(modelArg) {
|
|
|
124
124
|
|
|
125
125
|
// Unknown alias
|
|
126
126
|
throw new Error(
|
|
127
|
-
`Unknown model alias '${modelArg}'. Run '
|
|
127
|
+
`Unknown model alias '${modelArg}'. Run 'amicus setup' to configure aliases.`
|
|
128
128
|
);
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
// modelArg is undefined - use default
|
|
132
132
|
if (!config || !config.default) {
|
|
133
133
|
throw new Error(
|
|
134
|
-
'No model specified and no default configured. Run \'
|
|
134
|
+
'No model specified and no default configured. Run \'amicus setup\' to set a default model.'
|
|
135
135
|
);
|
|
136
136
|
}
|
|
137
137
|
|
|
@@ -153,7 +153,7 @@ function resolveModel(modelArg) {
|
|
|
153
153
|
|
|
154
154
|
// Default alias not found anywhere
|
|
155
155
|
throw new Error(
|
|
156
|
-
`Default alias '${defaultValue}' not found in aliases. Run '
|
|
156
|
+
`Default alias '${defaultValue}' not found in aliases. Run 'amicus setup' to fix configuration.`
|
|
157
157
|
);
|
|
158
158
|
}
|
|
159
159
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// src/utils/error-doc.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module error-doc
|
|
6
|
+
* Structured error envelope for the `--json` contract (WS-2 #6). Every
|
|
7
|
+
* pre-flight failure under --json writes one of these to STDOUT (with a stable
|
|
8
|
+
* code) so an automation caller doing JSON.parse(stdout) gets a typed result
|
|
9
|
+
* instead of an empty string. Non-JSON callers keep human text on stderr.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { SCHEMA_VERSION } = require('./result-schema');
|
|
13
|
+
|
|
14
|
+
/** Frozen — adding a code later is additive; renaming/removing is breaking. */
|
|
15
|
+
const ERROR_CODES = Object.freeze({
|
|
16
|
+
BAD_ARGS: 'BAD_ARGS', // bad/empty flag, mutually-exclusive flags, bad numeric/enum value
|
|
17
|
+
MISSING_PROMPT: 'MISSING_PROMPT', // no/empty --prompt or --prompt-file
|
|
18
|
+
BAD_MODEL: 'BAD_MODEL', // bad model format, not on provider, not in catalog
|
|
19
|
+
MISSING_KEY: 'MISSING_KEY', // provider API key absent
|
|
20
|
+
BAD_SESSION: 'BAD_SESSION', // task id missing / invalid / not found
|
|
21
|
+
BUDGET_EXCEEDED: 'BUDGET_EXCEEDED', // the WS-2 #10 spend gate
|
|
22
|
+
INTERNAL: 'INTERNAL', // unexpected pre-flight throw
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {{code: string, message: string, hint?: string, command?: string}} opts
|
|
27
|
+
* @returns {object} error document
|
|
28
|
+
*/
|
|
29
|
+
function buildErrorDoc({ code, message, hint = null, command = null }) {
|
|
30
|
+
return {
|
|
31
|
+
schemaVersion: SCHEMA_VERSION,
|
|
32
|
+
type: 'error',
|
|
33
|
+
ok: false,
|
|
34
|
+
error: { code, message, hint: hint || null, command: command || null },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Emit a pre-flight failure: JSON envelope to stdout when useJson, else the
|
|
40
|
+
* human message to stderr. Returns the exit code (always 1) so callers can
|
|
41
|
+
* `process.exit(failJson(...))`.
|
|
42
|
+
* @param {boolean} useJson
|
|
43
|
+
* @param {{code: string, message: string, hint?: string, command?: string}} opts
|
|
44
|
+
* @returns {number}
|
|
45
|
+
*/
|
|
46
|
+
function failJson(useJson, { code, message, hint = null, command = null }) {
|
|
47
|
+
if (useJson) {
|
|
48
|
+
process.stdout.write(JSON.stringify(buildErrorDoc({ code, message, hint, command }), null, 2) + '\n');
|
|
49
|
+
} else {
|
|
50
|
+
process.stderr.write(message + '\n');
|
|
51
|
+
}
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { ERROR_CODES, buildErrorDoc, failJson };
|
package/src/utils/lifecycle.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// when done (F3 #15). Deliberately EXCLUDED: `mcp` (long-lived server), and
|
|
13
13
|
// `setup`/`update` (no OpenCode server to leak, and `setup` can be a long-lived
|
|
14
14
|
// interactive Electron flow that must never be force-exited).
|
|
15
|
-
const ONE_SHOT_COMMANDS = new Set(['start', 'continue', 'resume', 'list', 'read', 'abort', 'fanout', 'models', 'key' /* local-only: no OpenCode server, no stray handles */]);
|
|
15
|
+
const ONE_SHOT_COMMANDS = new Set(['start', 'continue', 'resume', 'list', 'read', 'abort', 'fanout', 'models', 'key', 'council', 'doctor' /* local-only: no OpenCode server, no stray handles */]);
|
|
16
16
|
|
|
17
17
|
/** @param {string} command @returns {boolean} */
|
|
18
18
|
function isOneShotCommand(command) {
|
|
@@ -100,4 +100,4 @@ async function getCatalogInfo(opts = {}) {
|
|
|
100
100
|
return { models, fetchedAt: cache ? cache.fetchedAt : null };
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
module.exports = { getCatalog, refreshCatalog, catalogPath, getCatalogInfo, CATALOG_SCHEMA_VERSION };
|
|
103
|
+
module.exports = { getCatalog, refreshCatalog, catalogPath, getCatalogInfo, readCache, CATALOG_SCHEMA_VERSION };
|