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
@@ -540,34 +540,57 @@ function buildServerOptions(options = {}) {
540
540
  }
541
541
 
542
542
  const { findListenerPid } = require('./utils/port-pid');
543
+ const { waitThenKill: defaultWaitThenKill } = require('./utils/abort-coordinator');
544
+
545
+ // Teardown-appropriate grace window for the SIGTERM->SIGKILL escalation on
546
+ // close() (B06). Constant, not env-overridable — this is process teardown,
547
+ // not the marker-honoring abort grace in abort-coordinator.js.
548
+ const CLOSE_KILL_GRACE_MS = 2000;
543
549
 
544
550
  /**
545
551
  * Build the { url, goPid, close } server handle around a raw SDK server.
546
552
  * Extracted + dependency-injected so the goPid capture and cross-platform
547
553
  * force-kill (F3 #15) can be unit-tested without the SDK's dynamic import.
554
+ *
555
+ * close() (B06 teardown-race fix): sdkServer.close() SIGTERMs the SDK's
556
+ * wrapper script, not the Go binary — the SDK exposes no pid for it. So
557
+ * close() ALSO SIGTERMs goPid directly (captured via the port scan / sdk
558
+ * pid fields above) and runs it through the REF'd SIGTERM->SIGKILL
559
+ * escalation primitive, bounded by CLOSE_KILL_GRACE_MS. The old unref'd
560
+ * SIGKILL-only timer is gone: on POSIX it could die with the parent before
561
+ * ever firing (orphaning the Go server), and it never sent a SIGTERM to the
562
+ * Go pid at all. On win32 this is a no-op beyond firing the signal — the
563
+ * job-object semantics that already reap the tree are untouched.
548
564
  * @param {{url:string, close:Function, pid?:number, process?:{pid:number}}} sdkServer
549
- * @param {{findListenerPid?:Function, kill?:Function, logger?:object}} [deps]
565
+ * @param {{findListenerPid?:Function, kill?:Function, logger?:object, waitThenKill?:Function}} [deps]
550
566
  * @returns {{url:string, goPid:number|null, close:Function}}
551
567
  */
552
568
  function buildServerHandle(sdkServer, deps = {}) {
553
569
  const findPid = deps.findListenerPid || findListenerPid;
554
570
  const kill = deps.kill || ((pid, sig) => process.kill(pid, sig));
555
571
  const log = deps.logger || require('./utils/logger').logger;
572
+ const waitThenKill = deps.waitThenKill || defaultWaitThenKill;
556
573
  const serverPort = parseInt(new URL(sdkServer.url).port, 10);
557
574
  const goPid = sdkServer.pid || (sdkServer.process && sdkServer.process.pid) || findPid(serverPort);
558
575
  const server = {
559
576
  url: sdkServer.url,
560
577
  goPid,
561
- close() {
578
+ async close() {
562
579
  sdkServer.close();
563
- const fallback = setTimeout(() => {
564
- const pid = server.goPid;
565
- if (pid && pid !== process.pid) {
566
- try { kill(pid, 'SIGKILL'); } catch { /* already dead */ }
567
- log.debug('Force-killed OpenCode server', { port: serverPort, pid });
568
- }
569
- }, 2000);
570
- if (fallback.unref) { fallback.unref(); }
580
+ const pid = server.goPid;
581
+ if (!pid || pid === process.pid) { return; }
582
+ // graceMs: 0 => waitThenKill's own poll fires the direct SIGTERM to
583
+ // goPid immediately (no wait beforehand the SDK's close() above never
584
+ // reaches the Go binary, so there is no reason to delay this one), then
585
+ // the escalation tier SIGKILLs any survivor after CLOSE_KILL_GRACE_MS.
586
+ const { escalated } = await waitThenKill(pid, {
587
+ graceMs: 0,
588
+ escalate: { killGraceMs: CLOSE_KILL_GRACE_MS },
589
+ deps: { kill },
590
+ });
591
+ if (escalated.includes(pid)) {
592
+ log.debug('Force-killed OpenCode server', { port: serverPort, pid });
593
+ }
571
594
  }
572
595
  };
573
596
  return server;
@@ -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
  }
@@ -121,7 +123,7 @@ async function continueSidecar(options) {
121
123
  headless = false,
122
124
  timeout = 15,
123
125
  agent,
124
- mcp, mcpConfig, client, noMcp, excludeMcp
126
+ mcp, mcpConfig, client, noMcp, excludeMcp, json = false
125
127
  } = options;
126
128
 
127
129
  // Load previous session data
@@ -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
@@ -178,10 +185,17 @@ async function continueSidecar(options) {
178
185
 
179
186
  try {
180
187
  if (headless) {
181
- result = await runHeadless(
182
- model, systemPrompt, userMessage, newTaskId, project,
183
- timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers }
184
- );
188
+ try {
189
+ result = await runHeadless(
190
+ model, systemPrompt, userMessage, newTaskId, project,
191
+ timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers, nonce: foldNonce }
192
+ );
193
+ } catch (err) {
194
+ if (!json) { throw err; }
195
+ // --json contract: stdout must always carry a parseable run doc,
196
+ // even when the engine throws rather than returning {error}.
197
+ result = { summary: '', completed: false, timedOut: false, aborted: false, error: err.message, taskId: newTaskId };
198
+ }
185
199
  summary = result.summary ||
186
200
  '## Sidecar Results: No Output\n\nContinued session completed without summary.';
187
201
 
@@ -191,7 +205,7 @@ async function continueSidecar(options) {
191
205
  logger.info('Launching interactive continue', { taskId: newTaskId, model });
192
206
  result = await runInteractive(
193
207
  model, systemPrompt, userMessage, newTaskId, project,
194
- { agent: effectiveAgent, mcp: mcpServers }
208
+ { agent: effectiveAgent, mcp: mcpServers, foldNonce }
195
209
  );
196
210
  summary = result.summary || '';
197
211
  if (result.error) { logger.error('Interactive continue error', { taskId: newTaskId, error: result.error }); }
@@ -202,8 +216,8 @@ async function continueSidecar(options) {
202
216
  releaseLock(prevSessionDir);
203
217
  }
204
218
 
205
- // Output summary
206
- outputSummary(summary);
219
+ // Output summary (human mode only — json mode keeps stdout to the doc below)
220
+ if (!json) { outputSummary(summary); }
207
221
 
208
222
  // Load current metadata for finalization
209
223
  const metaPath = SessionPaths.metadataFile(sessionDir);
@@ -220,11 +234,19 @@ async function continueSidecar(options) {
220
234
  meta.status = 'error';
221
235
  meta.reason = (result && result.error) ? String(result.error) : 'Incomplete';
222
236
  meta.completedAt = new Date().toISOString();
223
- fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
237
+ writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
224
238
  logger.error('Continuation completed with error', { taskId: newTaskId, error: meta.reason });
225
239
  } else {
226
- finalizeSession(sessionDir, summary, project, meta, { status: terminal.status });
240
+ finalizeSession(sessionDir, summary, project, meta, { quietStdout: json, status: terminal.status });
227
241
  }
242
+
243
+ if (json) {
244
+ const { buildRunResult } = require('../utils/result-schema');
245
+ const finalMeta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
246
+ const doc = buildRunResult({ taskId: newTaskId, metadata: finalMeta, result, summary, sessionDir });
247
+ console.log(JSON.stringify(doc, null, 2));
248
+ }
249
+
228
250
  return terminal.exitCode;
229
251
  }
230
252
 
@@ -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
  }