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.
Files changed (44) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/.claude-plugin/plugin.json +19 -0
  3. package/CHANGELOG.md +53 -0
  4. package/LICENSE +22 -1
  5. package/README.md +12 -2
  6. package/bin/amicus.js +13 -161
  7. package/package.json +6 -4
  8. package/scripts/postinstall.js +16 -7
  9. package/skills/second-opinion/COUNCIL-DESIGN.md +34 -34
  10. package/skills/second-opinion/MODEL-NOTES.md +23 -17
  11. package/skills/second-opinion/SKILL.md +77 -45
  12. package/src/cli-handlers-council.js +59 -0
  13. package/src/cli-handlers-doctor.js +173 -0
  14. package/src/cli-handlers-run.js +196 -0
  15. package/src/cli-handlers.js +1 -1
  16. package/src/cli.js +11 -2
  17. package/src/council/findings.js +48 -0
  18. package/src/council/ledger.js +82 -0
  19. package/src/council/tally.js +108 -0
  20. package/src/council/verdict.js +48 -0
  21. package/src/headless.js +43 -149
  22. package/src/mcp-server.js +6 -0
  23. package/src/sidecar/budget.js +83 -0
  24. package/src/sidecar/conversation-mirror.js +128 -0
  25. package/src/sidecar/fanout-leg.js +4 -1
  26. package/src/sidecar/fanout.js +34 -7
  27. package/src/sidecar/interactive-mirror.js +66 -0
  28. package/src/sidecar/interactive.js +35 -21
  29. package/src/sidecar/models.js +10 -9
  30. package/src/sidecar/session-finalize.js +26 -0
  31. package/src/sidecar/session-utils.js +5 -5
  32. package/src/sidecar/setup.js +2 -2
  33. package/src/sidecar/start.js +19 -6
  34. package/src/utils/activity-poller.js +47 -0
  35. package/src/utils/alias-resolver.js +1 -1
  36. package/src/utils/config.js +4 -4
  37. package/src/utils/error-doc.js +55 -0
  38. package/src/utils/lifecycle.js +1 -1
  39. package/src/utils/model-catalog.js +1 -1
  40. package/src/utils/pricing.js +93 -0
  41. package/src/utils/result-schema.js +21 -2
  42. package/src/utils/session-abort.js +40 -13
  43. package/src/utils/validators.js +17 -17
  44. /package/{skill → skills/sidecar}/SKILL.md +0 -0
@@ -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 errorWave(options.waveId, validated.error); }
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
- const electronProcess = spawn(electronPath, [
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
- // 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
- };
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
- const originalResolve = resolve;
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
- originalResolve(result);
220
+ resolve(result);
207
221
  });
208
222
  });
209
223
  }
@@ -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 (curated aliases marked)
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
- return Number.isNaN(n) ? '—' : (n * 1e6).toFixed(2);
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 (defaults only — the curated view) */
40
+ /** alias marks: id → comma-joined alias names (effective user aliases) */
40
41
  function aliasMarks() {
41
- const { getDefaultAliases } = require('../utils/config');
42
+ const { getEffectiveAliases } = require('../utils/config');
42
43
  const map = new Map();
43
- for (const [alias, model] of Object.entries(getDefaultAliases())) {
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
- // Curated (alias-marked) rows first, then the rest.
63
- const curated = filtered.filter(m => marks.has(m.id));
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 [...curated, ...rest]) {
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 complete', { taskId: metadata.taskId });
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(`[sidecar] ${ts} | ${progress.messages} messages | ${progress.latest}\n`);
125
+ process.stderr.write(`[amicus] ${ts} | ${progress.messages} messages | ${progress.latest}\n`);
126
126
  } else {
127
- process.stderr.write(`[sidecar] still running... ${ts} elapsed\n`);
127
+ process.stderr.write(`[amicus] still running... ${ts} elapsed\n`);
128
128
  }
129
129
  }, interval);
130
130
 
@@ -168,7 +168,7 @@ async function runReadlineSetup() {
168
168
 
169
169
  try {
170
170
  console.log('');
171
- console.log('=== Sidecar Setup Wizard ===');
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: sidecar setup');
184
+ console.log('Set OPENROUTER_API_KEY to get started, or run: amicus setup');
185
185
  }
186
186
  console.log('');
187
187
 
@@ -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
- // Mark error results as 'error' instead of 'complete'
222
- if (result && result.error) {
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: result.error });
229
+ logger.error('Session completed with error', { taskId, error: meta.reason });
228
230
  } else {
229
- finalizeSession(sessDir, summary, effectiveProject, meta, { quietStdout: json });
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: sidecar setup --add-alias ${alias}=provider/model`
70
+ `Fix with: amicus setup --add-alias ${alias}=provider/model`
71
71
  );
72
72
  }
73
73
 
@@ -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 'sidecar setup'
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 'sidecar setup' to configure aliases.`
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 \'sidecar setup\' to set a default model.'
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 'sidecar setup' to fix configuration.`
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 };
@@ -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 };