amicus 1.0.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 (55) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/.claude-plugin/plugin.json +19 -0
  3. package/CHANGELOG.md +86 -0
  4. package/LICENSE +22 -1
  5. package/README.md +14 -3
  6. package/bin/amicus.js +17 -162
  7. package/electron/ipc-setup.js +30 -9
  8. package/electron/main.js +13 -5
  9. package/electron/preload.js +30 -10
  10. package/electron/setup-ui-keys.js +9 -0
  11. package/electron/setup-ui-model.js +33 -23
  12. package/electron/setup-ui-styles.js +6 -1
  13. package/electron/setup-ui.js +91 -38
  14. package/electron/toolbar.js +4 -5
  15. package/package.json +7 -5
  16. package/scripts/postinstall.js +16 -7
  17. package/skills/second-opinion/COUNCIL-DESIGN.md +36 -34
  18. package/skills/second-opinion/MODEL-NOTES.md +23 -17
  19. package/skills/second-opinion/SKILL.md +84 -51
  20. package/{skill → skills/sidecar}/SKILL.md +14 -4
  21. package/src/cli-handlers-council.js +59 -0
  22. package/src/cli-handlers-doctor.js +173 -0
  23. package/src/cli-handlers-run.js +196 -0
  24. package/src/cli-handlers.js +66 -1
  25. package/src/cli.js +16 -2
  26. package/src/council/findings.js +48 -0
  27. package/src/council/ledger.js +82 -0
  28. package/src/council/tally.js +108 -0
  29. package/src/council/verdict.js +48 -0
  30. package/src/headless.js +43 -149
  31. package/src/mcp-server.js +6 -0
  32. package/src/sidecar/budget.js +83 -0
  33. package/src/sidecar/conversation-mirror.js +128 -0
  34. package/src/sidecar/fanout-leg.js +4 -1
  35. package/src/sidecar/fanout.js +34 -7
  36. package/src/sidecar/interactive-mirror.js +66 -0
  37. package/src/sidecar/interactive.js +35 -21
  38. package/src/sidecar/models.js +41 -10
  39. package/src/sidecar/session-finalize.js +26 -0
  40. package/src/sidecar/session-utils.js +5 -5
  41. package/src/sidecar/setup.js +55 -42
  42. package/src/sidecar/start.js +19 -6
  43. package/src/utils/activity-poller.js +47 -0
  44. package/src/utils/alias-resolver.js +1 -1
  45. package/src/utils/config.js +4 -4
  46. package/src/utils/curated-models.js +88 -45
  47. package/src/utils/error-doc.js +55 -0
  48. package/src/utils/lifecycle.js +1 -1
  49. package/src/utils/model-catalog.js +1 -1
  50. package/src/utils/model-fetcher.js +16 -2
  51. package/src/utils/pricing.js +93 -0
  52. package/src/utils/quick-picks.js +81 -0
  53. package/src/utils/result-schema.js +21 -2
  54. package/src/utils/session-abort.js +40 -13
  55. package/src/utils/validators.js +17 -17
@@ -0,0 +1,128 @@
1
+ // src/sidecar/conversation-mirror.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module conversation-mirror
6
+ * Pure transform of an OpenCode getMessages() snapshot into conversation.jsonl
7
+ * append-lines + progress.json updates. Extracted from the headless poll loop so
8
+ * the interactive GUI path can mirror the same way (WS-4 #7). No I/O, no clock
9
+ * except the injectable `now`.
10
+ */
11
+
12
+ /** Fresh cursor for a session's mirror. */
13
+ function createMirrorState() {
14
+ return {
15
+ seenTextParts: new Map(), // partId -> last captured text length
16
+ toolCalls: [], // [{id,name,input}]
17
+ seenToolResultIds: new Set(),
18
+ receivingReported: false,
19
+ output: '', // accumulated assistant text
20
+ usageByMsg: new Map(), // msgId -> {tokens, cost}
21
+ };
22
+ }
23
+
24
+ /**
25
+ * @param {Array} messages getMessages() snapshot
26
+ * @param {object} state from createMirrorState() (mutated + returned)
27
+ * @param {{now?: () => string}} [opts]
28
+ */
29
+ function mirrorMessages(messages, state, opts = {}) {
30
+ const now = opts.now || (() => new Date().toISOString());
31
+ const appendLines = [];
32
+ const progressUpdates = [];
33
+ let currentAssistantMsgId = null;
34
+ let assistantFinished = false;
35
+ let sessionError = null;
36
+ const list = Array.isArray(messages) ? messages : [];
37
+ const messageCount = list.length;
38
+
39
+ for (const msg of list) {
40
+ const role = msg.info && msg.info.role;
41
+
42
+ // Track assistant message state
43
+ if (role === 'assistant') {
44
+ currentAssistantMsgId = msg.info.id;
45
+ if (msg.info.tokens || typeof msg.info.cost === 'number') {
46
+ state.usageByMsg.set(msg.info.id, { tokens: msg.info.tokens, cost: msg.info.cost });
47
+ }
48
+ // Check for errors — capture for result propagation
49
+ if (msg.info.error) {
50
+ sessionError = (msg.info.error.data && msg.info.error.data.message)
51
+ || msg.info.error.name || 'Unknown model error';
52
+ }
53
+ }
54
+
55
+ // Only process parts from assistant messages (skip user messages)
56
+ if (role !== 'assistant' || !msg.parts) { continue; }
57
+
58
+ for (const part of msg.parts) {
59
+ const partId = part.id || `${msg.info.id}:${part.type}:${msg.parts.indexOf(part)}`;
60
+
61
+ if (part.type === 'text' && part.text) {
62
+ const prevLen = state.seenTextParts.get(partId) || 0;
63
+ if (part.text.length > prevLen) {
64
+ // Append only the new portion (handles streaming growth)
65
+ const newText = part.text.slice(prevLen);
66
+ state.output += newText;
67
+ state.seenTextParts.set(partId, part.text.length);
68
+ appendLines.push({ role: 'assistant', content: newText, timestamp: now() });
69
+
70
+ // Report 'receiving' stage on first text detection
71
+ if (!state.receivingReported) {
72
+ state.receivingReported = true;
73
+ progressUpdates.push({ stage: 'receiving', extra: { messagesReceived: 1 } });
74
+ }
75
+ }
76
+ } else if ((part.type === 'tool_use' || part.type === 'tool') && !state.toolCalls.find(t => t.id === part.id)) {
77
+ const toolCall = { id: part.id, name: part.name, input: part.input };
78
+ state.toolCalls.push(toolCall);
79
+ appendLines.push({ role: 'assistant', type: 'tool_use', toolCall, timestamp: now() });
80
+
81
+ // Update progress on tool_use detection
82
+ progressUpdates.push({
83
+ stage: 'receiving',
84
+ extra: {
85
+ messagesReceived: state.toolCalls.length,
86
+ latestTool: part.name || undefined,
87
+ stageLabel: part.name ? `Calling tool: ${part.name}` : 'Executing tool call...',
88
+ },
89
+ });
90
+ state.receivingReported = true;
91
+ } else if (part.type === 'tool_result') {
92
+ // Dedup: append only on first sight (fixes latent double-log bug in headless poll loop)
93
+ if (!state.seenToolResultIds.has(partId)) {
94
+ state.seenToolResultIds.add(partId);
95
+ appendLines.push({
96
+ role: 'tool',
97
+ type: 'tool_result',
98
+ toolUseId: part.tool_use_id,
99
+ isError: part.is_error || false,
100
+ content: part.content,
101
+ timestamp: now(),
102
+ });
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ // assistantFinished = true only when the LAST assistant message is complete
109
+ // (earlier messages may finish while the model continues in new messages)
110
+ const lastAssistant = list.filter(m => m.info && m.info.role === 'assistant').pop();
111
+ assistantFinished = !!(lastAssistant && lastAssistant.info.time && lastAssistant.info.time.completed);
112
+
113
+ return { appendLines, progressUpdates, state, currentAssistantMsgId, assistantFinished, sessionError, messageCount };
114
+ }
115
+
116
+ const fs = require('fs');
117
+
118
+ /**
119
+ * Append one JSONL record to conversation.jsonl (0o600). Relocated here from
120
+ * headless.js so BOTH the headless loop and the interactive mirror import it from
121
+ * one place (no cross-module coupling to headless's 750-line surface).
122
+ * @param {string} conversationPath @param {object} message
123
+ */
124
+ function logMessage(conversationPath, message) {
125
+ fs.appendFileSync(conversationPath, JSON.stringify(message) + '\n', { mode: 0o600 });
126
+ }
127
+
128
+ module.exports = { createMirrorState, mirrorMessages, logMessage };
@@ -87,10 +87,13 @@ async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage,
87
87
  if (summary) {
88
88
  fs.writeFileSync(SessionPaths.summaryFile(legDir), summary, { mode: 0o600 });
89
89
  }
90
+ const { resolveUsage } = require('../utils/pricing');
91
+ const usage = result && result.usage ? resolveUsage({ model: leg.model, usageTotals: result.usage }) : null;
90
92
  const finalMeta = writeLegPatch(legDir, {
91
93
  status,
92
94
  reason: result.error || undefined,
93
95
  completedAt: new Date().toISOString(),
96
+ usage: usage || undefined,
94
97
  });
95
98
  const effectiveResult = finalMeta.status === 'aborted'
96
99
  ? { ...result, aborted: true }
@@ -100,7 +103,7 @@ async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage,
100
103
  }
101
104
  return buildRunResult({
102
105
  taskId: legId, metadata: finalMeta, result: effectiveResult, summary,
103
- modelInput: leg.modelInput, sessionDir: legDir, waveId,
106
+ modelInput: leg.modelInput, sessionDir: legDir, waveId, usage,
104
107
  });
105
108
  }
106
109
 
@@ -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)
@@ -15,14 +15,17 @@
15
15
  const { getCatalogInfo, refreshCatalog, catalogPath } = require('../utils/model-catalog');
16
16
  const { collectAliasSources, findStaleAliases, suggestReplacements } = require('../utils/alias-audit');
17
17
  const { buildCatalogDoc, buildAuditDoc } = require('../utils/result-schema');
18
+ const { getFamilies } = require('../utils/curated-models');
19
+ const { pickCurrent } = require('../utils/quick-picks');
18
20
 
19
21
  const CHECK_EXIT_CAP = 100;
20
22
 
21
- /** '0.000003' per token → '3.00' per Mtok; '—' when unknown */
23
+ /** '0.000003' per token → '3.00' per Mtok; '—' when unknown or variable (-1) */
22
24
  function perMtok(perToken) {
23
25
  if (perToken === null || perToken === undefined) { return '—'; }
24
26
  const n = Number(perToken);
25
- return Number.isNaN(n) ? '—' : (n * 1e6).toFixed(2);
27
+ if (Number.isNaN(n) || n < 0) { return '—'; }
28
+ return (n * 1e6).toFixed(2);
26
29
  }
27
30
 
28
31
  function fmtRow(m, aliasesById) {
@@ -34,11 +37,11 @@ function fmtRow(m, aliasesById) {
34
37
  return `${aliasCol}${m.id}\n ${m.name} ctx ${ctx} $/Mtok in ${pIn} out ${pOut}`;
35
38
  }
36
39
 
37
- /** alias marks: id → comma-joined alias names (defaults only — the curated view) */
40
+ /** alias marks: id → comma-joined alias names (effective user aliases) */
38
41
  function aliasMarks() {
39
- const { getDefaultAliases } = require('../utils/config');
42
+ const { getEffectiveAliases } = require('../utils/config');
40
43
  const map = new Map();
41
- for (const [alias, model] of Object.entries(getDefaultAliases())) {
44
+ for (const [alias, model] of Object.entries(getEffectiveAliases())) {
42
45
  map.set(model, map.has(model) ? `${map.get(model)},${alias}` : alias);
43
46
  }
44
47
  return map;
@@ -57,10 +60,10 @@ async function runList(args) {
57
60
  return 0;
58
61
  }
59
62
  const marks = aliasMarks();
60
- // Curated (alias-marked) rows first, then the rest.
61
- 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));
62
65
  const rest = filtered.filter(m => !marks.has(m.id));
63
- for (const m of [...curated, ...rest]) {
66
+ for (const m of [...marked, ...rest]) {
64
67
  process.stdout.write(fmtRow(m, marks) + '\n');
65
68
  }
66
69
  const when = fetchedAt ? new Date(fetchedAt).toISOString() : 'never';
@@ -105,8 +108,13 @@ async function runCheck(args) {
105
108
  }), null, 2) + '\n');
106
109
  return Math.min(stale.length, CHECK_EXIT_CAP);
107
110
  }
111
+ const driftLines = buildFallbackDriftReport(catalog);
108
112
  if (stale.length === 0) {
109
113
  process.stdout.write(`All aliases resolve to catalog models (${sources.length} checked).\n`);
114
+ if (driftLines.length > 0) {
115
+ process.stdout.write('Pinned fallback drift:\n');
116
+ for (const l of driftLines) { process.stdout.write(l + '\n'); }
117
+ }
110
118
  return 0;
111
119
  }
112
120
  for (const s of stale) {
@@ -118,9 +126,32 @@ async function runCheck(args) {
118
126
  process.stdout.write(' no same-vendor candidates in catalog\n');
119
127
  }
120
128
  }
129
+ if (driftLines.length > 0) {
130
+ process.stdout.write('Pinned fallback drift:\n');
131
+ for (const l of driftLines) { process.stdout.write(l + '\n'); }
132
+ }
121
133
  return Math.min(stale.length, CHECK_EXIT_CAP);
122
134
  }
123
135
 
136
+ /**
137
+ * Non-blocking drift report: pinned family fallbacks vs live resolution.
138
+ * Empty catalog → [] (cannot check). Never affects the exit code.
139
+ * @param {Array<{id:string}>} catalog
140
+ * @returns {string[]} human-readable warning lines
141
+ */
142
+ function buildFallbackDriftReport(catalog) {
143
+ if (!catalog || catalog.length === 0) { return []; }
144
+ const lines = [];
145
+ for (const f of getFamilies()) {
146
+ const live = pickCurrent(catalog, 'openrouter/', f.vendorPath, f.idPattern);
147
+ if (live && f.fallback.openrouter && live !== f.fallback.openrouter) {
148
+ lines.push(
149
+ ` pinned fallback drift: ${f.alias} → ${f.fallback.openrouter} (live: ${live}) — update curated-models.js`);
150
+ }
151
+ }
152
+ return lines;
153
+ }
154
+
124
155
  /** @param {object} args parsed CLI args @returns {Promise<number>} exit code */
125
156
  async function handleModels(args) {
126
157
  if (args.search === true) {
@@ -132,4 +163,4 @@ async function handleModels(args) {
132
163
  return runList(args);
133
164
  }
134
165
 
135
- module.exports = { handleModels };
166
+ module.exports = { handleModels, buildFallbackDriftReport };
@@ -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