amicus 1.9.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +149 -0
  3. package/README.md +40 -170
  4. package/bin/amicus.js +14 -20
  5. package/commands/council.md +3 -1
  6. package/electron/fold.js +10 -1
  7. package/electron/ipc-setup.js +10 -15
  8. package/electron/main.js +21 -16
  9. package/electron/preload-setup.js +0 -1
  10. package/electron/setup-ui-council.js +64 -10
  11. package/electron/setup-ui-styles.js +34 -3
  12. package/electron/setup-ui.js +44 -12
  13. package/package.json +2 -5
  14. package/skills/second-opinion/MODEL-NOTES.md +2 -2
  15. package/skills/second-opinion/SKILL.md +24 -23
  16. package/skills/sidecar/SKILL.md +3 -3
  17. package/src/cli-handlers-council.js +101 -1
  18. package/src/cli-handlers-doctor.js +7 -0
  19. package/src/cli-handlers-run.js +4 -4
  20. package/src/cli-handlers-spend.js +198 -0
  21. package/src/cli.js +35 -0
  22. package/src/council/presets-cli.js +141 -0
  23. package/src/headless.js +146 -38
  24. package/src/index.js +1 -9
  25. package/src/mcp-server.js +132 -108
  26. package/src/mcp-tools.js +27 -3
  27. package/src/mcp-wait.js +8 -5
  28. package/src/opencode-client.js +33 -10
  29. package/src/prompt-builder.js +32 -11
  30. package/src/session-manager.js +7 -14
  31. package/src/sidecar/continue.js +12 -5
  32. package/src/sidecar/conversation-mirror.js +22 -1
  33. package/src/sidecar/crash-handler.js +2 -1
  34. package/src/sidecar/fanout-leg.js +12 -3
  35. package/src/sidecar/fanout.js +27 -10
  36. package/src/sidecar/interactive-process.js +6 -17
  37. package/src/sidecar/interactive.js +5 -6
  38. package/src/sidecar/models.js +33 -4
  39. package/src/sidecar/progress.js +2 -1
  40. package/src/sidecar/read.js +4 -6
  41. package/src/sidecar/resume.js +19 -4
  42. package/src/sidecar/session-finalize.js +2 -1
  43. package/src/sidecar/session-utils.js +13 -35
  44. package/src/sidecar/setup-window.js +2 -3
  45. package/src/sidecar/start.js +22 -7
  46. package/src/utils/abort-coordinator.js +57 -7
  47. package/src/utils/api-key-store.js +2 -13
  48. package/src/utils/config.js +30 -43
  49. package/src/utils/council-presets.js +87 -0
  50. package/src/utils/env-loader.js +1 -2
  51. package/src/utils/fold-marker.js +79 -0
  52. package/src/utils/idle-watchdog.js +9 -12
  53. package/src/utils/lifecycle.js +1 -1
  54. package/src/utils/mcp-discovery.js +29 -5
  55. package/src/utils/mcp-self-identity.js +12 -5
  56. package/src/utils/model-catalog.js +54 -6
  57. package/src/utils/read-slice.js +73 -0
  58. package/src/utils/remediation-hints.js +9 -0
  59. package/src/utils/result-schema.js +8 -2
  60. package/src/utils/session-abort.js +1 -1
  61. package/src/utils/session-index-tmp-sweep.js +80 -0
  62. package/src/utils/session-index.js +4 -5
  63. package/src/utils/session-path.js +6 -10
  64. package/src/utils/shared-server.js +7 -5
  65. package/src/utils/spend-ledger.js +80 -0
  66. package/src/utils/updater.js +2 -3
  67. package/src/utils/env-compat.js +0 -38
@@ -8,7 +8,7 @@
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
10
  const { safeSessionDir, TASK_ID_PATTERN } = require('../utils/validators');
11
- const { SESSIONS_DIR, LEGACY_SESSIONS_DIR } = require('../session-manager');
11
+ const { SESSIONS_DIR } = require('../session-manager');
12
12
  const { fenceSidecarOutput } = require('../utils/untrusted-fence');
13
13
 
14
14
  /**
@@ -31,18 +31,16 @@ function formatAge(dateStr) {
31
31
  }
32
32
 
33
33
  /**
34
- * Enumerate sessions across canonical + legacy roots (dedup, amicus wins).
34
+ * Enumerate sessions under the canonical amicus_sessions root.
35
35
  * @param {string} project
36
36
  * @param {{status?: string}} [opts] - status filter ('running', etc.); omit/'all' for all
37
37
  * @returns {Array<{id, model, status, agent, briefing, createdAt}>}
38
38
  */
39
39
  function enumerateSessions(project, opts = {}) {
40
- const roots = [SESSIONS_DIR, LEGACY_SESSIONS_DIR]
41
- .map(d => path.join(project, '.claude', d))
42
- .filter(fs.existsSync);
40
+ const root = path.join(project, '.claude', SESSIONS_DIR);
43
41
 
44
42
  const byId = new Map();
45
- for (const root of roots) {
43
+ if (fs.existsSync(root)) {
46
44
  for (const d of fs.readdirSync(root)) {
47
45
  if (!TASK_ID_PATTERN.test(d)) { continue; }
48
46
  if (byId.has(d)) { continue; }
@@ -6,6 +6,7 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
 
9
+ const { writeFileAtomic } = require('../utils/atomic-write');
9
10
  const { runInteractive, buildMcpConfig } = require('./start');
10
11
  const {
11
12
  SessionPaths,
@@ -16,6 +17,7 @@ const {
16
17
  } = require('./session-utils');
17
18
  const { acquireLock, releaseLock } = require('../utils/session-lock');
18
19
  const { runHeadless } = require('../headless');
20
+ const { extractNonceFromText, generateFoldNonce } = require('../utils/fold-marker');
19
21
  const { logger } = require('../utils/logger');
20
22
 
21
23
  /** Load session metadata from session directory */
@@ -106,7 +108,7 @@ function updateSessionStatus(sessionDir, status) {
106
108
  if (status === 'running') {
107
109
  meta.resumedAt = new Date().toISOString();
108
110
  }
109
- fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
111
+ writeFileAtomic(metaPath, JSON.stringify(meta, null, 2));
110
112
  return meta;
111
113
  }
112
114
 
@@ -159,6 +161,18 @@ async function resumeSidecar(options) {
159
161
  logger.warn('Files changed since last activity', { taskId, changedFileCount: drift.changedFiles.length });
160
162
  }
161
163
 
164
+ // 15b.3: resume re-sends the ORIGINAL prompt text verbatim (unlike
165
+ // continue, which builds a fresh one) — that prompt already instructed
166
+ // the model with the nonce baked in at the initial start/continue time.
167
+ // Recover it from the saved text so the detector agrees with what the
168
+ // model was actually told. A session saved before 15b.3 shipped (or any
169
+ // prompt that somehow lost its marker instruction) has no nonce to
170
+ // recover — generate a fresh one so resume still gets nonce protection,
171
+ // even though the OLD prompt text won't mention it (that just means this
172
+ // resumed run can only complete via a non-fold-marker path, same as any
173
+ // other run whose prompt and detector nonce happen to mismatch).
174
+ const foldNonce = extractNonceFromText(systemPrompt) || generateFoldNonce();
175
+
162
176
  // Update metadata (get updated metadata with resumedAt)
163
177
  const updatedMetadata = updateSessionStatus(sessionDir, 'running');
164
178
 
@@ -179,7 +193,7 @@ async function resumeSidecar(options) {
179
193
  const userMessage = buildResumeUserMessage(metadata.briefing || '', existingConversation);
180
194
  result = await runHeadless(
181
195
  metadata.model, resumePrompt, userMessage,
182
- taskId, project, timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers }
196
+ taskId, project, timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers, nonce: foldNonce }
183
197
  );
184
198
  summary = result.summary || '## Sidecar Results: No Output\n\nResumed session completed without summary.';
185
199
 
@@ -196,7 +210,8 @@ async function resumeSidecar(options) {
196
210
  isResume: true,
197
211
  conversation: existingConversation,
198
212
  opencodeSessionId: metadata.opencodeSessionId,
199
- mcp: mcpServers
213
+ mcp: mcpServers,
214
+ foldNonce
200
215
  }
201
216
  );
202
217
  summary = result.summary || '';
@@ -216,7 +231,7 @@ async function resumeSidecar(options) {
216
231
  updatedMetadata.status = 'error';
217
232
  updatedMetadata.reason = (result && result.error) ? String(result.error) : 'Incomplete';
218
233
  updatedMetadata.completedAt = new Date().toISOString();
219
- fs.writeFileSync(metaPath, JSON.stringify(updatedMetadata, null, 2), { mode: 0o600 });
234
+ writeFileAtomic(metaPath, JSON.stringify(updatedMetadata, null, 2), { mode: 0o600 });
220
235
  logger.error('Resume completed with error', { taskId, error: updatedMetadata.reason });
221
236
  } else {
222
237
  finalizeSession(sessionDir, summary, project, updatedMetadata, { status: terminal.status });
@@ -42,6 +42,7 @@ function finalizeHeadlessResult(sessionDir, result, project, metadata) {
42
42
  const fs = require('fs');
43
43
  const path = require('path');
44
44
  const { finalizeSession, SessionPaths } = require('./session-utils');
45
+ const { writeFileAtomic } = require('../utils/atomic-write');
45
46
 
46
47
  const terminal = resolveTerminalState(result);
47
48
  if (terminal.status === 'error') {
@@ -51,7 +52,7 @@ function finalizeHeadlessResult(sessionDir, result, project, metadata) {
51
52
  metadata.status = 'error';
52
53
  metadata.reason = (result && result.error) ? String(result.error) : 'Incomplete';
53
54
  metadata.completedAt = new Date().toISOString();
54
- fs.writeFileSync(
55
+ writeFileAtomic(
55
56
  path.join(sessionDir, 'metadata.json'),
56
57
  JSON.stringify(metadata, null, 2),
57
58
  { mode: 0o600 }
@@ -9,6 +9,11 @@ const path = require('path');
9
9
  const { detectConflicts, formatConflictWarning } = require('../conflict');
10
10
  const { logger } = require('../utils/logger');
11
11
  const { fenceSidecarOutput } = require('../utils/untrusted-fence');
12
+ const { writeFileAtomic } = require('../utils/atomic-write');
13
+ // isProcessAlive/checkSessionLiveness live in utils/abort-coordinator.js
14
+ // (shared EPERM-aware liveness classification with isAlive); re-exported
15
+ // below for backward-compatible imports.
16
+ const { isAlive: isProcessAlive, checkSessionLiveness } = require('../utils/abort-coordinator');
12
17
  const {
13
18
  SESSIONS_DIR,
14
19
  getSessionDir,
@@ -31,9 +36,8 @@ const SessionPaths = {
31
36
  },
32
37
 
33
38
  /**
34
- * Resolve an EXISTING session directory for READS: prefer amicus, fall back
35
- * to legacy `sidecar_sessions` (backward-compat shim). Use this when
36
- * resuming/continuing an existing session so pre-rebrand sessions are found.
39
+ * Resolve an EXISTING session directory for READS. Use this when
40
+ * resuming/continuing an existing session.
37
41
  */
38
42
  resolveSessionDir(project, taskId) {
39
43
  return resolveExistingSessionDir(project, taskId);
@@ -100,7 +104,7 @@ function finalizeSession(sessionDir, summary, project, metadata, opts = {}) {
100
104
  const hasSummary = typeof summary === 'string' && summary.trim().length > 0;
101
105
  metadata.status = opts.status || (hasSummary ? 'complete' : 'error');
102
106
  metadata.completedAt = new Date().toISOString();
103
- fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
107
+ writeFileAtomic(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
104
108
 
105
109
  logger.info('Session finalized', { taskId: metadata.taskId, status: metadata.status });
106
110
  }
@@ -248,43 +252,17 @@ async function startOpenCodeServer(mcpConfig, options = {}) {
248
252
 
249
253
  const ready = await waitForServer(client, checkHealth);
250
254
  if (!ready) {
251
- server.close();
255
+ // Fire-and-forget: today close() is sync (Promise.resolve wraps a
256
+ // non-promise harmlessly); once close() becomes async (bounded
257
+ // kill-escalation poll) this guard prevents an unhandled rejection
258
+ // from racing the throw below.
259
+ Promise.resolve(server.close()).catch(() => {});
252
260
  throw new Error('OpenCode server failed to become ready');
253
261
  }
254
262
 
255
263
  return { client, server };
256
264
  }
257
265
 
258
- /**
259
- * Check if a process with the given PID is still alive.
260
- * @param {number|null} pid
261
- * @returns {boolean}
262
- */
263
- function isProcessAlive(pid) {
264
- if (!pid) { return false; }
265
- try {
266
- process.kill(pid, 0);
267
- return true;
268
- } catch {
269
- return false;
270
- }
271
- }
272
-
273
- /**
274
- * Check if a session's processes are alive.
275
- * @param {Object} metadata - Session metadata with pid and goPid
276
- * @returns {'alive'|'server-dead'|'dead'}
277
- */
278
- function checkSessionLiveness(metadata) {
279
- if (!metadata) { return 'dead'; }
280
- const nodeAlive = isProcessAlive(metadata.pid);
281
- const goAlive = isProcessAlive(metadata.goPid);
282
-
283
- if (nodeAlive && goAlive) { return 'alive'; }
284
- if (nodeAlive && !goAlive) { return 'server-dead'; }
285
- return 'dead';
286
- }
287
-
288
266
  module.exports = {
289
267
  HEARTBEAT_INTERVAL,
290
268
  SessionPaths,
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Setup Window Launcher
3
3
  *
4
- * Spawns the Electron window in setup mode (SIDECAR_MODE=setup)
4
+ * Spawns the Electron window in setup mode (AMICUS_MODE=setup)
5
5
  * for API key configuration. Waits for the window to close and
6
6
  * returns whether setup completed successfully.
7
7
  */
@@ -11,7 +11,6 @@ const path = require('path');
11
11
  const { logger } = require('../utils/logger');
12
12
  const { getElectronPath } = require('./interactive-process');
13
13
  const { ensureElectron } = require('./electron-ensure');
14
- const { getCompatEnv } = require('../utils/env-compat');
15
14
 
16
15
  /**
17
16
  * Launch the Electron setup window for API key entry.
@@ -35,7 +34,7 @@ async function launchSetupWindow() {
35
34
  AMICUS_MODE: 'setup'
36
35
  };
37
36
 
38
- const debugPort = getCompatEnv('DEBUG_PORT');
37
+ const debugPort = process.env.AMICUS_DEBUG_PORT;
39
38
  const args = debugPort
40
39
  ? [`--remote-debugging-port=${debugPort}`, mainPath]
41
40
  : [mainPath];
@@ -6,6 +6,7 @@
6
6
  const crypto = require('crypto');
7
7
  const fs = require('fs');
8
8
 
9
+ const { writeFileAtomic } = require('../utils/atomic-write');
9
10
  const { buildContext } = require('./context-builder');
10
11
  const {
11
12
  SessionPaths,
@@ -25,6 +26,7 @@ const { loadMcpConfig, parseMcpSpec } = require('../opencode-client');
25
26
  const { mapAgentToOpenCode } = require('../utils/agent-mapping');
26
27
  const { discoverParentMcps } = require('../utils/mcp-discovery');
27
28
  const { stripSelfMcpEntries } = require('../utils/mcp-self-identity');
29
+ const { generateFoldNonce } = require('../utils/fold-marker');
28
30
 
29
31
  /** Generate a unique 8-character hex task ID */
30
32
  function generateTaskId() {
@@ -66,7 +68,7 @@ function createSessionMetadata(taskId, project, options) {
66
68
  createdAt: existing.createdAt || new Date().toISOString()
67
69
  };
68
70
 
69
- fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
71
+ writeFileAtomic(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
70
72
 
71
73
  return sessionDir;
72
74
  }
@@ -163,6 +165,12 @@ async function startSidecar(options) {
163
165
  });
164
166
  const taskId = options.taskId || generateTaskId();
165
167
  const reasoning = thinking ? { effort: thinking } : undefined;
168
+ // 15b.3: one nonce per run, generated BEFORE prompt construction so the
169
+ // SAME value can be baked into the prompt's instruction (buildPrompts) and
170
+ // handed to the detector (runHeadless.options.nonce / the GUI fold writer
171
+ // via env). Harmless to generate even for the interactive path — buildPrompts
172
+ // only consumes it in headless mode.
173
+ const foldNonce = generateFoldNonce();
166
174
 
167
175
  logger.info('Starting task', { taskId, model, mode: effectiveHeadless ? 'headless' : 'interactive' });
168
176
 
@@ -170,7 +178,7 @@ async function startSidecar(options) {
170
178
  ? buildContext(effectiveProject, effectiveSession, { contextTurns, contextSince, contextMaxTokens, sessionDir, client, coworkProcess })
171
179
  : '[Context excluded by caller - briefing is self-contained]';
172
180
  const { system: systemPrompt, userMessage } = buildPrompts(
173
- effectivePrompt, context, effectiveProject, effectiveHeadless, agent, summaryLength, client
181
+ effectivePrompt, context, effectiveProject, effectiveHeadless, agent, summaryLength, client, foldNonce
174
182
  );
175
183
 
176
184
  const sessDir = createSessionMetadata(taskId, effectiveProject, {
@@ -188,7 +196,8 @@ async function startSidecar(options) {
188
196
  try {
189
197
  result = await runHeadless(
190
198
  model, systemPrompt, userMessage, taskId, effectiveProject,
191
- timeout * 60 * 1000, agent || 'build', { mcp: mcpServers, summaryLength, reasoning, port: opencodePort }
199
+ timeout * 60 * 1000, agent || 'build',
200
+ { mcp: mcpServers, summaryLength, reasoning, port: opencodePort, nonce: foldNonce }
192
201
  );
193
202
  } catch (err) {
194
203
  if (!json) { throw err; }
@@ -204,7 +213,7 @@ async function startSidecar(options) {
204
213
  logger.info('Launching interactive sidecar', { taskId, model, agent: effectiveAgent });
205
214
  result = await runInteractive(
206
215
  model, systemPrompt, userMessage, taskId, effectiveProject,
207
- { agent, mcp: mcpServers, reasoning, client, windowPosition: position }
216
+ { agent, mcp: mcpServers, reasoning, client, windowPosition: position, foldNonce }
208
217
  );
209
218
  summary = result.summary || '';
210
219
  if (result.error) { logger.error('Interactive task error', { taskId, error: result.error }); }
@@ -221,7 +230,7 @@ async function startSidecar(options) {
221
230
  // Persist OpenCode session ID for resume capability
222
231
  if (result && result.opencodeSessionId) {
223
232
  meta.opencodeSessionId = result.opencodeSessionId;
224
- fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
233
+ writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
225
234
  }
226
235
 
227
236
  // Map the run result to a definitive terminal status + exit code (single source of truth).
@@ -231,7 +240,7 @@ async function startSidecar(options) {
231
240
  meta.status = 'error';
232
241
  meta.reason = (result && result.error) ? String(result.error) : 'Incomplete';
233
242
  meta.completedAt = new Date().toISOString();
234
- fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
243
+ writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
235
244
  logger.error('Session completed with error', { taskId, error: meta.reason });
236
245
  } else {
237
246
  // complete / timed-out / aborted: persist the (possibly partial) summary with the correct status.
@@ -243,7 +252,13 @@ async function startSidecar(options) {
243
252
  if (runUsage) {
244
253
  const m = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
245
254
  m.usage = runUsage;
246
- fs.writeFileSync(metaPath, JSON.stringify(m, null, 2), { mode: 0o600 });
255
+ writeFileAtomic(metaPath, JSON.stringify(m, null, 2), { mode: 0o600 });
256
+ // B24: cross-run spend ledger. Best-effort — appendSpend never throws, but
257
+ // this run's own success must never hinge on ledger bookkeeping either way.
258
+ try {
259
+ const { appendSpend } = require('../utils/spend-ledger');
260
+ appendSpend({ taskId, model, mode: effectiveHeadless ? 'headless' : 'interactive', usage: runUsage });
261
+ } catch { /* best-effort */ }
247
262
  }
248
263
 
249
264
  if (json) {
@@ -54,18 +54,36 @@ function killPidBestEffort(pid, kill = process.kill.bind(process)) {
54
54
  }
55
55
  }
56
56
 
57
+ /** SIGKILL a pid, swallowing ESRCH. @returns {boolean} signal was sent */
58
+ function killPidHard(pid, kill = process.kill.bind(process)) {
59
+ if (!pid) { return false; }
60
+ try { kill(pid, 'SIGKILL'); return true; } catch (err) {
61
+ if (err.code !== 'ESRCH') {
62
+ logger.warn('Failed to force-kill process', { pid, error: err.message });
63
+ }
64
+ return false;
65
+ }
66
+ }
67
+
57
68
  /**
58
69
  * Wait up to graceMs for the pids to exit on their own (marker-honoring
59
70
  * teardown), then SIGTERM any survivor. Early-exits as soon as every target
60
71
  * is gone, so a process that honors the marker in ~2s never sees a signal.
61
72
  *
73
+ * Opt-in escalation (B06): pass `escalate` to additionally wait up to
74
+ * `escalate.killGraceMs` after the SIGTERM for the survivor(s) to exit, then
75
+ * SIGKILL anyone still alive. Same REF'd poll cadence (`pollMs`) as the TERM
76
+ * tier. A pid that was already dead at entry, or that exits during the TERM
77
+ * grace window, is never escalated — escalation only applies to the set that
78
+ * was actually SIGTERM'd and remained alive.
79
+ *
62
80
  * NOTE: the poll timer is deliberately REF'D. The CLI awaits this call and
63
81
  * must stay alive through the grace window; callers that must not block
64
82
  * (MCP handler) fire-and-forget the returned promise instead.
65
83
  *
66
84
  * @param {number|null|Array<number|null>} pids
67
- * @param {{graceMs?:number, pollMs?:number, deps?:{kill?:Function, sleep?:Function}}} [opts]
68
- * @returns {Promise<{killed:number[], exited:number[]}>}
85
+ * @param {{graceMs?:number, pollMs?:number, escalate?:{killGraceMs?:number}, deps?:{kill?:Function, sleep?:Function}}} [opts]
86
+ * @returns {Promise<{killed:number[], exited:number[], escalated:number[]}>}
69
87
  */
70
88
  async function waitThenKill(pids, opts = {}) {
71
89
  const graceMs = opts.graceMs !== undefined ? opts.graceMs : abortGraceMs();
@@ -82,10 +100,42 @@ async function waitThenKill(pids, opts = {}) {
82
100
  remaining = remaining.filter((pid) => isAlive(pid, kill));
83
101
  }
84
102
  const killed = remaining.filter((pid) => killPidBestEffort(pid, kill));
85
- return {
86
- killed,
87
- exited: targets.filter((pid) => !remaining.includes(pid)),
88
- };
103
+ const exited = targets.filter((pid) => !remaining.includes(pid));
104
+
105
+ let escalated = [];
106
+ if (opts.escalate && killed.length > 0) {
107
+ const killGraceMs = opts.escalate.killGraceMs !== undefined ? opts.escalate.killGraceMs : 2000;
108
+ const killDeadline = Date.now() + killGraceMs;
109
+ let stillAlive = killed.filter((pid) => isAlive(pid, kill));
110
+ while (stillAlive.length > 0 && Date.now() < killDeadline) {
111
+ await sleep(pollMs);
112
+ stillAlive = stillAlive.filter((pid) => isAlive(pid, kill));
113
+ }
114
+ // Anyone SIGTERM'd that isn't in the final stillAlive set exited on its
115
+ // own during the kill-grace window; anyone left gets SIGKILL'd here.
116
+ escalated = stillAlive.filter((pid) => killPidHard(pid, kill));
117
+ for (const pid of killed) { exited.push(pid); }
118
+ }
119
+
120
+ return opts.escalate ? { killed, exited, escalated } : { killed, exited };
121
+ }
122
+
123
+ /**
124
+ * Check if a session's Node/Go process pair is alive. Thin wrapper over
125
+ * isAlive — kept here (not sidecar/session-utils.js) so both liveness
126
+ * checks share one EPERM-aware classification. session-utils.js re-exports
127
+ * this for backward-compatible imports.
128
+ * @param {Object} metadata - Session metadata with pid and goPid
129
+ * @returns {'alive'|'server-dead'|'dead'}
130
+ */
131
+ function checkSessionLiveness(metadata) {
132
+ if (!metadata) { return 'dead'; }
133
+ const nodeAlive = isAlive(metadata.pid);
134
+ const goAlive = isAlive(metadata.goPid);
135
+
136
+ if (nodeAlive && goAlive) { return 'alive'; }
137
+ if (nodeAlive && !goAlive) { return 'server-dead'; }
138
+ return 'dead';
89
139
  }
90
140
 
91
- module.exports = { abortGraceMs, isAlive, killPidBestEffort, waitThenKill };
141
+ module.exports = { abortGraceMs, isAlive, killPidBestEffort, killPidHard, waitThenKill, checkSessionLiveness };
@@ -5,7 +5,6 @@
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const { validateApiKey, validateOpenRouterKey, VALIDATION_ENDPOINTS } = require('./api-key-validation');
8
- const { getCompatEnv } = require('./env-compat');
9
8
 
10
9
  /** Maps provider IDs to environment variable names */
11
10
  const PROVIDER_ENV_MAP = {
@@ -23,7 +22,7 @@ const LEGACY_KEY_NAMES = {
23
22
 
24
23
  /** Get the path to the .env file */
25
24
  function getEnvPath() {
26
- const envDir = getCompatEnv('ENV_DIR');
25
+ const envDir = process.env.AMICUS_ENV_DIR;
27
26
  if (envDir) {
28
27
  const resolved = path.resolve(envDir);
29
28
  if (resolved.includes('\0')) {
@@ -32,17 +31,7 @@ function getEnvPath() {
32
31
  return path.join(resolved, '.env');
33
32
  }
34
33
  const homeDir = process.env.HOME || process.env.USERPROFILE;
35
- const amicusEnvPath = path.join(homeDir, '.config', 'amicus', '.env');
36
- // DEPRECATED(amicus-shim): fall back to the legacy ~/.config/sidecar/.env if it
37
- // exists and the new one does not, so pre-rebrand installs keep reading/writing
38
- // their existing keys. Remove in a future revision — see docs/SHIMS.md.
39
- if (!fs.existsSync(amicusEnvPath)) {
40
- const legacyEnvPath = path.join(homeDir, '.config', 'sidecar', '.env');
41
- if (fs.existsSync(legacyEnvPath)) {
42
- return legacyEnvPath;
43
- }
44
- }
45
- return amicusEnvPath;
34
+ return path.join(homeDir, '.config', 'amicus', '.env');
46
35
  }
47
36
 
48
37
  /** Parse a .env file into a key-value map (comments/blanks excluded) */
@@ -9,15 +9,17 @@ const fs = require('fs');
9
9
  const path = require('path');
10
10
  const crypto = require('crypto');
11
11
  const { applyDirectApiFallback, autoRepairAlias } = require('./alias-resolver');
12
- const { getCompatEnv } = require('./env-compat');
13
12
 
14
13
  /** Default model alias map — derived from the curated-models single source (F5) */
15
14
  const { toDefaultAliases } = require('./curated-models');
16
15
  const DEFAULT_ALIASES = toDefaultAliases();
17
16
 
17
+ /** Built-in council benches (B23) — consulted only when a name is absent from user config. */
18
+ const { resolveBuiltinCouncil } = require('./council-presets');
19
+
18
20
  /** @returns {string} Config directory path */
19
21
  function getConfigDir() {
20
- const override = getCompatEnv('CONFIG_DIR');
22
+ const override = process.env.AMICUS_CONFIG_DIR;
21
23
  if (override) {
22
24
  const resolved = path.resolve(override);
23
25
  if (resolved.includes('\0')) {
@@ -26,45 +28,7 @@ function getConfigDir() {
26
28
  return resolved;
27
29
  }
28
30
  const homeDir = process.env.HOME || process.env.USERPROFILE;
29
- const amicusDir = path.join(homeDir, '.config', 'amicus');
30
- // DEPRECATED(amicus-shim): fall back to the legacy ~/.config/sidecar dir if it
31
- // exists and the new one does not, so pre-rebrand credentials keep working.
32
- // Remove in a future revision — see docs/SHIMS.md.
33
- if (!fs.existsSync(amicusDir)) {
34
- const legacyDir = path.join(homeDir, '.config', 'sidecar');
35
- if (fs.existsSync(legacyDir)) {
36
- return legacyDir;
37
- }
38
- }
39
- return amicusDir;
40
- }
41
-
42
- /**
43
- * One-time, non-destructive migration of the legacy ~/.config/sidecar config
44
- * directory onto the canonical ~/.config/amicus. Copies (does not move), so the
45
- * legacy dir is left intact as a backup. This collapses the two-dir split that
46
- * let getConfigDir() flip between them and orphan data: once ~/.config/amicus
47
- * exists it always wins. No-op when amicus already exists, when there is no
48
- * legacy dir, or when a CONFIG_DIR override is set. Best-effort — returns a
49
- * result object and never throws. Call once at startup, before any config read.
50
- *
51
- * @param {{home?: string}} [opts]
52
- * @returns {{migrated: boolean, from?: string, to?: string, reason?: string, error?: string}}
53
- */
54
- function migrateLegacyConfigDir(opts = {}) {
55
- if (getCompatEnv('CONFIG_DIR')) { return { migrated: false, reason: 'override-set' }; }
56
- const home = opts.home || process.env.HOME || process.env.USERPROFILE;
57
- if (!home) { return { migrated: false, reason: 'no-home' }; }
58
- const amicusDir = path.join(home, '.config', 'amicus');
59
- const legacyDir = path.join(home, '.config', 'sidecar');
60
- try {
61
- if (fs.existsSync(amicusDir)) { return { migrated: false, reason: 'amicus-exists' }; }
62
- if (!fs.existsSync(legacyDir)) { return { migrated: false, reason: 'no-legacy' }; }
63
- fs.cpSync(legacyDir, amicusDir, { recursive: true });
64
- return { migrated: true, from: legacyDir, to: amicusDir };
65
- } catch (err) {
66
- return { migrated: false, reason: 'error', error: err.message };
67
- }
31
+ return path.join(homeDir, '.config', 'amicus');
68
32
  }
69
33
 
70
34
  /** @returns {string} Full path to config.json */
@@ -312,6 +276,24 @@ function getCouncil(name) {
312
276
  return getCouncils()[name] || null;
313
277
  }
314
278
 
279
+ /**
280
+ * Look up a council's raw member list, checking user config FIRST and the
281
+ * built-in benches (free/budget/frontier — src/utils/council-presets.js)
282
+ * only when the name is absent from user config. User config always shadows
283
+ * a same-named built-in — this matches the pre-existing last-write-wins
284
+ * posture the wizard's `councils.free` seeding already relied on.
285
+ * @param {string} name
286
+ * @param {Array<{id:string}>} [catalog] needed only to resolve the dynamic 'free' bench
287
+ * @returns {{members:string[]|null, builtin:boolean}}
288
+ */
289
+ function getCouncilWithSource(name, catalog = []) {
290
+ const userMembers = getCouncil(name);
291
+ if (userMembers) { return { members: userMembers, builtin: false }; }
292
+ const builtinMembers = resolveBuiltinCouncil(name, catalog);
293
+ if (builtinMembers) { return { members: builtinMembers, builtin: true }; }
294
+ return { members: null, builtin: false };
295
+ }
296
+
315
297
  /**
316
298
  * Expand a saved council into a runnable members list, degrading gracefully.
317
299
  * Each member is resolved to its full model id (alias → id via effective
@@ -320,12 +302,17 @@ function getCouncil(name) {
320
302
  * warning rather than fail-fast-aborting the whole wave. The catalog check is
321
303
  * skipped when the catalog is empty (offline). Returns members RAW (alias or
322
304
  * id) — leg-time validation resolves them again.
305
+ *
306
+ * Resolution order: user config (`config.councils`) is checked first; when
307
+ * `name` is absent there, the built-in benches (`free`/`budget`/`frontier`)
308
+ * are consulted (src/utils/council-presets.js). A user-saved council always
309
+ * shadows a built-in of the same name.
323
310
  * @param {string} name
324
311
  * @param {Array<{id:string}>} [catalog]
325
312
  * @returns {{models:string[], dropped:string[]} | {error:string}}
326
313
  */
327
314
  function resolveCouncilMembers(name, catalog = []) {
328
- const members = getCouncil(name);
315
+ const { members } = getCouncilWithSource(name, catalog);
329
316
  if (!members) {
330
317
  return { error: `Unknown council '${name}'. Run 'amicus setup' to create one.` };
331
318
  }
@@ -354,7 +341,6 @@ function resolveCouncilMembers(name, catalog = []) {
354
341
 
355
342
  module.exports = {
356
343
  getConfigDir,
357
- migrateLegacyConfigDir,
358
344
  getConfigPath,
359
345
  loadConfig,
360
346
  saveConfig,
@@ -370,5 +356,6 @@ module.exports = {
370
356
  buildProviderModels,
371
357
  getCouncils,
372
358
  getCouncil,
359
+ getCouncilWithSource,
373
360
  resolveCouncilMembers,
374
361
  };
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Built-in council benches (B23).
3
+ *
4
+ * `resolveCouncilMembers` (src/utils/config.js) consults this table ONLY when
5
+ * the requested name is absent from user config (`config.councils`) — user
6
+ * config always shadows a built-in of the same name. This preserves today's
7
+ * behavior for the wizard-seeded `councils.free` (src/sidecar/setup.js
8
+ * seedFreeCouncil): once seeded, the user's `free` list wins over the
9
+ * built-in dynamic free bench below.
10
+ *
11
+ * Two shapes:
12
+ * - 'free' is DYNAMIC: resolved at use time from the live catalog via
13
+ * suggestFreeCouncil (one :free model per vendor), falling back to the
14
+ * offline PINNED_FREE_MODELS when the catalog has no free rows. This
15
+ * mirrors the wizard's own free-council derivation so the built-in and
16
+ * the wizard-seeded version pick the same kind of members.
17
+ * - 'budget' and 'frontier' are STATIC: three DEFAULT_ALIASES entries
18
+ * (src/utils/curated-models.js) each, chosen by catalog pricing at
19
+ * implementation time (see docs/CHANGELOG or the task report for the
20
+ * pricing evidence). Alias-based so `amicus models --check` drift
21
+ * tooling and normal alias resolution keep them healthy for free —
22
+ * no raw model ids are hardcoded here.
23
+ */
24
+ 'use strict';
25
+
26
+ const { suggestFreeCouncil, PINNED_FREE_MODELS } = require('./free-models');
27
+
28
+ /**
29
+ * Budget bench: three cheapest DEFAULT_ALIASES entries, one per vendor
30
+ * family, verified against the cached catalog (~/.config/amicus/model-catalog.json)
31
+ * on 2026-07-02 (prices are $/token, prompt+completion):
32
+ * minimax openrouter/minimax/minimax-m2.7 $0.00000018 / $0.00000072
33
+ * qwen-coder openrouter/qwen/qwen3-coder-next $0.00000011 / $0.0000008
34
+ * deepseek openrouter/deepseek/deepseek-v4-pro $0.000000435 / $0.00000087
35
+ * These are the three lowest total (prompt+completion) prices in
36
+ * DEFAULT_ALIASES, and each is a distinct vendor family (MiniMax / Qwen /
37
+ * DeepSeek) — the qwen-flash entry (also cheap) was skipped to keep vendor
38
+ * diversity across the bench.
39
+ */
40
+ const BUDGET_ALIASES = ['minimax', 'qwen-coder', 'deepseek'];
41
+
42
+ /**
43
+ * Frontier bench: three premium-flagship DEFAULT_ALIASES entries, one per
44
+ * vendor family, verified against the same catalog snapshot:
45
+ * gpt-pro openrouter/openai/gpt-5.5-pro $0.00003 / $0.00018
46
+ * opus openrouter/anthropic/claude-opus-4.8 $0.000005 / $0.000025
47
+ * gemini-pro openrouter/google/gemini-3.1-pro-preview $0.000002 / $0.000012
48
+ * These are the three highest total (prompt+completion) prices in
49
+ * DEFAULT_ALIASES that are also each a distinct vendor family (OpenAI /
50
+ * Anthropic / Google) — `gpt` and `codex` (also OpenAI) and `claude`/`sonnet`
51
+ * (also Anthropic) were skipped as same-family duplicates of the pick above.
52
+ */
53
+ const FRONTIER_ALIASES = ['gpt-pro', 'opus', 'gemini-pro'];
54
+
55
+ /**
56
+ * @param {Array} catalog live model-catalog rows (for the dynamic free bench)
57
+ * @returns {string[]} council members (aliases or full ids), possibly empty
58
+ */
59
+ function resolveFreeBench(catalog) {
60
+ const picks = suggestFreeCouncil(Array.isArray(catalog) ? catalog : []);
61
+ if (picks.length > 0) { return picks.map(p => p.id); }
62
+ return [...PINNED_FREE_MODELS];
63
+ }
64
+
65
+ /**
66
+ * @param {string} name
67
+ * @param {Array} [catalog] live model-catalog rows, needed only for 'free'
68
+ * @returns {string[]|null} resolved built-in members, or null if `name` is not a built-in
69
+ */
70
+ function resolveBuiltinCouncil(name, catalog = []) {
71
+ if (name === 'free') { return resolveFreeBench(catalog); }
72
+ if (name === 'budget') { return [...BUDGET_ALIASES]; }
73
+ if (name === 'frontier') { return [...FRONTIER_ALIASES]; }
74
+ return null;
75
+ }
76
+
77
+ /** @returns {string[]} built-in bench names, in resolution-doc order */
78
+ function listBuiltinCouncilNames() {
79
+ return ['free', 'budget', 'frontier'];
80
+ }
81
+
82
+ module.exports = {
83
+ BUDGET_ALIASES,
84
+ FRONTIER_ALIASES,
85
+ resolveBuiltinCouncil,
86
+ listBuiltinCouncilNames,
87
+ };