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
@@ -4,6 +4,7 @@
4
4
  * Spec Reference: §6 Fold Mechanism, §9 Implementation
5
5
  * Constructs system prompts for sidecar sessions in both interactive and headless modes.
6
6
  */
7
+ const { buildFoldMarker } = require('./utils/fold-marker');
7
8
 
8
9
  /**
9
10
  * Summary template for fold output per spec §6.1
@@ -77,6 +78,16 @@ function buildSystemPrompt(briefing, context, project, headless, mode, client) {
77
78
  * @param {string} [mode='code'] - Agent mode ('code', 'ask', or 'plan')
78
79
  * @param {string} [summaryLength='normal'] - Desired summary length for headless mode
79
80
  * @param {string} [client='code-local'] - Client type for branding
81
+ * @param {string} [nonce] - Per-run fold nonce (15b.3, #BL-7 residual). Only used in
82
+ * headless mode: the model is instructed to emit `[SIDECAR_FOLD:<nonce>]` instead of
83
+ * the legacy bare `[SIDECAR_FOLD]`, so runHeadless's detector (which must be given
84
+ * this SAME nonce) can't be forced into completing by a model that merely echoes the
85
+ * public, guessable bare marker. Callers that build a headless prompt and intend to
86
+ * run it should generate one nonce (utils/fold-marker.generateFoldNonce()) BEFORE
87
+ * calling buildPrompts, pass it here, and pass the SAME value to runHeadless's
88
+ * options.nonce. Omitted in interactive mode (GUI fold is exit-code driven, not
89
+ * marker-detected) and harmless to omit in headless mode too — buildHeadlessModeSection
90
+ * falls back to the legacy bare marker, matching runHeadless's own no-nonce fallback.
80
91
  * @returns {{system: string, userMessage: string}} Separated prompts
81
92
  *
82
93
  * @example
@@ -89,16 +100,19 @@ function buildSystemPrompt(briefing, context, project, headless, mode, client) {
89
100
  * );
90
101
  * // Use: POST /session/:id/message { system, parts: [{ type: 'text', text: userMessage }] }
91
102
  */
92
- function buildPrompts(briefing, context, project, headless, mode, summaryLength = 'normal', client) {
103
+ function buildPrompts(briefing, context, project, headless, mode, summaryLength = 'normal', client, nonce) {
93
104
  const systemSections = [
94
105
  buildHeader(client),
95
106
  buildEnvironmentSection(project, mode),
96
- headless ? buildHeadlessModeSection(summaryLength) : buildInteractiveModeSection()
107
+ headless ? buildHeadlessModeSection(summaryLength, nonce) : buildInteractiveModeSection()
97
108
  ];
98
109
 
99
- // Strip [SIDECAR_FOLD] markers from context so the model doesn't
100
- // mimic them from previous sidecar outputs in the conversation history
101
- const cleanContext = context ? context.replace(/\[SIDECAR_FOLD\]/g, '') : context;
110
+ // Strip fold markers from context so the model doesn't mimic them from
111
+ // previous sidecar outputs in the conversation history. Matches BOTH the
112
+ // legacy bare `[SIDECAR_FOLD]` and any nonced `[SIDECAR_FOLD:<nonce>]`
113
+ // a resumed/continued conversation's history can carry either shape
114
+ // depending on when the prior turn ran (15b.3).
115
+ const cleanContext = context ? context.replace(/\[SIDECAR_FOLD(:[^\]]*)?\]/g, '') : context;
102
116
 
103
117
  let userMessage;
104
118
  if (headless) {
@@ -237,9 +251,16 @@ Keep track of key findings as you work.`;
237
251
  * Spec Reference: §6.2 Headless Mode
238
252
  *
239
253
  * @param {string} summaryLength - Desired summary length (brief, normal, verbose)
254
+ * @param {string} [nonce] - Per-run fold nonce (15b.3, #BL-7 residual). When provided,
255
+ * the model is instructed to emit `[SIDECAR_FOLD:<nonce>]` instead of the legacy bare
256
+ * `[SIDECAR_FOLD]` — see buildPrompts' @param doc for the full rationale. Falls back to
257
+ * the legacy bare marker when omitted (keeps this function usable standalone / by the
258
+ * deprecated buildSystemPrompt(), which has no orchestration-layer caller to source a
259
+ * nonce from).
240
260
  * @returns {string}
241
261
  */
242
- function buildHeadlessModeSection(summaryLength) {
262
+ function buildHeadlessModeSection(summaryLength, nonce) {
263
+ const marker = nonce ? buildFoldMarker(nonce) : '[SIDECAR_FOLD]';
243
264
  let summaryFormat = `## Summary Format
244
265
 
245
266
  When complete, output your findings in this format:
@@ -266,7 +287,7 @@ When complete, output your findings in this format:
266
287
 
267
288
  **Open Questions:** (if any)
268
289
 
269
- [SIDECAR_FOLD]`;
290
+ ${marker}`;
270
291
 
271
292
  if (summaryLength === 'brief') {
272
293
  summaryFormat = `## Summary Format
@@ -281,7 +302,7 @@ When complete, output a BRIEF summary in this format:
281
302
  **Recommendations:**
282
303
  [Suggested actions]
283
304
 
284
- [SIDECAR_FOLD]`;
305
+ ${marker}`;
285
306
  } else if (summaryLength === 'verbose') {
286
307
  // Verbose could include more details or examples
287
308
  summaryFormat = `## Summary Format (VERBOSE)
@@ -315,7 +336,7 @@ When complete, output a COMPREHENSIVE summary in this format, including all deta
315
336
  **Open Questions:** (if any)
316
337
  [List all remaining ambiguities, unresolved issues, or areas requiring further investigation.]
317
338
 
318
- [SIDECAR_FOLD]`;
339
+ ${marker}`;
319
340
  }
320
341
 
321
342
  return `## HEADLESS MODE INSTRUCTIONS
@@ -324,14 +345,14 @@ You are running autonomously without human interaction.
324
345
 
325
346
  1. Execute the task completely
326
347
  2. Make reasonable assumptions and document them
327
- 3. When done, output your summary followed by [SIDECAR_FOLD]
348
+ 3. When done, output your summary followed by ${marker}
328
349
 
329
350
  Do NOT ask questions. Work independently.
330
351
 
331
352
  If you encounter a blocker you cannot resolve:
332
353
  1. Document what you tried
333
354
  2. Output partial results
334
- 3. End with [SIDECAR_FOLD]
355
+ 3. End with ${marker}
335
356
 
336
357
  ${summaryFormat}`;
337
358
  }
@@ -21,9 +21,6 @@ const SESSION_STATUS = {
21
21
 
22
22
  /** Canonical session dir name — new sessions are written here. */
23
23
  const SESSIONS_DIR = 'amicus_sessions';
24
- // DEPRECATED(amicus-shim): legacy session dir read for pre-rebrand sessions.
25
- // Remove in a future revision — see docs/SHIMS.md.
26
- const LEGACY_SESSIONS_DIR = 'sidecar_sessions';
27
24
 
28
25
  /**
29
26
  * Get the canonical session directory path for a task (used for WRITES).
@@ -50,19 +47,16 @@ function getSessionDir(projectDir, taskId) {
50
47
  }
51
48
 
52
49
  /**
53
- * Resolve an EXISTING session dir for reads: prefer amicus, fall back to legacy.
54
- * Backward-compat shim so pre-rebrand `.claude/sidecar_sessions/` stay visible.
50
+ * Resolve an EXISTING session dir for reads. Currently identical to
51
+ * getSessionDir (kept as a distinct name for call-site clarity/history
52
+ * pre-#19 it also probed a legacy session-dir root).
55
53
  *
56
54
  * @param {string} projectDir - Project directory path
57
55
  * @param {string} taskId - Sidecar task ID
58
- * @returns {string} Path to the resolved session directory (defaults to the new path)
56
+ * @returns {string} Path to the resolved session directory
59
57
  */
60
58
  function resolveExistingSessionDir(projectDir, taskId) {
61
- const current = getSessionDir(projectDir, taskId);
62
- if (fs.existsSync(current)) { return current; }
63
- const legacy = path.join(projectDir, '.claude', LEGACY_SESSIONS_DIR, taskId);
64
- if (fs.existsSync(legacy)) { return legacy; }
65
- return current; // default to the new path
59
+ return getSessionDir(projectDir, taskId);
66
60
  }
67
61
 
68
62
  /**
@@ -299,7 +293,7 @@ function createSubagentSession(projectDir, parentTaskId, subagentId, metadata) {
299
293
  };
300
294
 
301
295
  // Write metadata
302
- fs.writeFileSync(
296
+ writeFileAtomic(
303
297
  path.join(subagentDir, 'metadata.json'),
304
298
  JSON.stringify(subagentMetadata, null, 2),
305
299
  { mode: 0o600 }
@@ -329,7 +323,7 @@ function updateSubagentSession(projectDir, parentTaskId, subagentId, updates) {
329
323
 
330
324
  const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
331
325
  const updated = { ...metadata, ...updates };
332
- fs.writeFileSync(metadataPath, JSON.stringify(updated, null, 2), { mode: 0o600 });
326
+ writeFileAtomic(metadataPath, JSON.stringify(updated, null, 2), { mode: 0o600 });
333
327
  }
334
328
 
335
329
  /**
@@ -418,7 +412,6 @@ module.exports = {
418
412
  getSessionDir,
419
413
  resolveExistingSessionDir,
420
414
  SESSIONS_DIR,
421
- LEGACY_SESSIONS_DIR,
422
415
  SESSION_STATUS,
423
416
  // Sub-agent functions
424
417
  getSubagentDir,
@@ -5,6 +5,7 @@
5
5
 
6
6
  const fs = require('fs');
7
7
 
8
+ const { writeFileAtomic } = require('../utils/atomic-write');
8
9
  const { generateTaskId, runInteractive, buildMcpConfig } = require('./start');
9
10
  const {
10
11
  SessionPaths,
@@ -16,6 +17,7 @@ const {
16
17
  const { acquireLock, releaseLock } = require('../utils/session-lock');
17
18
  const { runHeadless } = require('../headless');
18
19
  const { buildPrompts } = require('../prompt-builder');
20
+ const { generateFoldNonce } = require('../utils/fold-marker');
19
21
  const { logger } = require('../utils/logger');
20
22
 
21
23
  /** Load previous session data (metadata, summary, conversation) */
@@ -103,7 +105,7 @@ function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
103
105
  continuesFrom: oldTaskId
104
106
  };
105
107
 
106
- fs.writeFileSync(SessionPaths.metadataFile(sessionDir), JSON.stringify(metadata, null, 2));
108
+ writeFileAtomic(SessionPaths.metadataFile(sessionDir), JSON.stringify(metadata, null, 2));
107
109
 
108
110
  return sessionDir;
109
111
  }
@@ -151,9 +153,14 @@ async function continueSidecar(options) {
151
153
  // Inherit agent from previous session if not specified
152
154
  const effectiveAgent = agent || oldMetadata.agent || 'Build';
153
155
 
156
+ // 15b.3: a continuation builds a FRESH prompt (unlike resume, which re-sends
157
+ // the original), so it gets a fresh nonce too — the old session's nonce has
158
+ // no bearing on this new one.
159
+ const foldNonce = generateFoldNonce();
160
+
154
161
  // Build system prompt and user message
155
162
  const { system: systemPrompt, userMessage } = buildPrompts(
156
- briefing, fullContext, project, headless, effectiveAgent, 'normal', client
163
+ briefing, fullContext, project, headless, effectiveAgent, 'normal', client, foldNonce
157
164
  );
158
165
 
159
166
  // Use provided task ID (from MCP server) or generate a new one
@@ -180,7 +187,7 @@ async function continueSidecar(options) {
180
187
  if (headless) {
181
188
  result = await runHeadless(
182
189
  model, systemPrompt, userMessage, newTaskId, project,
183
- timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers }
190
+ timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers, nonce: foldNonce }
184
191
  );
185
192
  summary = result.summary ||
186
193
  '## Sidecar Results: No Output\n\nContinued session completed without summary.';
@@ -191,7 +198,7 @@ async function continueSidecar(options) {
191
198
  logger.info('Launching interactive continue', { taskId: newTaskId, model });
192
199
  result = await runInteractive(
193
200
  model, systemPrompt, userMessage, newTaskId, project,
194
- { agent: effectiveAgent, mcp: mcpServers }
201
+ { agent: effectiveAgent, mcp: mcpServers, foldNonce }
195
202
  );
196
203
  summary = result.summary || '';
197
204
  if (result.error) { logger.error('Interactive continue error', { taskId: newTaskId, error: result.error }); }
@@ -220,7 +227,7 @@ async function continueSidecar(options) {
220
227
  meta.status = 'error';
221
228
  meta.reason = (result && result.error) ? String(result.error) : 'Incomplete';
222
229
  meta.completedAt = new Date().toISOString();
223
- fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
230
+ writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
224
231
  logger.error('Continuation completed with error', { taskId: newTaskId, error: meta.reason });
225
232
  } else {
226
233
  finalizeSession(sessionDir, summary, project, meta, { status: terminal.status });
@@ -24,6 +24,7 @@ function createMirrorState() {
24
24
  toolCalls: [], // [{id,name,input}] — capped at MAX_TOOL_CALLS (most-recent-N)
25
25
  seenToolCallIds: new Set(), // stable dedup identity for tool calls (survives the cap)
26
26
  seenToolResultIds: new Set(),
27
+ pendingToolCalls: new Map(), // id -> {id,name,firstSeenAt} — tool_use with no tool_result yet (B53)
27
28
  receivingReported: false,
28
29
  output: '', // accumulated assistant text
29
30
  seenReasoningParts: new Map(), // partId -> last captured reasoning length
@@ -32,6 +33,19 @@ function createMirrorState() {
32
33
  };
33
34
  }
34
35
 
36
+ /**
37
+ * Unresolved tool calls: tool_use ids seen with no matching tool_result yet
38
+ * (matched by `part.tool_use_id`). Used by the headless poll loop's stall
39
+ * detector (B53) to fail fast on a wedged tool call instead of burning the
40
+ * full timeout. Returns a fresh array each call; `state.pendingToolCalls`
41
+ * is the live source of truth.
42
+ * @param {object} state from createMirrorState()
43
+ * @returns {Array<{id:string,name:string,firstSeenAt:string}>}
44
+ */
45
+ function getPendingToolCalls(state) {
46
+ return Array.from(state.pendingToolCalls.values());
47
+ }
48
+
35
49
  /**
36
50
  * @param {Array} messages getMessages() snapshot
37
51
  * @param {object} state from createMirrorState() (mutated + returned)
@@ -94,6 +108,10 @@ function mirrorMessages(messages, state, opts = {}) {
94
108
  if (state.toolCalls.length > MAX_TOOL_CALLS) { state.toolCalls.shift(); }
95
109
  appendLines.push({ role: 'assistant', type: 'tool_use', toolCall, timestamp: now() });
96
110
 
111
+ // Track as pending until a matching tool_result arrives (B53 stall detector).
112
+ // firstSeenAt is captured once here — never touched again for this id.
113
+ state.pendingToolCalls.set(part.id, { id: part.id, name: part.name, firstSeenAt: now() });
114
+
97
115
  // Update progress on tool_use detection
98
116
  progressUpdates.push({
99
117
  stage: 'receiving',
@@ -117,6 +135,9 @@ function mirrorMessages(messages, state, opts = {}) {
117
135
  timestamp: now(),
118
136
  });
119
137
  }
138
+ // Resolved: clear the pending entry regardless of dedup state above (a
139
+ // result seen again for an id already cleared is a harmless no-op delete).
140
+ if (part.tool_use_id) { state.pendingToolCalls.delete(part.tool_use_id); }
120
141
  } else if (part.type === 'reasoning' && part.text) {
121
142
  // Some providers (e.g. Gemini 3.x on the direct Google path) return the
122
143
  // answer as a reasoning part with no separate text part. Accumulate it in a
@@ -178,4 +199,4 @@ function logMessage(conversationPath, message) {
178
199
  fs.appendFileSync(conversationPath, JSON.stringify(message) + '\n', { mode: 0o600 });
179
200
  }
180
201
 
181
- module.exports = { createMirrorState, mirrorMessages, logMessage };
202
+ module.exports = { createMirrorState, mirrorMessages, logMessage, getPendingToolCalls };
@@ -9,6 +9,7 @@
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
11
  const { SessionPaths } = require('./session-utils');
12
+ const { writeFileAtomic } = require('../utils/atomic-write');
12
13
 
13
14
  /**
14
15
  * Create a crash handler that updates session metadata on error.
@@ -38,7 +39,7 @@ function installCrashHandler(taskId, project) {
38
39
  metadata.reason = err.message;
39
40
  metadata.errorAt = new Date().toISOString();
40
41
 
41
- fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
42
+ writeFileAtomic(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
42
43
 
43
44
  // Delete session lock if it exists
44
45
  const lockPath = path.join(sessionDir, 'session.lock');
@@ -10,6 +10,7 @@
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
12
  const { logger } = require('../utils/logger');
13
+ const { writeFileAtomic } = require('../utils/atomic-write');
13
14
 
14
15
  /** Map a runHeadless result to a leg metadata status. */
15
16
  function legStatusFromResult(result) {
@@ -30,7 +31,7 @@ function writeLegPatch(legDir, patch) {
30
31
  delete defined.status;
31
32
  }
32
33
  const merged = { ...meta, ...defined };
33
- fs.writeFileSync(metaPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
34
+ writeFileAtomic(metaPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
34
35
  return merged;
35
36
  }
36
37
 
@@ -38,7 +39,7 @@ function writeLegPatch(legDir, patch) {
38
39
  * Run one leg end-to-end: session record → runHeadless (shared server) →
39
40
  * leg finalize. Never throws — always resolves to a run document.
40
41
  */
41
- async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, reasoning, quiet }) {
42
+ async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, reasoning, quiet, foldNonce }) {
42
43
  const { IdleWatchdog } = require('../utils/idle-watchdog');
43
44
  const { markAborted } = require('../utils/session-abort');
44
45
  const { runHeadless } = require('../headless');
@@ -81,7 +82,7 @@ async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage,
81
82
  result = await runHeadless(
82
83
  leg.model, systemPrompt, userMessage, legId, project,
83
84
  timeoutMs, agent || 'build',
84
- { client, server, watchdog, summaryLength, reasoning }
85
+ { client, server, watchdog, summaryLength, reasoning, nonce: foldNonce }
85
86
  );
86
87
  } catch (err) {
87
88
  result = { summary: '', completed: false, timedOut: false, aborted: false, error: err.message, taskId: legId };
@@ -93,6 +94,14 @@ async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage,
93
94
  const summary = result.summary || null;
94
95
  const { resolveUsage } = require('../utils/pricing');
95
96
  const usage = result && result.usage ? resolveUsage({ model: leg.model, usageTotals: result.usage }) : null;
97
+ // B24: cross-run spend ledger — one row per leg (mirrors start.js's single-run
98
+ // append). Best-effort; never let ledger bookkeeping affect the leg's own result.
99
+ if (usage) {
100
+ try {
101
+ const { appendSpend } = require('../utils/spend-ledger');
102
+ appendSpend({ taskId: legId, waveId, model: leg.model, mode: 'leg', usage });
103
+ } catch { /* best-effort */ }
104
+ }
96
105
  // If setup threw before the session dir existed, there is nothing on disk to
97
106
  // finalize — still resolve to an error run document so the wave aggregates.
98
107
  const legPatch = {
@@ -15,6 +15,7 @@ const path = require('path');
15
15
  const { logger } = require('../utils/logger');
16
16
  const { runLeg } = require('./fanout-leg');
17
17
  const { ERROR_CODES } = require('../utils/error-doc');
18
+ const { writeFileAtomic } = require('../utils/atomic-write');
18
19
 
19
20
  /** Default max legs per wave (env-overridable). */
20
21
  const DEFAULT_MAX_LEGS = 10;
@@ -86,15 +87,24 @@ async function validateFanoutModels(modelsArg, opts = {}) {
86
87
  return { legs };
87
88
  }
88
89
 
89
- /** Write/merge wave metadata (preserves fields an MCP pre-spawn handler wrote). */
90
+ /**
91
+ * Write/merge wave metadata (preserves fields an MCP pre-spawn handler wrote).
92
+ * Abort-wins: once existing status is 'aborted', a patch cannot demote it back
93
+ * to a softer status (same precedence rule as writeLegPatch — a signal/abort
94
+ * marker must never lose a write race against an in-flight init/finalize).
95
+ */
90
96
  function writeWaveMetadata(waveDir, patch) {
91
97
  const metaPath = path.join(waveDir, 'metadata.json');
92
98
  let existing = {};
93
99
  if (fs.existsSync(metaPath)) {
94
100
  try { existing = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); } catch { /* corrupt → rewrite */ }
95
101
  }
96
- const merged = { ...existing, ...patch };
97
- fs.writeFileSync(metaPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
102
+ const safePatch = { ...patch };
103
+ if (existing.status === 'aborted' && safePatch.status && safePatch.status !== 'aborted') {
104
+ delete safePatch.status;
105
+ }
106
+ const merged = { ...existing, ...safePatch };
107
+ writeFileAtomic(metaPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
98
108
  return merged;
99
109
  }
100
110
 
@@ -114,6 +124,7 @@ async function runFanout(options) {
114
124
  const { createWaveHeartbeat } = require('./wave-progress');
115
125
  const { buildContext } = require('./context-builder');
116
126
  const { buildPrompts } = require('../prompt-builder');
127
+ const { generateFoldNonce } = require('../utils/fold-marker');
117
128
  const { installSignalAbort, markAborted } = require('../utils/session-abort');
118
129
  const { getSessionDir } = require('../session-manager');
119
130
 
@@ -188,8 +199,12 @@ async function runFanout(options) {
188
199
  coworkProcess: options.coworkProcess,
189
200
  })
190
201
  : '[Context excluded by caller - briefing is self-contained]';
202
+ // 15b.3: ONE nonce for the whole wave — every leg shares the SAME prompt
203
+ // (built once, above), so every leg's model is instructed with the same
204
+ // nonce, and runLeg threads it to each leg's own runHeadless detector.
205
+ const foldNonce = generateFoldNonce();
191
206
  const { system: systemPrompt, userMessage } = buildPrompts(
192
- options.prompt, context, project, true, options.agent || 'build', options.summaryLength, options.client
207
+ options.prompt, context, project, true, options.agent || 'build', options.summaryLength, options.client, foldNonce
193
208
  );
194
209
 
195
210
  // 4. One shared OpenCode server
@@ -220,7 +235,10 @@ async function runFanout(options) {
220
235
  logger.warn('Signal received — aborting wave', { waveId, signal });
221
236
  markAborted(waveDir, signal);
222
237
  for (const dir of legDirs) { markAborted(dir, signal); }
223
- try { server.close(); } catch { /* best-effort */ }
238
+ // close() is async (B06 escalation); this handler stays sync, so
239
+ // fire-and-forget with a rejection guard. The 10s exit watchdog below
240
+ // comfortably outlives the ~2s escalation grace inside close().
241
+ try { server.close().catch(() => {}); } catch { /* best-effort */ }
224
242
  const { armExitWatchdog } = require('../utils/lifecycle');
225
243
  armExitWatchdog(code, 10000, { log: (m, meta) => logger.debug(m, meta) });
226
244
  },
@@ -241,11 +259,12 @@ async function runFanout(options) {
241
259
  leg, legId: legIds[i], waveId, project, systemPrompt, userMessage,
242
260
  timeoutMs, agent: options.agent, client, server,
243
261
  summaryLength: options.summaryLength, reasoning, quiet: options.quiet,
262
+ foldNonce,
244
263
  })));
245
264
  } finally {
246
265
  heartbeat.stop();
247
266
  uninstallSignals();
248
- try { server.close(); } catch { /* already closed on signal */ }
267
+ try { await server.close(); } catch { /* already closed on signal */ }
249
268
  }
250
269
 
251
270
  // 7. Aggregate, persist (atomic: tmp + rename), finalize, emit
@@ -255,9 +274,7 @@ async function runFanout(options) {
255
274
  status: signalled ? 'aborted' : null,
256
275
  });
257
276
  const wavePath = path.join(waveDir, 'wave.json');
258
- const waveTmp = `${wavePath}.tmp`;
259
- fs.writeFileSync(waveTmp, JSON.stringify(wave, null, 2), { mode: 0o600 });
260
- fs.renameSync(waveTmp, wavePath);
277
+ writeFileAtomic(wavePath, JSON.stringify(wave, null, 2), { mode: 0o600 });
261
278
  writeWaveMetadata(waveDir, { status: wave.status, completedAt });
262
279
  emit(wave);
263
280
  const exitCode = signalled
@@ -268,5 +285,5 @@ async function runFanout(options) {
268
285
 
269
286
  module.exports = {
270
287
  parseModelsList, deriveLegIds, validateFanoutModels, DEFAULT_MAX_LEGS,
271
- runFanout,
288
+ runFanout, writeWaveMetadata,
272
289
  };
@@ -6,7 +6,6 @@
6
6
  const path = require('path');
7
7
 
8
8
  const { logger } = require('../utils/logger');
9
- const { mapAgentToOpenCode } = require('../utils/agent-mapping');
10
9
 
11
10
  /** Resolve the Electron binary path ONLY when the exe actually exists on disk.
12
11
  * #54: path.txt surviving (require('electron') resolving) is NOT enough — a
@@ -31,13 +30,12 @@ function checkElectronAvailable() {
31
30
 
32
31
  /** Build environment variables for Electron process */
33
32
  function buildElectronEnv(taskId, model, project, nodeModulesBin, existingPath, options = {}) {
34
- const { agent, isResume, conversation, mcp, client, windowPosition, sessionDirectory } = options;
33
+ const { client, windowPosition, sessionDirectory, foldNonce } = options;
35
34
  const env = {
36
35
  ...process.env,
37
36
  PATH: `${nodeModulesBin}${path.delimiter}${existingPath}`,
38
37
  AMICUS_TASK_ID: taskId,
39
- AMICUS_MODEL: model,
40
- SIDECAR_PROJECT: project
38
+ AMICUS_MODEL: model
41
39
  };
42
40
 
43
41
  if (client) { env.AMICUS_CLIENT = client; }
@@ -46,19 +44,10 @@ function buildElectronEnv(taskId, model, project, nodeModulesBin, existingPath,
46
44
  // Web-UI route from THIS, not a fresh base64url(CWD) guess, so follow-up
47
45
  // prompts resolve the session when process cwd != --cwd.
48
46
  if (sessionDirectory) { env.AMICUS_SESSION_DIRECTORY = sessionDirectory; }
49
-
50
- if (agent) {
51
- const agentConfig = mapAgentToOpenCode(agent);
52
- env.SIDECAR_AGENT = agentConfig.agent;
53
- if (agentConfig.permissions) { env.SIDECAR_PERMISSIONS = agentConfig.permissions; }
54
- }
55
-
56
- if (isResume) {
57
- env.SIDECAR_RESUME = 'true';
58
- if (conversation) { env.SIDECAR_CONVERSATION = conversation; }
59
- }
60
-
61
- if (mcp) { env.SIDECAR_MCP_CONFIG = JSON.stringify(mcp); }
47
+ // 15b.3: per-run fold nonce (#BL-7 residual) — the same value baked into the
48
+ // system prompt's instruction (buildPrompts) so fold.js writes a marker the
49
+ // prompt actually asked for, not the guessable legacy bare `[SIDECAR_FOLD]`.
50
+ if (foldNonce) { env.AMICUS_FOLD_NONCE = foldNonce; }
62
51
 
63
52
  return env;
64
53
  }
@@ -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
  /**