amicus 1.7.7 → 1.8.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.
package/src/mcp-server.js CHANGED
@@ -9,6 +9,7 @@ const { logger } = require('./utils/logger');
9
9
  const { safeSessionDir } = require('./utils/validators');
10
10
  const { getSessionDir, SESSIONS_DIR, LEGACY_SESSIONS_DIR } = require('./session-manager');
11
11
  const { readProgress, isStalled } = require('./sidecar/progress');
12
+ const { deriveStage, sanitizePreview } = require('./sidecar/progress-fields');
12
13
  const { SharedServerManager } = require('./utils/shared-server');
13
14
  const { durationBetween } = require('./utils/result-schema');
14
15
  const { canonicalProjectPath } = require('./utils/project-path');
@@ -16,6 +17,7 @@ const { isAllowedProjectRoot } = require('./project-root-allowlist');
16
17
  const { recordSession } = require('./utils/session-index');
17
18
  const { fileURLToPath } = require('url');
18
19
  const { RUNNING_VERSION, versionWarning } = require('./utils/version-info');
20
+ const { runWait, registerInProcessRun, settleInProcessRun } = require('./mcp-wait');
19
21
 
20
22
  /**
21
23
  * Elapsed run duration: time between createdAt and the run's end, bounding the
@@ -211,13 +213,13 @@ function appendVersionWarning(content) {
211
213
  */
212
214
  function computeNextPoll() {
213
215
  return {
214
- hint: 'Run `sleep 25` in your shell before calling amicus_status again. This enforces the wait and prevents token-wasting rapid polls.',
216
+ hint: 'Run `sleep 25` in your shell before calling amicus_status again. This enforces the wait and prevents token-wasting rapid polls. Preferred: call amicus_wait with this task ID instead — one blocking call replaces the sleep+status loop; re-call it while it returns timedOut: true.',
215
217
  wait_command: 'sleep 25',
216
218
  };
217
219
  }
218
220
 
219
- const HEADLESS_START_REMINDER = '<system-reminder>IMPORTANT: Before calling amicus_status, you MUST run `sleep 25` in your shell first. This enforces the polling interval and prevents token waste. Do other useful work while waiting, or run `sleep 25` to block until the next poll window.</system-reminder>';
220
- const HEADLESS_STATUS_REMINDER = '<system-reminder>IMPORTANT: This session is still running. Before calling amicus_status again, you MUST run `sleep 25` in your shell first. Each premature poll wastes context tokens for zero benefit. Run `sleep 25` now, then check again.</system-reminder>';
221
+ const HEADLESS_START_REMINDER = '<system-reminder>IMPORTANT: Before calling amicus_status, you MUST run `sleep 25` in your shell first. This enforces the polling interval and prevents token waste. Do other useful work while waiting, or run `sleep 25` to block until the next poll window. Preferred: call amicus_wait with this task ID instead — one blocking call replaces the sleep+status loop; re-call it while it returns timedOut: true.</system-reminder>';
222
+ const HEADLESS_STATUS_REMINDER = '<system-reminder>IMPORTANT: This session is still running. Before calling amicus_status again, you MUST run `sleep 25` in your shell first. Each premature poll wastes context tokens for zero benefit. Run `sleep 25` now, then check again. Preferred: call amicus_wait with this task ID instead — one blocking call replaces the sleep+status loop; re-call it while it returns timedOut: true.</system-reminder>';
221
223
 
222
224
  /** Spawn an Amicus CLI process (fire-and-forget) */
223
225
  function spawnSidecarProcess(args, sessionDir) {
@@ -322,6 +324,12 @@ const handlers = {
322
324
  goPid: server.goPid || null,
323
325
  createdAt: new Date().toISOString(),
324
326
  headless: true, model: resolvedModel,
327
+ // F6: agent-visible provenance at creation. The CLI path writes these
328
+ // via createSessionMetadata; the shared-server path has no CLI child,
329
+ // so without them status/list/read show a briefing-less, mode-less run.
330
+ mode: 'headless',
331
+ agent: agent || 'build',
332
+ briefing: input.prompt,
325
333
  }, null, 2), { mode: 0o600 });
326
334
 
327
335
  // Build context from parent conversation (unless --no-context)
@@ -359,6 +367,10 @@ const handlers = {
359
367
 
360
368
  const timeoutMs = (input.timeout || 15) * 60 * 1000;
361
369
 
370
+ // amicus_wait fast path: this process owns the run promise; settle wakes
371
+ // any pending wait the moment finalize lands (poll fallback covers the rest).
372
+ registerInProcessRun(taskId);
373
+
362
374
  // Fire-and-forget: runHeadless with shared server's client
363
375
  runHeadless(resolvedModel, systemPrompt, userMessage, taskId, cwd,
364
376
  timeoutMs, agent, {
@@ -389,6 +401,13 @@ const handlers = {
389
401
  } catch (writeErr) {
390
402
  logger.warn('Failed to write error metadata', { error: writeErr.message });
391
403
  }
404
+ }).finally(() => {
405
+ // Guaranteed settle: runs after the .then/.catch bodies above (which
406
+ // write terminal metadata first), even if removeSession or another
407
+ // in-chain step throws — otherwise a pending amicus_wait leaks its
408
+ // waiter and the throw becomes an unhandled rejection. No-op if the
409
+ // taskId was never registered or was already settled.
410
+ settleInProcessRun(taskId);
392
411
  });
393
412
 
394
413
  // Return immediately
@@ -403,6 +422,7 @@ const handlers = {
403
422
  if (sessionId) {
404
423
  sharedServer.removeSession(sessionId);
405
424
  }
425
+ settleInProcessRun(taskId); // clear a dangling waiter (no-op if never registered)
406
426
  // Fall through to spawn path below
407
427
  }
408
428
  }
@@ -427,6 +447,10 @@ const handlers = {
427
447
  fs.writeFileSync(metaPath, JSON.stringify({
428
448
  taskId, status: 'running', pid: child.pid, createdAt: new Date().toISOString(),
429
449
  headless: !!input.noUi,
450
+ // Seed briefing/mode so list/status are informative even before the
451
+ // CLI child's createSessionMetadata overwrite (or if it crashes first).
452
+ mode: input.noUi ? 'headless' : 'interactive',
453
+ briefing: input.prompt,
430
454
  }, null, 2), { mode: 0o600 });
431
455
  }
432
456
  }
@@ -464,6 +488,10 @@ const handlers = {
464
488
  leg.messages = p.messages;
465
489
  leg.latestActivity = p.latest;
466
490
  leg.stalled = leg.status === 'running' && isStalled(p.lastActivityMs);
491
+ leg.stage = p.stage; // raw lifecycle stage
492
+ leg.phase = deriveStage(leg.status, p.stage); // coarse: starting|generating|folding|terminal
493
+ leg.latestPreview = p.latestPreview;
494
+ leg.lastActivityAt = p.lastActivityAt;
467
495
  } catch { /* no progress yet — leave base fields only */ }
468
496
  return leg;
469
497
  });
@@ -537,9 +565,17 @@ const handlers = {
537
565
  };
538
566
  if (metadata.model) { response.model = metadata.model; }
539
567
 
568
+ // F6: agent-visible mode (headless|interactive). metadata.mode is written at
569
+ // creation (CLI createSessionMetadata; MCP paths since F6); fall back to the
570
+ // headless boolean for records created before that.
571
+ if (metadata.mode) { response.mode = metadata.mode; }
572
+ else if (metadata.headless !== undefined) { response.mode = metadata.headless ? 'headless' : 'interactive'; }
573
+
540
574
  if (metadata.status === 'running') {
541
575
  const progress = readProgress(sessionDir);
542
- Object.assign(response, progress);
576
+ Object.assign(response, progress); // messages/latest/lastActivity/lastActivityMs/lastActivityAt/latestPreview/stage
577
+ response.messageCount = progress.messages; // stable agent-facing alias
578
+ response.phase = deriveStage(metadata.status, progress.stage); // coarse lifecycle
543
579
 
544
580
  // Stall detection: flag when no activity for 2+ minutes
545
581
  const STALL_THRESHOLD_MS = 120000;
@@ -554,6 +590,8 @@ const handlers = {
554
590
  if (metadata.headless) {
555
591
  response.next_poll = computeNextPoll();
556
592
  }
593
+ } else {
594
+ response.phase = deriveStage(metadata.status, undefined); // 'terminal'
557
595
  }
558
596
  if (metadata.status === 'crashed' || metadata.status === 'error') {
559
597
  response.reason = metadata.reason || 'Unknown error';
@@ -566,6 +604,14 @@ const handlers = {
566
604
  return { content };
567
605
  },
568
606
 
607
+ async amicus_wait(input, project) {
608
+ // statusFn injection avoids a circular require and inherits amicus_status's
609
+ // crash detection + wave leg rollup on every poll tick.
610
+ return runWait(input, project, {
611
+ statusFn: (i, p) => handlers.amicus_status(i, p),
612
+ });
613
+ },
614
+
569
615
  async amicus_read(input, project) {
570
616
  const cwd = project || getProjectDir(input.project);
571
617
  const sessionDir = safeSessionDir(cwd, input.taskId);
@@ -646,11 +692,25 @@ const handlers = {
646
692
  if (!fs.existsSync(metaPath)) { continue; }
647
693
  try {
648
694
  const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
649
- byId.set(d, {
695
+ const entry = {
650
696
  id: d, model: meta.model, status: meta.status, agent: meta.agent,
651
- briefing: (String(meta.briefing || '')).slice(0, 80),
697
+ briefing: sanitizePreview(String(meta.briefing || ''), 80),
652
698
  createdAt: meta.createdAt,
653
- });
699
+ mode: meta.mode
700
+ || (meta.headless === undefined ? undefined : (meta.headless ? 'headless' : 'interactive')),
701
+ };
702
+ // Live-progress enrichment for RUNNING sessions only — readProgress
703
+ // parses conversation.jsonl, so terminal rows stay cheap.
704
+ if (meta.status === 'running') {
705
+ try {
706
+ const p = readProgress(path.join(root, d));
707
+ entry.phase = deriveStage(meta.status, p.stage);
708
+ entry.messageCount = p.messages;
709
+ entry.lastActivityAt = p.lastActivityAt;
710
+ entry.latestPreview = p.latestPreview;
711
+ } catch { /* progress optional */ }
712
+ }
713
+ byId.set(d, entry);
654
714
  } catch {
655
715
  // Skip unreadable metadata
656
716
  }
@@ -733,18 +793,47 @@ const handlers = {
733
793
  return textResult(`Session ${input.taskId} is not running (status: ${metadata.status}).`);
734
794
  }
735
795
 
736
- if (metadata.pid) {
737
- try { process.kill(metadata.pid, 'SIGTERM'); } catch (err) {
738
- if (err.code !== 'ESRCH') {
739
- logger.warn('Failed to kill Amicus process', { pid: metadata.pid, error: err.message });
740
- }
796
+ const sessionDir = safeSessionDir(cwd, input.taskId);
797
+ const { markAborted } = require('./utils/session-abort');
798
+ const { waitThenKill } = require('./utils/abort-coordinator');
799
+
800
+ if (metadata.type === 'wave') {
801
+ // Order is load-bearing: mark every running leg aborted BEFORE any kill
802
+ // or wave-status write. A TerminateProcess'd orchestrator (Windows
803
+ // process.kill) runs no signal handlers, and writing the wave status
804
+ // first falsifies the crash-cascade gate in amicus_status — both used
805
+ // to strand legs 'running' forever. Legs poll their own marker (~2s),
806
+ // so a live orchestrator settles gracefully during the grace window.
807
+ let legsAborted = 0;
808
+ for (const legId of metadata.legs || []) {
809
+ try {
810
+ const legMeta = readMetadata(legId, cwd);
811
+ if (legMeta && legMeta.status === 'running' &&
812
+ markAborted(safeSessionDir(cwd, legId), 'wave abort (MCP)')) {
813
+ legsAborted++;
814
+ }
815
+ } catch { /* skip unreadable leg */ }
741
816
  }
817
+ markAborted(sessionDir, 'manual abort (MCP)');
818
+ // Fallback only: SIGTERM the orchestrator + its OWNED OpenCode server
819
+ // if they outlive the grace window. Fire-and-forget — the tool result
820
+ // must not block on the grace period.
821
+ waitThenKill([metadata.pid, metadata.goPid]).catch(() => { /* best-effort */ });
822
+ return textResult(JSON.stringify({
823
+ taskId: input.taskId, status: 'aborted', legsAborted,
824
+ message: `Wave abort requested. ${legsAborted} running leg(s) marked aborted; ` +
825
+ 'the fan-out process will terminate shortly.',
826
+ }));
742
827
  }
743
- const sessionDir = safeSessionDir(cwd, input.taskId);
744
- const metaPath = path.join(sessionDir, 'metadata.json');
745
- metadata.status = 'aborted';
746
- metadata.abortedAt = new Date().toISOString();
747
- fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2));
828
+
829
+ // Single session: marker FIRST — the headless loop and the interactive
830
+ // abort watch honor it within ~2s and tear down gracefully (mirror flush,
831
+ // usage persist, server-side abortSession). SIGTERM only a process that
832
+ // outlives the grace window. NEVER touch goPid here: on the shared-server
833
+ // path it is the server every session shares (pid is null there, so that
834
+ // path is marker-only by construction).
835
+ markAborted(sessionDir, 'manual abort (MCP)');
836
+ waitThenKill(metadata.pid).catch(() => { /* best-effort */ });
748
837
 
749
838
  return textResult(JSON.stringify({
750
839
  taskId: input.taskId, status: 'aborted',
@@ -885,10 +974,16 @@ const handlers = {
885
974
  async amicus_guide() { return textResult(getGuideText()); },
886
975
  };
887
976
 
888
- // DEPRECATED(amicus-shim): also register each tool under its legacy sidecar_*
889
- // name so existing agent scripts keep working. Remove in a future revision.
977
+ // DEPRECATED(amicus-shim): legacy sidecar_* twins of each amicus_* tool.
978
+ // OPT-IN since v1.8.0 registering both names doubled the advertised tool
979
+ // surface (14 -> 28 per server). Set AMICUS_LEGACY_ALIASES=1 in the MCP
980
+ // entry's "env" to restore them. A stdio MCP server cannot learn the
981
+ // client-side registration key it was launched under (initialize carries
982
+ // clientInfo, not the config key), so an env flag is the only reliable
983
+ // switch. Remove entirely in the next major.
890
984
  const LEGACY_TOOL_ALIASES = {
891
985
  amicus_start: 'sidecar_start', amicus_status: 'sidecar_status',
986
+ amicus_wait: 'sidecar_wait',
892
987
  amicus_read: 'sidecar_read', amicus_list: 'sidecar_list',
893
988
  amicus_resume: 'sidecar_resume', amicus_continue: 'sidecar_continue',
894
989
  amicus_setup: 'sidecar_setup', amicus_abort: 'sidecar_abort',
@@ -899,6 +994,11 @@ const LEGACY_TOOL_ALIASES = {
899
994
  amicus_verdict: 'sidecar_verdict',
900
995
  };
901
996
 
997
+ /** sidecar_* tool aliases are opt-in as of v1.8.0. */
998
+ function legacyAliasesEnabled(env = process.env) {
999
+ return env.AMICUS_LEGACY_ALIASES === '1';
1000
+ }
1001
+
902
1002
  /** Start the MCP server on stdio transport */
903
1003
  async function startMcpServer() {
904
1004
  const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
@@ -909,6 +1009,9 @@ async function startMcpServer() {
909
1009
  // can request them (roots/list) when no explicit project is supplied.
910
1010
  { capabilities: { roots: {} } }
911
1011
  );
1012
+ // Read once per call (not at module load) so tests and long-lived
1013
+ // processes observe the env deterministically.
1014
+ const withLegacyAliases = legacyAliasesEnabled();
912
1015
 
913
1016
  for (const tool of getTools()) {
914
1017
  const register = (name) => server.registerTool(
@@ -926,7 +1029,7 @@ async function startMcpServer() {
926
1029
  }
927
1030
  );
928
1031
  register(tool.name);
929
- if (LEGACY_TOOL_ALIASES[tool.name]) { register(LEGACY_TOOL_ALIASES[tool.name]); }
1032
+ if (withLegacyAliases && LEGACY_TOOL_ALIASES[tool.name]) { register(LEGACY_TOOL_ALIASES[tool.name]); }
930
1033
  }
931
1034
  process.on('SIGTERM', () => {
932
1035
  sharedServer.shutdown();
@@ -943,5 +1046,5 @@ async function startMcpServer() {
943
1046
 
944
1047
  module.exports = {
945
1048
  handlers, startMcpServer, getProjectDir, resolveProjectDir, getClientRoot,
946
- LEGACY_TOOL_ALIASES,
1049
+ LEGACY_TOOL_ALIASES, legacyAliasesEnabled,
947
1050
  };
package/src/mcp-tools.js CHANGED
@@ -130,6 +130,30 @@ function getTools() {
130
130
  ),
131
131
  },
132
132
  },
133
+ {
134
+ name: 'amicus_wait',
135
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
136
+ description:
137
+ 'Wait (block inside one tool call) until an Amicus session or fan-out wave ' +
138
+ 'reaches a terminal state, or until timeoutMs elapses. Returns the same JSON ' +
139
+ 'shape as amicus_status plus {timedOut, waitedMs} (and {hint} on timeout), with ' +
140
+ 'next_poll stripped/replaced. PREFER this over sleep+amicus_status polling for ' +
141
+ 'headless runs: one call replaces many polls. If it returns timedOut: true the ' +
142
+ 'run is still going — simply call amicus_wait again. Works for any session or ' +
143
+ 'wave, including ones started by other processes.',
144
+ inputSchema: {
145
+ taskId: safeTaskId.optional().describe('The session task ID (or wave ID) to wait on.'),
146
+ waveId: safeTaskId.optional().describe('Alias for taskId when waiting on a fan-out wave.'),
147
+ timeoutMs: z.number().int().min(1000).max(110000).optional().describe(
148
+ 'Max wait in milliseconds. Default 50000; capped at 110000 so the call ' +
149
+ 'returns before typical MCP client kill windows. On expiry the tool ' +
150
+ 'returns {timedOut: true} instead of erroring.'
151
+ ),
152
+ project: z.string().optional().describe(
153
+ 'Optional project directory path. Auto-detected from working directory if omitted.'
154
+ ),
155
+ },
156
+ },
133
157
  {
134
158
  name: 'amicus_read',
135
159
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
@@ -400,6 +424,9 @@ Amicus spawns parallel conversations with different LLMs and folds results back
400
424
  5. amicus_read to get the summary once complete
401
425
  6. Act on findings
402
426
 
427
+ (Alternative to steps 2-4: call amicus_wait with the task ID — one call blocks
428
+ up to ~50s and returns status; call it again while it returns timedOut: true.)
429
+
403
430
  **IMPORTANT:** Always run \`sleep 25\` before every amicus_status call. This is not optional. Each premature poll wastes context tokens for zero benefit. The sleep command enforces the wait mechanically.
404
431
 
405
432
  ### Interactive Mode (noUi: false, default)
@@ -412,7 +439,7 @@ Amicus spawns parallel conversations with different LLMs and folds results back
412
439
  ### Fan-Out (amicus_fanout)
413
440
  Run the SAME prompt across 1-10 models in parallel (one shared engine):
414
441
  1. amicus_fanout with models + prompt -> {waveId, taskIds[]}
415
- 2. sleep 25, then amicus_status with the waveId (repeat until done)
442
+ 2. sleep 25, then amicus_status with the waveId (repeat until done), or call amicus_wait with the waveId
416
443
  3. amicus_read the waveId -> aggregated JSON wave document (per-leg summaries inside)
417
444
  Each leg is an ordinary session: read/resume/continue it by taskId.
418
445
 
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Engine for the amicus_wait MCP tool: blocks inside one tool call until a session/wave reaches a terminal state or the wait window closes.
3
+ * @module mcp-wait
4
+ *
5
+ * Two wake sources, one loop:
6
+ * - disk polling of amicus_status (spawn-path CLI children, other-process
7
+ * sessions, waves), and
8
+ * - an in-process run registry: shared-server runs owned by THIS MCP process
9
+ * settle their waiter the moment finalizeHeadlessResult lands, waking the
10
+ * loop immediately instead of at the next poll tick.
11
+ *
12
+ * Client-timeout budget: the MCP TS SDK's default request timeout is 60s
13
+ * (DEFAULT_REQUEST_TIMEOUT_MSEC in @modelcontextprotocol/sdk shared/protocol).
14
+ * Claude Code can raise it via MCP_TOOL_TIMEOUT but we cannot assume it did,
15
+ * so the DEFAULT wait returns {timedOut:true} at 50s — before a 60s client
16
+ * kill — and the hard cap is 110s for clients with ~2min budgets.
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const { versionWarning } = require('./utils/version-info');
22
+
23
+ const DEFAULT_WAIT_MS = Number(process.env.AMICUS_WAIT_DEFAULT_MS) || 50000;
24
+ const MAX_WAIT_MS = Number(process.env.AMICUS_WAIT_MAX_MS) || 110000;
25
+ const MIN_WAIT_MS = 1000;
26
+ const WAIT_POLL_INTERVAL_MS = Number(process.env.AMICUS_WAIT_POLL_INTERVAL_MS) || 2000;
27
+
28
+ /** taskId -> {promise, resolve} for runs owned by this process. */
29
+ const _inProcessRuns = new Map();
30
+
31
+ /** Register a deferred for a run this process owns (shared-server path). */
32
+ function registerInProcessRun(taskId) {
33
+ let resolve;
34
+ const promise = new Promise((r) => { resolve = r; });
35
+ _inProcessRuns.set(taskId, { promise, resolve });
36
+ }
37
+
38
+ /** Settle (and forget) a run's waiter. Safe for unknown ids / double calls. */
39
+ function settleInProcessRun(taskId) {
40
+ const w = _inProcessRuns.get(taskId);
41
+ if (w) { _inProcessRuns.delete(taskId); w.resolve(); }
42
+ }
43
+
44
+ /** @returns {boolean} test hook */
45
+ function hasInProcessRun(taskId) { return _inProcessRuns.has(taskId); }
46
+
47
+ /** Clamp a requested timeout into [MIN, MAX]; default when absent/invalid. */
48
+ function clampTimeout(requested) {
49
+ const n = Number(requested);
50
+ if (!Number.isFinite(n) || n <= 0) { return Math.min(DEFAULT_WAIT_MS, MAX_WAIT_MS); }
51
+ return Math.max(MIN_WAIT_MS, Math.min(n, MAX_WAIT_MS));
52
+ }
53
+
54
+ /** unref'd sleep so a pending wait never holds the MCP process open. */
55
+ function defaultSleep(ms) {
56
+ return new Promise((resolve) => {
57
+ const t = setTimeout(resolve, ms);
58
+ if (t.unref) { t.unref(); }
59
+ });
60
+ }
61
+
62
+ /** Parse the JSON payload out of an amicus_status result, or null. */
63
+ function parseStatusPayload(statusResult) {
64
+ try { return JSON.parse(statusResult.content[0].text); } catch { return null; }
65
+ }
66
+
67
+ /**
68
+ * Terminal check over a parsed amicus_status payload. Any status other than
69
+ * running/unknown is terminal (TERMINAL_STATUSES omits 'timed-out', so an
70
+ * allowlist would miss the canonical single-session timeout status). Waves:
71
+ * terminal status wins; while the wave record still says 'running',
72
+ * all-legs-terminal also counts (aggregator may still be writing wave.json,
73
+ * but every leg has ended — the caller can read the legs).
74
+ */
75
+ function isTerminalSnapshot(s) {
76
+ const statusTerminal = !!s.status && s.status !== 'running' && s.status !== 'unknown';
77
+ if (s.type === 'wave') {
78
+ return statusTerminal
79
+ || (Number.isFinite(s.legsTotal) && s.legsTotal > 0 && s.legsComplete >= s.legsTotal);
80
+ }
81
+ return statusTerminal;
82
+ }
83
+
84
+ /** Build the amicus_wait MCP result: status payload + {timedOut, waitedMs}. */
85
+ function buildWaitResult(snapshot, timedOut, waitedMs) {
86
+ const body = { ...snapshot, timedOut, waitedMs };
87
+ delete body.next_poll; // amicus_wait replaces the sleep-25 polling protocol
88
+ if (timedOut) {
89
+ body.hint = 'Run still in progress when the wait window closed. Call amicus_wait again to continue waiting.';
90
+ }
91
+ const content = [{ type: 'text', text: JSON.stringify(body) }];
92
+ const warn = versionWarning();
93
+ if (warn) { content.push({ type: 'text', text: warn }); }
94
+ return { content };
95
+ }
96
+
97
+ /**
98
+ * Wait for a session/wave to reach a terminal state, or time out.
99
+ * @param {{taskId?:string, waveId?:string, timeoutMs?:number, project?:string}} input
100
+ * @param {string} project resolved project dir
101
+ * @param {{statusFn:Function, sleep?:Function, now?:Function, pollIntervalMs?:number}} deps
102
+ * statusFn(input, project) must be the amicus_status handler (or compatible).
103
+ * @returns {Promise<object>} MCP tool result
104
+ */
105
+ async function runWait(input, project, deps) {
106
+ const { statusFn } = deps;
107
+ const sleep = deps.sleep || defaultSleep;
108
+ const now = deps.now || Date.now;
109
+ const pollIntervalMs = deps.pollIntervalMs || WAIT_POLL_INTERVAL_MS;
110
+
111
+ const taskId = input.taskId || input.waveId;
112
+ if (!taskId) {
113
+ return { isError: true, content: [{ type: 'text', text: "amicus_wait requires 'taskId' (or 'waveId')." }] };
114
+ }
115
+
116
+ const timeoutMs = clampTimeout(input.timeoutMs);
117
+ const started = now();
118
+ const deadline = started + timeoutMs;
119
+
120
+ // Torn-read tolerance: a statusFn THROW, or a non-error result whose
121
+ // content[0].text fails to JSON.parse, is a MISSED TICK — not a hard
122
+ // failure. metadata.json is written with non-atomic fs.writeFileSync by
123
+ // several writers, and this loop reads it up to ~55x per call (2s cadence),
124
+ // multiplying exposure to a mid-write torn read vs the old 25s manual
125
+ // polling. Keep looping on a miss; only surface an error if the deadline
126
+ // passes without EVER having seen a valid snapshot.
127
+ let lastSnapshot = null;
128
+ let lastFailure = null;
129
+
130
+ for (;;) {
131
+ let snapshot = null;
132
+ try {
133
+ const statusResult = await statusFn({ taskId, project: input.project }, project);
134
+ if (statusResult.isError) { return statusResult; } // e.g. session not found — pinned behavior, unchanged
135
+ snapshot = parseStatusPayload(statusResult);
136
+ if (!snapshot) { lastFailure = `amicus_wait: unparseable status for ${taskId}.`; }
137
+ } catch (err) {
138
+ lastFailure = `amicus_wait: statusFn threw for ${taskId}: ${err.message}`;
139
+ }
140
+
141
+ if (snapshot) {
142
+ lastSnapshot = snapshot;
143
+ if (isTerminalSnapshot(snapshot)) { return buildWaitResult(snapshot, false, now() - started); }
144
+ }
145
+
146
+ const remaining = deadline - now();
147
+ if (remaining <= 0) {
148
+ if (lastSnapshot) { return buildWaitResult(lastSnapshot, true, now() - started); }
149
+ return { isError: true, content: [{ type: 'text', text: lastFailure || `amicus_wait: no valid status for ${taskId} before deadline.` }] };
150
+ }
151
+ const delay = Math.min(pollIntervalMs, remaining);
152
+ const waiter = _inProcessRuns.get(taskId);
153
+ // The waiter only ACCELERATES the wake — the sleep arm keeps the loop live
154
+ // for evicted/never-settled runs (disk polling stays authoritative).
155
+ await (waiter ? Promise.race([waiter.promise, sleep(delay)]) : sleep(delay));
156
+ }
157
+ }
158
+
159
+ module.exports = {
160
+ runWait, registerInProcessRun, settleInProcessRun, hasInProcessRun,
161
+ clampTimeout, isTerminalSnapshot, parseStatusPayload, buildWaitResult,
162
+ DEFAULT_WAIT_MS, MAX_WAIT_MS, WAIT_POLL_INTERVAL_MS,
163
+ };
@@ -44,6 +44,7 @@ function mirrorMessages(messages, state, opts = {}) {
44
44
  let currentAssistantMsgId = null;
45
45
  let assistantFinished = false;
46
46
  let sessionError = null;
47
+ let reasoningActivity = false;
47
48
  const list = Array.isArray(messages) ? messages : [];
48
49
  const messageCount = list.length;
49
50
 
@@ -127,15 +128,27 @@ function mirrorMessages(messages, state, opts = {}) {
127
128
  if (part.text.length > prevLen) {
128
129
  state.reasoningOutput += part.text.slice(prevLen);
129
130
  state.seenReasoningParts.set(partId, part.text.length);
130
- if (!state.receivingReported) {
131
- state.receivingReported = true;
132
- progressUpdates.push({ stage: 'receiving', extra: { messagesReceived: 1 } });
133
- }
131
+ reasoningActivity = true; // F6d: growth this poll = the model is thinking
134
132
  }
135
133
  }
136
134
  }
137
135
  }
138
136
 
137
+ // F6d: thinking IS activity. Emit ONE progress tick per poll with reasoning
138
+ // growth (only when no text/tool update already fired) so heartbeat/status
139
+ // show "Thinking…" and the stall detector resets during long pre-text
140
+ // reasoning — while a poll with NO growth still writes nothing, keeping
141
+ // genuine-stall detection intact. OpenCode's getMessages() exposes these
142
+ // deltas as growing part.type === 'reasoning' text.
143
+ if (reasoningActivity && progressUpdates.length === 0) {
144
+ const assistantCount = list.filter(m => m.info && m.info.role === 'assistant').length;
145
+ progressUpdates.push({
146
+ stage: 'receiving',
147
+ extra: { messagesReceived: Math.max(assistantCount, 1), stageLabel: 'Thinking…' },
148
+ });
149
+ state.receivingReported = true;
150
+ }
151
+
139
152
  // assistantFinished = true only when the LAST assistant message is complete
140
153
  // (earlier messages may finish while the model continues in new messages)
141
154
  const lastAssistant = list.filter(m => m.info && m.info.role === 'assistant').pop();
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { logger } = require('../utils/logger');
6
+
7
+ const DEFAULT_INTERVAL_MS = 2000;
8
+
9
+ /**
10
+ * Watch a session's metadata.json for an external abort marker
11
+ * (status === 'aborted', written by `amicus abort` or MCP amicus_abort) and
12
+ * tear the interactive session down when it appears:
13
+ * 1. best-effort server-side abortSession (stops token spend immediately),
14
+ * 2. SIGTERM the Electron process (killIfAlive).
15
+ * Teardown then completes through the EXISTING Electron close handler
16
+ * (mirror.stop → usage persist, server.close) — this watcher triggers
17
+ * teardown but never owns it. Best-effort: read/parse errors keep polling.
18
+ *
19
+ * @param {object} opts
20
+ * @param {string} opts.sessionDir
21
+ * @param {() => Promise<void>} opts.abortOpenCodeSession
22
+ * @param {() => void} opts.killElectron
23
+ * @param {number} [opts.intervalMs=2000]
24
+ * @returns {{ stop: () => void, wasAborted: () => boolean }}
25
+ */
26
+ function startAbortWatch({ sessionDir, abortOpenCodeSession, killElectron, intervalMs = DEFAULT_INTERVAL_MS }) {
27
+ const metaPath = path.join(sessionDir, 'metadata.json');
28
+ let timer = null;
29
+ let stopped = false;
30
+ let aborted = false;
31
+
32
+ const schedule = () => {
33
+ if (stopped) { return; }
34
+ timer = setTimeout(tick, intervalMs);
35
+ if (timer.unref) { timer.unref(); }
36
+ };
37
+
38
+ async function tick() {
39
+ if (stopped) { return; }
40
+ try {
41
+ if (fs.existsSync(metaPath)) {
42
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
43
+ if (meta.status === 'aborted') {
44
+ aborted = true;
45
+ stopped = true;
46
+ logger.info('External abort marker detected — tearing down interactive session', { sessionDir });
47
+ try { await abortOpenCodeSession(); } catch (err) {
48
+ logger.warn('abortSession failed during interactive abort', { error: err.message });
49
+ }
50
+ try { killElectron(); } catch (err) {
51
+ logger.warn('Electron kill failed during interactive abort', { error: err.message });
52
+ }
53
+ return; // teardown continues via the Electron close handler
54
+ }
55
+ }
56
+ } catch (err) {
57
+ logger.debug('Abort watch poll failed (best-effort)', { error: err.message });
58
+ }
59
+ schedule();
60
+ }
61
+
62
+ schedule();
63
+ return {
64
+ stop() { stopped = true; if (timer) { clearTimeout(timer); timer = null; } },
65
+ wasAborted() { return aborted; },
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Fold the abort-watch outcome into the runInteractive result so
71
+ * resolveTerminalState() maps a marker-triggered GUI teardown to 'aborted' —
72
+ * never 'error' (SIGTERM'd Electron exits non-zero) and never 'complete'
73
+ * (Electron exiting 0 after the marker landed).
74
+ */
75
+ function markResultAborted(result, wasAborted) {
76
+ if (wasAborted) {
77
+ result.aborted = true;
78
+ result.completed = false;
79
+ }
80
+ return result;
81
+ }
82
+
83
+ /**
84
+ * Best-effort, ONE-SHOT read of metadata.json to check for a durable
85
+ * 'aborted' marker written by `amicus abort` / MCP amicus_abort.
86
+ *
87
+ * Closes a race the poll-based startAbortWatch cannot: the marker can land
88
+ * and Electron can exit naturally before the watch's next ~2s tick (or the
89
+ * close handler can already be past markResultAborted, awaiting
90
+ * mirror.stop()). In that window abortWatch.wasAborted() reads false even
91
+ * though the session WAS aborted, so resolveTerminalState resolves
92
+ * 'complete' and finalizeSession clobbers the on-disk 'aborted' status.
93
+ * Callers should OR this into their aborted flag immediately before
94
+ * markResultAborted. Missing/corrupt metadata reads as false (best-effort,
95
+ * matches startAbortWatch's own error handling).
96
+ *
97
+ * @param {string} sessionDir
98
+ * @returns {boolean} true iff metadata.json exists and status === 'aborted'
99
+ */
100
+ function readAbortedMarker(sessionDir) {
101
+ try {
102
+ const metaPath = path.join(sessionDir, 'metadata.json');
103
+ if (!fs.existsSync(metaPath)) { return false; }
104
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
105
+ return meta.status === 'aborted';
106
+ } catch (err) {
107
+ logger.debug('readAbortedMarker: metadata read failed (best-effort)', { error: err.message });
108
+ return false;
109
+ }
110
+ }
111
+
112
+ module.exports = { startAbortWatch, markResultAborted, readAbortedMarker, DEFAULT_INTERVAL_MS };