amicus 1.9.1 → 2.1.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 (75) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +200 -0
  3. package/README.md +40 -170
  4. package/bin/amicus.js +19 -107
  5. package/commands/council.md +7 -3
  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 +30 -28
  16. package/skills/sidecar/SKILL.md +20 -17
  17. package/src/cli-handlers-abort.js +244 -0
  18. package/src/cli-handlers-council.js +101 -1
  19. package/src/cli-handlers-doctor.js +20 -53
  20. package/src/cli-handlers-resume-continue.js +103 -0
  21. package/src/cli-handlers-run.js +9 -8
  22. package/src/cli-handlers-spend.js +198 -0
  23. package/src/cli-handlers.js +5 -120
  24. package/src/cli.js +55 -0
  25. package/src/council/presets-cli.js +141 -0
  26. package/src/headless.js +146 -38
  27. package/src/index.js +1 -9
  28. package/src/mcp-server.js +140 -113
  29. package/src/mcp-tools.js +58 -24
  30. package/src/mcp-wait.js +8 -5
  31. package/src/opencode-client.js +33 -10
  32. package/src/prompt-builder.js +32 -11
  33. package/src/session-manager.js +7 -14
  34. package/src/sidecar/continue.js +34 -12
  35. package/src/sidecar/conversation-mirror.js +22 -1
  36. package/src/sidecar/crash-handler.js +2 -1
  37. package/src/sidecar/fanout-leg.js +12 -3
  38. package/src/sidecar/fanout.js +27 -10
  39. package/src/sidecar/interactive-process.js +6 -17
  40. package/src/sidecar/interactive.js +5 -6
  41. package/src/sidecar/models.js +33 -4
  42. package/src/sidecar/progress.js +2 -1
  43. package/src/sidecar/read.js +4 -6
  44. package/src/sidecar/resume.js +41 -11
  45. package/src/sidecar/session-finalize.js +2 -1
  46. package/src/sidecar/session-utils.js +13 -35
  47. package/src/sidecar/setup-window.js +2 -3
  48. package/src/sidecar/start.js +22 -7
  49. package/src/utils/abort-coordinator.js +57 -7
  50. package/src/utils/abort-result.js +36 -0
  51. package/src/utils/api-key-store.js +2 -13
  52. package/src/utils/cli-preflight.js +43 -0
  53. package/src/utils/config.js +30 -43
  54. package/src/utils/council-presets.js +87 -0
  55. package/src/utils/doctor-mcp-checks.js +84 -0
  56. package/src/utils/env-loader.js +1 -2
  57. package/src/utils/fold-marker.js +79 -0
  58. package/src/utils/idle-watchdog.js +9 -12
  59. package/src/utils/input-validators.js +52 -1
  60. package/src/utils/lifecycle.js +1 -1
  61. package/src/utils/mcp-discovery.js +80 -19
  62. package/src/utils/mcp-self-identity.js +12 -5
  63. package/src/utils/model-catalog.js +54 -6
  64. package/src/utils/read-slice.js +73 -0
  65. package/src/utils/remediation-hints.js +9 -0
  66. package/src/utils/result-schema-version.js +14 -0
  67. package/src/utils/result-schema.js +18 -12
  68. package/src/utils/session-abort.js +1 -1
  69. package/src/utils/session-index-tmp-sweep.js +80 -0
  70. package/src/utils/session-index.js +4 -5
  71. package/src/utils/session-path.js +6 -10
  72. package/src/utils/shared-server.js +7 -5
  73. package/src/utils/spend-ledger.js +80 -0
  74. package/src/utils/updater.js +2 -3
  75. package/src/utils/env-compat.js +0 -38
@@ -11,7 +11,6 @@ const { startOpenCodeServer } = require('./session-utils');
11
11
  const { createSession, sendPromptAsync, getMessages, abortSession } = require('../opencode-client');
12
12
  const { mapAgentToOpenCode } = require('../utils/agent-mapping');
13
13
  const { logger } = require('../utils/logger');
14
- const { getCompatEnv } = require('../utils/env-compat');
15
14
  const { startInteractiveMirror } = require('./interactive-mirror');
16
15
  const { startAbortWatch, markResultAborted, readAbortedMarker } = require('./interactive-abort');
17
16
  const { getSessionDir } = require('../session-manager');
@@ -34,7 +33,7 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
34
33
  };
35
34
  }
36
35
 
37
- const { agent, isResume, conversation, mcp, reasoning, opencodeSessionId, client } = options;
36
+ const { agent, isResume, conversation, mcp, reasoning, opencodeSessionId, client, foldNonce } = options;
38
37
 
39
38
  // F6c: mirror headless's lifecycle stages (best-effort — a write failure must
40
39
  // never break the GUI) so the heartbeat/status never read "Starting up...".
@@ -101,7 +100,7 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
101
100
  }
102
101
  logger.debug('Interactive session ready', { sessionId, isResume: !!isResume });
103
102
  } catch (error) {
104
- server.close();
103
+ server.close().catch(() => {});
105
104
  return {
106
105
  summary: '', completed: false, timedOut: false, taskId,
107
106
  error: `Session setup failed: ${error.message}`
@@ -157,12 +156,12 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
157
156
  const existingPath = process.env.PATH || '';
158
157
  const env = buildElectronEnv(
159
158
  taskId, model, project, nodeModulesBin, existingPath,
160
- { agent, isResume, conversation, mcp, client, sessionDirectory }
159
+ { agent, isResume, conversation, mcp, client, sessionDirectory, foldNonce }
161
160
  );
162
161
  env.AMICUS_OPENCODE_PORT = serverPort;
163
162
  env.AMICUS_SESSION_ID = sessionId;
164
163
 
165
- const debugPort = getCompatEnv('DEBUG_PORT') || '9222';
164
+ const debugPort = process.env.AMICUS_DEBUG_PORT || '9222';
166
165
  logger.debug('Launching Electron', { taskId, model, debugPort, serverPort, sessionId });
167
166
 
168
167
  electronProcess = spawn(electronPath, [
@@ -200,7 +199,7 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
200
199
  const { usage } = await mirror.stop();
201
200
  if (usage) { result.usage = usage; }
202
201
  } catch (err) { logger.debug('mirror stop failed', { error: err.message }); }
203
- server.close();
202
+ try { await server.close(); } catch { /* best-effort */ }
204
203
  logger.debug('OpenCode server closed after Electron exit');
205
204
  result.opencodeSessionId = sessionId;
206
205
  resolve(result);
@@ -47,15 +47,29 @@ function aliasMarks() {
47
47
  return map;
48
48
  }
49
49
 
50
+ /**
51
+ * #13: a one-line honest memo when the last refresh attempt on record failed
52
+ * AFTER the data currently being shown was fetched — i.e. the cache is stale
53
+ * because refreshing keeps failing, not just because nobody's refreshed lately.
54
+ * @returns {string|null}
55
+ */
56
+ function staleMemo(fetchedAt, lastRefreshAttempt, lastRefreshError) {
57
+ if (!lastRefreshAttempt || !lastRefreshError) { return null; }
58
+ if (fetchedAt && lastRefreshAttempt <= fetchedAt) { return null; }
59
+ const attemptWhen = new Date(lastRefreshAttempt).toISOString();
60
+ const fetchedWhen = fetchedAt ? new Date(fetchedAt).toISOString() : 'never';
61
+ return `⚠ catalog may be stale: last refresh attempt failed ${attemptWhen} (${lastRefreshError}); showing data fetched ${fetchedWhen}`;
62
+ }
63
+
50
64
  async function runList(args) {
51
- const { models, fetchedAt } = await getCatalogInfo();
65
+ const { models, fetchedAt, lastRefreshAttempt, lastRefreshError } = await getCatalogInfo();
52
66
  const q = typeof args.search === 'string' ? args.search.toLowerCase() : null;
53
67
  const filtered = q
54
68
  ? models.filter(m => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q))
55
69
  : models;
56
70
  if (args.json) {
57
71
  process.stdout.write(JSON.stringify(buildCatalogDoc({
58
- models: filtered, fetchedAt, search: q
72
+ models: filtered, fetchedAt, search: q, lastRefreshAttempt, lastRefreshError
59
73
  }), null, 2) + '\n');
60
74
  return 0;
61
75
  }
@@ -71,16 +85,31 @@ async function runList(args) {
71
85
  if (filtered.length === 0 && models.length === 0) {
72
86
  process.stdout.write('Catalog unavailable (offline or first run) — try: amicus models --refresh\n');
73
87
  }
88
+ const memo = staleMemo(fetchedAt, lastRefreshAttempt, lastRefreshError);
89
+ if (memo) { process.stdout.write(memo + '\n'); }
74
90
  return 0;
75
91
  }
76
92
 
77
93
  async function runRefresh(args) {
78
94
  const models = await refreshCatalog();
95
+ const { fetchedAt, lastRefreshAttempt, lastRefreshError } = await getCatalogInfo({ maxAgeMs: Number.POSITIVE_INFINITY });
79
96
  if (args.json) {
80
97
  process.stdout.write(JSON.stringify(buildCatalogDoc({
81
- models, fetchedAt: models.length > 0 ? Date.now() : null, refreshed: true
98
+ models, fetchedAt, refreshed: true, lastRefreshAttempt, lastRefreshError
82
99
  }), null, 2) + '\n');
83
- return 0;
100
+ return models.length === 0 && !fetchedAt ? 1 : 0;
101
+ }
102
+ if (models.length === 0 && lastRefreshError) {
103
+ // Honest failure report — never claim "Refreshed catalog: 0 models" when
104
+ // the refresh actually failed and an old (or no) cache was retained.
105
+ if (fetchedAt) {
106
+ const when = new Date(fetchedAt).toISOString();
107
+ process.stdout.write(`refresh failed (${lastRefreshError}); keeping catalog from ${when}\n`);
108
+ process.stdout.write(`Cache: ${catalogPath()}\n`);
109
+ return 0; // stale-but-served: a warning, not a command failure
110
+ }
111
+ process.stdout.write(`refresh failed (${lastRefreshError}); no cache available\n`);
112
+ return 1; // no cache at all: a real failure
84
113
  }
85
114
  process.stdout.write(`Refreshed catalog: ${models.length} models.\n`);
86
115
  process.stdout.write(`Cache: ${catalogPath()}\n`);
@@ -10,6 +10,7 @@
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
12
  const { latestAssistantPreview } = require('./progress-fields');
13
+ const { writeFileAtomic } = require('../utils/atomic-write');
13
14
 
14
15
  /** Lifecycle stage labels */
15
16
  const STAGE_LABELS = {
@@ -117,7 +118,7 @@ function writeProgress(sessionDir, stage, extra = {}) {
117
118
  updatedAt: new Date().toISOString(),
118
119
  ...extra
119
120
  };
120
- fs.writeFileSync(progressPath, JSON.stringify(data), { mode: 0o600 });
121
+ writeFileAtomic(progressPath, JSON.stringify(data), { mode: 0o600 });
121
122
  }
122
123
 
123
124
  /**
@@ -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
 
@@ -117,7 +119,7 @@ function updateSessionStatus(sessionDir, status) {
117
119
  async function resumeSidecar(options) {
118
120
  const {
119
121
  taskId, project = process.cwd(), headless = false, timeout = 15,
120
- mcp, mcpConfig, client, noMcp, excludeMcp
122
+ mcp, mcpConfig, client, noMcp, excludeMcp, json = false
121
123
  } = options;
122
124
 
123
125
  // Resume operates on an EXISTING session — resolve dual-dir (amicus, then legacy).
@@ -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
 
@@ -177,10 +191,17 @@ async function resumeSidecar(options) {
177
191
 
178
192
  if (headless) {
179
193
  const userMessage = buildResumeUserMessage(metadata.briefing || '', existingConversation);
180
- result = await runHeadless(
181
- metadata.model, resumePrompt, userMessage,
182
- taskId, project, timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers }
183
- );
194
+ try {
195
+ result = await runHeadless(
196
+ metadata.model, resumePrompt, userMessage,
197
+ taskId, project, timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers, nonce: foldNonce }
198
+ );
199
+ } catch (err) {
200
+ if (!json) { throw err; }
201
+ // --json contract: stdout must always carry a parseable run doc,
202
+ // even when the engine throws rather than returning {error}.
203
+ result = { summary: '', completed: false, timedOut: false, aborted: false, error: err.message, taskId };
204
+ }
184
205
  summary = result.summary || '## Sidecar Results: No Output\n\nResumed session completed without summary.';
185
206
 
186
207
  if (result.timedOut) { logger.warn('Resume task timed out', { taskId }); }
@@ -196,15 +217,16 @@ async function resumeSidecar(options) {
196
217
  isResume: true,
197
218
  conversation: existingConversation,
198
219
  opencodeSessionId: metadata.opencodeSessionId,
199
- mcp: mcpServers
220
+ mcp: mcpServers,
221
+ foldNonce
200
222
  }
201
223
  );
202
224
  summary = result.summary || '';
203
225
  if (result.error) { logger.error('Interactive resume error', { taskId, error: result.error }); }
204
226
  }
205
227
 
206
- // Output summary
207
- outputSummary(summary);
228
+ // Output summary (human mode only — json mode keeps stdout to the doc below)
229
+ if (!json) { outputSummary(summary); }
208
230
 
209
231
  // Map the run result to the canonical terminal status + exit code —
210
232
  // mirrors start.js. Explicit status preserves the interactive
@@ -216,11 +238,19 @@ async function resumeSidecar(options) {
216
238
  updatedMetadata.status = 'error';
217
239
  updatedMetadata.reason = (result && result.error) ? String(result.error) : 'Incomplete';
218
240
  updatedMetadata.completedAt = new Date().toISOString();
219
- fs.writeFileSync(metaPath, JSON.stringify(updatedMetadata, null, 2), { mode: 0o600 });
241
+ writeFileAtomic(metaPath, JSON.stringify(updatedMetadata, null, 2), { mode: 0o600 });
220
242
  logger.error('Resume completed with error', { taskId, error: updatedMetadata.reason });
221
243
  } else {
222
- finalizeSession(sessionDir, summary, project, updatedMetadata, { status: terminal.status });
244
+ finalizeSession(sessionDir, summary, project, updatedMetadata, { quietStdout: json, status: terminal.status });
223
245
  }
246
+
247
+ if (json) {
248
+ const { buildRunResult } = require('../utils/result-schema');
249
+ const finalMeta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
250
+ const doc = buildRunResult({ taskId, metadata: finalMeta, result, summary, sessionDir });
251
+ console.log(JSON.stringify(doc, null, 2));
252
+ }
253
+
224
254
  return terminal.exitCode; // finally below still releases the lock first
225
255
  } finally {
226
256
  if (heartbeat) { heartbeat.stop(); }
@@ -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 };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @module abort-result
3
+ * The abort-result document builder for `abort <taskId|--all> --json` (B21-rest).
4
+ * Split out of result-schema.js purely to stay under the size gate — same
5
+ * versioning contract (fields only ADDED within a SCHEMA_VERSION) applies here.
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const { SCHEMA_VERSION } = require('./result-schema-version');
11
+
12
+ /**
13
+ * Build an abort-result document.
14
+ * `ok` is true iff at least one session/leg was actually marked aborted by this
15
+ * call — a no-op (nothing running) is a successful call with an empty list, but
16
+ * a specific taskId that exists yet was not running (already terminal) reports
17
+ * ok:false so a scripted caller can tell "nothing happened" from "you aborted N".
18
+ * @param {object} opts
19
+ * @param {'session'|'wave'|'all'} opts.scope
20
+ * @param {string|null} opts.taskId - null for scope:'all'
21
+ * @param {string[]} opts.aborted - ids actually marked aborted (session/wave id + any legs)
22
+ * @returns {object} abort document
23
+ */
24
+ function buildAbortResult({ scope, taskId = null, aborted = [] }) {
25
+ return {
26
+ schemaVersion: SCHEMA_VERSION,
27
+ type: 'abort',
28
+ ok: aborted.length > 0 || scope === 'all',
29
+ scope,
30
+ taskId,
31
+ aborted,
32
+ count: aborted.length,
33
+ };
34
+ }
35
+
36
+ module.exports = { buildAbortResult };
@@ -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) */