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
package/src/mcp-server.js CHANGED
@@ -1,13 +1,14 @@
1
1
  /** @module mcp-server — Amicus MCP Server (stdio transport) */
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
+ const { writeFileAtomic } = require('./utils/atomic-write');
4
5
  const { spawn } = require('child_process');
5
6
  const { getTools, getGuideText } = require('./mcp-tools');
6
7
  const { tryResolveModel } = require('./utils/config');
7
8
  const os = require('os');
8
9
  const { logger } = require('./utils/logger');
9
10
  const { safeSessionDir } = require('./utils/validators');
10
- const { getSessionDir, SESSIONS_DIR, LEGACY_SESSIONS_DIR } = require('./session-manager');
11
+ const { getSessionDir, SESSIONS_DIR } = require('./session-manager');
11
12
  const { readProgress, isStalled } = require('./sidecar/progress');
12
13
  const { deriveStage, sanitizePreview } = require('./sidecar/progress-fields');
13
14
  const { SharedServerManager } = require('./utils/shared-server');
@@ -20,6 +21,7 @@ const { RUNNING_VERSION, versionWarning } = require('./utils/version-info');
20
21
  const { runWait, registerInProcessRun, settleInProcessRun } = require('./mcp-wait');
21
22
  const { detectClient } = require('./utils/client-detect');
22
23
  const { fenceSidecarOutput } = require('./utils/untrusted-fence');
24
+ const { sliceForRead } = require('./utils/read-slice');
23
25
 
24
26
  /**
25
27
  * Elapsed run duration: time between createdAt and the run's end, bounding the
@@ -42,6 +44,14 @@ function elapsedMs(metadata) {
42
44
  // coverage); 'idle-timeout' is the shared-server idle-eviction value.
43
45
  const FAILED_TERMINAL_STATUSES = ['error', 'crashed', 'timeout', 'timed-out', 'idle-timeout', 'aborted'];
44
46
 
47
+ // 15a.1: the statuses finalizeHeadlessResult's SUCCESS path can commit
48
+ // (resolveTerminalState's complete/aborted/timed-out outcomes — never 'error',
49
+ // which is finalizeHeadlessResult's OWN failure branch and must remain
50
+ // overwritable by a later genuine failure). Once one of these lands, a later
51
+ // throw in the same .then/.catch chain (e.g. removeSession) must not
52
+ // overwrite it with a fresh 'error' — see the shared-server .catch below.
53
+ const TERMINAL_STATUS_SET = new Set(['complete', 'aborted', 'timed-out']);
54
+
45
55
  const sharedServer = new SharedServerManager({ logger });
46
56
 
47
57
  /**
@@ -195,13 +205,13 @@ function appendVersionWarning(content) {
195
205
  */
196
206
  function computeNextPoll() {
197
207
  return {
198
- 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.',
208
+ hint: 'Preferred: call amicus_wait with this task ID — one blocking call replaces the sleep+status loop; re-call it while it returns timedOut: true. Fallback (no amicus_wait tool available): run `sleep 25` in your shell before calling amicus_status again. This enforces the wait and prevents token-wasting rapid polls.',
199
209
  wait_command: 'sleep 25',
200
210
  };
201
211
  }
202
212
 
203
- 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>';
204
- 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>';
213
+ const HEADLESS_START_REMINDER = '<system-reminder>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. Fallback (no amicus_wait tool available): 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>';
214
+ const HEADLESS_STATUS_REMINDER = '<system-reminder>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. Fallback (no amicus_wait tool available): 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>';
205
215
 
206
216
  /** Spawn an Amicus CLI process (fire-and-forget) */
207
217
  function spawnSidecarProcess(args, sessionDir) {
@@ -284,6 +294,7 @@ const handlers = {
284
294
  const { buildContext } = require('./sidecar/context-builder');
285
295
  const { buildPrompts } = require('./prompt-builder');
286
296
  const { runHeadless } = require('./headless');
297
+ const { generateFoldNonce } = require('./utils/fold-marker');
287
298
  const { finalizeHeadlessResult } = require('./sidecar/session-finalize');
288
299
  // resolvedModel is already available from validateStartInputs() above
289
300
 
@@ -299,7 +310,7 @@ const handlers = {
299
310
  recordSession(taskId, cwd); // #40: global index for cross-project lookup
300
311
  const metaPath = path.join(sessionDir, 'metadata.json');
301
312
  const serverPort = server.url ? new URL(server.url).port : null;
302
- fs.writeFileSync(metaPath, JSON.stringify({
313
+ writeFileAtomic(metaPath, JSON.stringify({
303
314
  taskId, status: 'running',
304
315
  pid: null, // Shared server path: don't store MCP server PID (abort would kill all sessions)
305
316
  opencodeSessionId: sessionId,
@@ -331,9 +342,12 @@ const handlers = {
331
342
  }
332
343
  }
333
344
 
345
+ // 15b.3: one nonce per run, generated before prompt construction.
346
+ const foldNonce = generateFoldNonce();
347
+
334
348
  // Build prompts (same as CLI path in start.js)
335
349
  const { system: systemPrompt, userMessage } = buildPrompts(
336
- input.prompt, context, cwd, true, agent, input.summaryLength
350
+ input.prompt, context, cwd, true, agent, input.summaryLength, undefined, foldNonce
337
351
  );
338
352
 
339
353
  // Register session with idle eviction
@@ -342,7 +356,7 @@ const handlers = {
342
356
  const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
343
357
  meta.status = 'idle-timeout';
344
358
  meta.completedAt = new Date().toISOString();
345
- fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
359
+ writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
346
360
  } catch (err) {
347
361
  logger.warn('Failed to update evicted session metadata', { error: err.message });
348
362
  }
@@ -366,6 +380,7 @@ const handlers = {
366
380
  // Not yet consumed downstream; threaded here so it's available the
367
381
  // moment a consumer (e.g. metadata/fold-output) needs it (12a.1/B02).
368
382
  amicusClient: detectedClient,
383
+ nonce: foldNonce,
369
384
  }
370
385
  ).then((result) => {
371
386
  // Session done — route through resolveTerminalState (same single source
@@ -377,16 +392,38 @@ const handlers = {
377
392
  } catch (finErr) {
378
393
  logger.warn('Failed to finalize session', { error: finErr.message });
379
394
  }
380
- sharedServer.removeSession(sessionId);
395
+ // Guarded: a throw here (e.g. a stale/evicted sessionId) must not
396
+ // escape into an unhandled rejection, and must not fall into .catch
397
+ // below and have its error-overwrite clobber the terminal status
398
+ // finalizeHeadlessResult just committed above.
399
+ try {
400
+ sharedServer.removeSession(sessionId);
401
+ } catch (removeErr) {
402
+ logger.warn('Failed to remove session from shared server', { taskId, error: removeErr.message });
403
+ }
381
404
  }).catch((err) => {
382
405
  logger.error('Shared server session failed', { taskId, error: err.message });
383
- sharedServer.removeSession(sessionId);
406
+ try {
407
+ sharedServer.removeSession(sessionId);
408
+ } catch (removeErr) {
409
+ logger.warn('Failed to remove session from shared server', { taskId, error: removeErr.message });
410
+ }
384
411
  try {
385
412
  const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
413
+ // A run that already committed a terminal status (complete/aborted/
414
+ // timed-out) must never be clobbered by a LATER in-chain throw (e.g.
415
+ // a bug after finalizeHeadlessResult) — only a genuine pre-terminal
416
+ // failure (runHeadless itself rejecting) should land here as 'error'.
417
+ if (TERMINAL_STATUS_SET.has(meta.status)) {
418
+ logger.warn('Shared server chain error after terminal status already committed — not overwriting', {
419
+ taskId, existingStatus: meta.status, error: err.message,
420
+ });
421
+ return;
422
+ }
386
423
  meta.status = 'error';
387
424
  meta.reason = err.message;
388
425
  meta.completedAt = new Date().toISOString();
389
- fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
426
+ writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
390
427
  } catch (writeErr) {
391
428
  logger.warn('Failed to write error metadata', { error: writeErr.message });
392
429
  }
@@ -433,7 +470,7 @@ const handlers = {
433
470
  recordSession(taskId, cwd); // #40: global index for cross-project lookup
434
471
  const metaPath = path.join(sessionDir, 'metadata.json');
435
472
  if (!fs.existsSync(metaPath)) {
436
- fs.writeFileSync(metaPath, JSON.stringify({
473
+ writeFileAtomic(metaPath, JSON.stringify({
437
474
  taskId, status: 'running', pid: child.pid, createdAt: new Date().toISOString(),
438
475
  headless: !!input.noUi,
439
476
  // Seed briefing/mode so list/status are informative even before the
@@ -490,27 +527,31 @@ const handlers = {
490
527
  // Crash-detection for hard-killed fanout processes: the wave branch
491
528
  // returns early, so the single-session pid probe below never runs here.
492
529
  if (metadata.status === 'running' && metadata.pid) {
493
- try { process.kill(metadata.pid, 0); } catch {
494
- const crashedAt = new Date().toISOString();
495
- Object.assign(metadata, {
496
- status: 'crashed', crashedAt,
497
- reason: 'Fan-out process exited unexpectedly',
498
- });
499
- fs.writeFileSync(path.join(sessionDir, 'metadata.json'),
500
- JSON.stringify(metadata, null, 2), { mode: 0o600 });
501
- // Cascade to legs whose pollers died with the parent
502
- for (const leg of legs) {
503
- if (leg.status === 'running') {
504
- const legMeta = readMetadata(leg.taskId, cwd);
505
- if (legMeta) {
506
- Object.assign(legMeta, {
507
- status: 'crashed', crashedAt,
508
- reason: 'Parent fan-out process killed',
509
- });
510
- fs.writeFileSync(
511
- path.join(getSessionDir(cwd, leg.taskId), 'metadata.json'),
512
- JSON.stringify(legMeta, null, 2), { mode: 0o600 });
513
- leg.status = 'crashed';
530
+ try { process.kill(metadata.pid, 0); } catch (err) {
531
+ // EPERM means the pid exists but we lack permission to signal it —
532
+ // that's ALIVE, not dead (mirrors utils/abort-coordinator.js isAlive).
533
+ if (err.code !== 'EPERM') {
534
+ const crashedAt = new Date().toISOString();
535
+ Object.assign(metadata, {
536
+ status: 'crashed', crashedAt,
537
+ reason: 'Fan-out process exited unexpectedly',
538
+ });
539
+ writeFileAtomic(path.join(sessionDir, 'metadata.json'),
540
+ JSON.stringify(metadata, null, 2), { mode: 0o600 });
541
+ // Cascade to legs whose pollers died with the parent
542
+ for (const leg of legs) {
543
+ if (leg.status === 'running') {
544
+ const legMeta = readMetadata(leg.taskId, cwd);
545
+ if (legMeta) {
546
+ Object.assign(legMeta, {
547
+ status: 'crashed', crashedAt,
548
+ reason: 'Parent fan-out process killed',
549
+ });
550
+ writeFileAtomic(
551
+ path.join(getSessionDir(cwd, leg.taskId), 'metadata.json'),
552
+ JSON.stringify(legMeta, null, 2), { mode: 0o600 });
553
+ leg.status = 'crashed';
554
+ }
514
555
  }
515
556
  }
516
557
  }
@@ -536,13 +577,17 @@ const handlers = {
536
577
  }
537
578
 
538
579
  if (metadata.status === 'running' && metadata.pid) {
539
- try { process.kill(metadata.pid, 0); } catch {
540
- Object.assign(metadata, {
541
- status: 'crashed', crashedAt: new Date().toISOString(),
542
- reason: 'Process exited unexpectedly',
543
- });
544
- fs.writeFileSync(path.join(sessionDir, 'metadata.json'),
545
- JSON.stringify(metadata, null, 2));
580
+ try { process.kill(metadata.pid, 0); } catch (err) {
581
+ // EPERM means the pid exists but we lack permission to signal it —
582
+ // that's ALIVE, not dead (mirrors utils/abort-coordinator.js isAlive).
583
+ if (err.code !== 'EPERM') {
584
+ Object.assign(metadata, {
585
+ status: 'crashed', crashedAt: new Date().toISOString(),
586
+ reason: 'Process exited unexpectedly',
587
+ });
588
+ writeFileAtomic(path.join(sessionDir, 'metadata.json'),
589
+ JSON.stringify(metadata, null, 2), { mode: 0o600 });
590
+ }
546
591
  }
547
592
  }
548
593
 
@@ -619,12 +664,16 @@ const handlers = {
619
664
  // Fence the whole wave.json text: it embeds each leg's folded-back
620
665
  // summary/error, which is untrusted model prose entering the parent
621
666
  // context (same blunt whole-text treatment as the single-session fence).
622
- return textResult(fenceSidecarOutput(fs.readFileSync(wavePath, 'utf-8')));
667
+ // Sliced BEFORE fencing (15a.3/B17) so the fence markup itself is
668
+ // never truncated.
669
+ const { body } = sliceForRead(fs.readFileSync(wavePath, 'utf-8'), input);
670
+ return textResult(fenceSidecarOutput(body));
623
671
  }
624
672
  const legsTotal = (readMeta.legs || []).length;
625
673
  const stillRunning = !readMeta.status || readMeta.status === 'running';
626
674
  const msg = stillRunning
627
- ? `Wave ${input.taskId} is still running (${legsTotal} legs). Poll amicus_status.`
675
+ ? `Wave ${input.taskId} is still running (${legsTotal} legs). Preferred: call amicus_wait ` +
676
+ 'with this waveId — one blocking call replaces polling. Fallback: poll amicus_status.'
628
677
  : `Wave ${input.taskId} ended with status '${readMeta.status}' before writing wave.json ` +
629
678
  '(fan-out may have been killed). Read individual legs by taskId, or use mode \'metadata\'.';
630
679
  return textResult(msg);
@@ -632,14 +681,22 @@ const handlers = {
632
681
 
633
682
  const mode = input.mode || 'summary';
634
683
  if (mode === 'metadata') {
635
- return textResult(fs.readFileSync(path.join(sessionDir, 'metadata.json'), 'utf-8'));
684
+ // metadata is small structured JSON: exempt from offset/limit/tail (a
685
+ // caller slicing JSON would just break parsing), but still cap-defended
686
+ // defensively — apply the SAME notice convention, left unfenced to
687
+ // match its unfenced (structured-data) status.
688
+ const metaText = fs.readFileSync(path.join(sessionDir, 'metadata.json'), 'utf-8');
689
+ const { body } = sliceForRead(metaText, {});
690
+ return textResult(body);
636
691
  }
637
692
  if (mode === 'conversation') {
638
693
  const convPath = path.join(sessionDir, 'conversation.jsonl');
639
694
  if (!fs.existsSync(convPath)) { return textResult('No conversation recorded.'); }
640
695
  // Fence the whole conversation dump in ONE fence (not per-line): it is
641
- // untrusted model prose entering the parent context.
642
- return textResult(fenceSidecarOutput(fs.readFileSync(convPath, 'utf-8')));
696
+ // untrusted model prose entering the parent context. Sliced BEFORE
697
+ // fencing (15a.3/B17) so the fence markup itself is never truncated.
698
+ const { body } = sliceForRead(fs.readFileSync(convPath, 'utf-8'), input);
699
+ return textResult(fenceSidecarOutput(body));
643
700
  }
644
701
  // Default: summary
645
702
  const summaryPath = path.join(sessionDir, 'summary.md');
@@ -667,49 +724,47 @@ const handlers = {
667
724
  // parent context (inbound mirror of prompt-builder's outbound fence). Same
668
725
  // fence also wraps wave-summary and conversation-mode reads above (B03);
669
726
  // mode=metadata and every --json contract stay unfenced (structured data).
670
- return textResult(fenceSidecarOutput(header + summaryText));
727
+ // Sliced BEFORE fencing (15a.3/B17). The model header is prepended before
728
+ // slicing, so it counts against offset/limit/the cap like the rest of the
729
+ // body — an explicit offset can page past it, same as any other prose.
730
+ const { body: slicedSummary } = sliceForRead(header + summaryText, input);
731
+ return textResult(fenceSidecarOutput(slicedSummary));
671
732
  },
672
733
 
673
734
  async amicus_list(input, project) {
674
735
  const cwd = project || getProjectDir(input.project);
675
- // Scan BOTH roots: canonical amicus first, then legacy sidecar (shim).
676
- const roots = [SESSIONS_DIR, LEGACY_SESSIONS_DIR]
677
- .map(d => path.join(cwd, '.claude', d))
678
- .filter(fs.existsSync);
679
- if (roots.length === 0) { return textResult('No amicus sessions found.'); }
736
+ const root = path.join(cwd, '.claude', SESSIONS_DIR);
737
+ if (!fs.existsSync(root)) { return textResult('No amicus sessions found.'); }
680
738
 
681
- // Dedup by task id — amicus (first root) wins over legacy.
682
739
  const byId = new Map();
683
- for (const root of roots) {
684
- for (const d of fs.readdirSync(root)) {
685
- if (!/^[a-zA-Z0-9_-]{1,64}$/.test(d)) { continue; }
686
- if (byId.has(d)) { continue; }
687
- const metaPath = path.join(root, d, 'metadata.json');
688
- if (!fs.existsSync(metaPath)) { continue; }
689
- try {
690
- const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
691
- const entry = {
692
- id: d, model: meta.model, status: meta.status, agent: meta.agent,
693
- briefing: sanitizePreview(String(meta.briefing || ''), 80),
694
- createdAt: meta.createdAt,
695
- mode: meta.mode
696
- || (meta.headless === undefined ? undefined : (meta.headless ? 'headless' : 'interactive')),
697
- };
698
- // Live-progress enrichment for RUNNING sessions only — readProgress
699
- // parses conversation.jsonl, so terminal rows stay cheap.
700
- if (meta.status === 'running') {
701
- try {
702
- const p = readProgress(path.join(root, d));
703
- entry.phase = deriveStage(meta.status, p.stage);
704
- entry.messageCount = p.messages;
705
- entry.lastActivityAt = p.lastActivityAt;
706
- entry.latestPreview = p.latestPreview;
707
- } catch { /* progress optional */ }
708
- }
709
- byId.set(d, entry);
710
- } catch {
711
- // Skip unreadable metadata
740
+ for (const d of fs.readdirSync(root)) {
741
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(d)) { continue; }
742
+ if (byId.has(d)) { continue; }
743
+ const metaPath = path.join(root, d, 'metadata.json');
744
+ if (!fs.existsSync(metaPath)) { continue; }
745
+ try {
746
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
747
+ const entry = {
748
+ id: d, model: meta.model, status: meta.status, agent: meta.agent,
749
+ briefing: sanitizePreview(String(meta.briefing || ''), 80),
750
+ createdAt: meta.createdAt,
751
+ mode: meta.mode
752
+ || (meta.headless === undefined ? undefined : (meta.headless ? 'headless' : 'interactive')),
753
+ };
754
+ // Live-progress enrichment for RUNNING sessions only — readProgress
755
+ // parses conversation.jsonl, so terminal rows stay cheap.
756
+ if (meta.status === 'running') {
757
+ try {
758
+ const p = readProgress(path.join(root, d));
759
+ entry.phase = deriveStage(meta.status, p.stage);
760
+ entry.messageCount = p.messages;
761
+ entry.lastActivityAt = p.lastActivityAt;
762
+ entry.latestPreview = p.latestPreview;
763
+ } catch { /* progress optional */ }
712
764
  }
765
+ byId.set(d, entry);
766
+ } catch {
767
+ // Skip unreadable metadata
713
768
  }
714
769
  }
715
770
 
@@ -881,7 +936,7 @@ const handlers = {
881
936
  // The prompt goes via file: the spawned command line must NOT carry it,
882
937
  // or it re-hits the ~32KB Windows argument cap (F4 spec §4.2).
883
938
  fs.writeFileSync(briefingPath, input.prompt, { mode: 0o600 });
884
- fs.writeFileSync(path.join(waveDir, 'metadata.json'), JSON.stringify({
939
+ writeFileAtomic(path.join(waveDir, 'metadata.json'), JSON.stringify({
885
940
  taskId: waveId, type: 'wave', status: 'running', legs: legIds,
886
941
  models: effectiveModels, headless: true, createdAt: new Date().toISOString(),
887
942
  }, null, 2), { mode: 0o600 });
@@ -915,14 +970,16 @@ const handlers = {
915
970
  try {
916
971
  const m = JSON.parse(fs.readFileSync(path.join(waveDir, 'metadata.json'), 'utf-8'));
917
972
  Object.assign(m, { status: 'error', reason: err.message, completedAt: new Date().toISOString() });
918
- fs.writeFileSync(path.join(waveDir, 'metadata.json'), JSON.stringify(m, null, 2), { mode: 0o600 });
973
+ writeFileAtomic(path.join(waveDir, 'metadata.json'), JSON.stringify(m, null, 2), { mode: 0o600 });
919
974
  } catch { /* best-effort */ }
920
975
  return textResult(`Failed to start fan-out: ${err.message}`, true);
921
976
  }
922
977
 
923
978
  const body = JSON.stringify({
924
979
  waveId, taskIds: legIds, status: 'running', mode: 'headless',
925
- message: 'Fan-out started. Poll amicus_status with the waveId; amicus_read the waveId when complete.',
980
+ message: 'Fan-out started. Preferred: call amicus_wait with the waveId one blocking call ' +
981
+ 'replaces polling; re-call it while it returns timedOut: true. Fallback: poll amicus_status ' +
982
+ 'with the waveId. Either way, amicus_read the waveId when complete.',
926
983
  });
927
984
  return { content: [{ type: 'text', text: body }, { type: 'text', text: HEADLESS_START_REMINDER }] };
928
985
  },
@@ -970,31 +1027,6 @@ const handlers = {
970
1027
  async amicus_guide() { return textResult(getGuideText()); },
971
1028
  };
972
1029
 
973
- // DEPRECATED(amicus-shim): legacy sidecar_* twins of each amicus_* tool.
974
- // OPT-IN since v1.8.0 — registering both names doubled the advertised tool
975
- // surface (14 -> 28 per server). Set AMICUS_LEGACY_ALIASES=1 in the MCP
976
- // entry's "env" to restore them. A stdio MCP server cannot learn the
977
- // client-side registration key it was launched under (initialize carries
978
- // clientInfo, not the config key), so an env flag is the only reliable
979
- // switch. Remove entirely in the next major.
980
- const LEGACY_TOOL_ALIASES = {
981
- amicus_start: 'sidecar_start', amicus_status: 'sidecar_status',
982
- amicus_wait: 'sidecar_wait',
983
- amicus_read: 'sidecar_read', amicus_list: 'sidecar_list',
984
- amicus_resume: 'sidecar_resume', amicus_continue: 'sidecar_continue',
985
- amicus_setup: 'sidecar_setup', amicus_abort: 'sidecar_abort',
986
- amicus_fanout: 'sidecar_fanout',
987
- amicus_guide: 'sidecar_guide',
988
- amicus_council_tally: 'sidecar_council_tally',
989
- amicus_council_stats: 'sidecar_council_stats',
990
- amicus_verdict: 'sidecar_verdict',
991
- };
992
-
993
- /** sidecar_* tool aliases are opt-in as of v1.8.0. */
994
- function legacyAliasesEnabled(env = process.env) {
995
- return env.AMICUS_LEGACY_ALIASES === '1';
996
- }
997
-
998
1030
  /** Start the MCP server on stdio transport */
999
1031
  async function startMcpServer() {
1000
1032
  const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
@@ -1005,9 +1037,6 @@ async function startMcpServer() {
1005
1037
  // can request them (roots/list) when no explicit project is supplied.
1006
1038
  { capabilities: { roots: {} } }
1007
1039
  );
1008
- // Read once per call (not at module load) so tests and long-lived
1009
- // processes observe the env deterministically.
1010
- const withLegacyAliases = legacyAliasesEnabled();
1011
1040
 
1012
1041
  for (const tool of getTools()) {
1013
1042
  const register = (name) => server.registerTool(
@@ -1025,7 +1054,6 @@ async function startMcpServer() {
1025
1054
  }
1026
1055
  );
1027
1056
  register(tool.name);
1028
- if (withLegacyAliases && LEGACY_TOOL_ALIASES[tool.name]) { register(LEGACY_TOOL_ALIASES[tool.name]); }
1029
1057
  }
1030
1058
  process.on('SIGTERM', () => {
1031
1059
  sharedServer.shutdown();
@@ -1042,5 +1070,4 @@ async function startMcpServer() {
1042
1070
 
1043
1071
  module.exports = {
1044
1072
  handlers, startMcpServer, getProjectDir, resolveProjectDir, getClientRoot,
1045
- LEGACY_TOOL_ALIASES, legacyAliasesEnabled,
1046
1073
  };
package/src/mcp-tools.js CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  const { z } = require('zod');
11
11
  const { formatAliasNames } = require('./utils/config');
12
+ const { READ_CAP_BYTES } = require('./utils/read-slice');
12
13
 
13
14
  /** Zod pattern for safe task IDs (alphanumeric, hyphens, underscores only) */
14
15
  const safeTaskId = z.string().regex(
@@ -43,8 +44,11 @@ function getTools() {
43
44
  'ALWAYS use HEADLESS (noUi: true) for all of them unless the user ' +
44
45
  'explicitly requests interactive. Opening multiple Electron windows ' +
45
46
  'at once is disruptive. ' +
46
- 'For headless mode, ALWAYS run `sleep 25` in your shell before each ' +
47
- 'amicus_status call to enforce the polling interval. ' +
47
+ 'For headless mode, prefer calling amicus_wait with the task ID one ' +
48
+ 'blocking call replaces the sleep+status loop; re-call it while it returns ' +
49
+ 'timedOut: true. Fallback (no amicus_wait tool available): ALWAYS run ' +
50
+ '`sleep 25` in your shell before each amicus_status call to enforce the ' +
51
+ 'polling interval. ' +
48
52
  'For interactive mode, do not poll. Wait for the user to tell you ' +
49
53
  'they\'ve clicked Fold, then use amicus_read. ' +
50
54
  'Call amicus_guide first if you need help choosing a model or writing a good briefing.' +
@@ -60,9 +64,9 @@ function getTools() {
60
64
  ),
61
65
  agent: z.enum(['Chat', 'Plan', 'Build']).optional()
62
66
  .default('Chat').describe(
63
- 'Agent mode. Chat (default): reads auto, writes ask ' +
64
- 'permission. Plan: read-only analysis. Build: full auto ' +
65
- '(all operations approved).'
67
+ 'Agent mode. Chat (interactive default; headless runs auto-convert ' +
68
+ 'to Build): reads auto, writes ask permission. Plan: read-only ' +
69
+ 'analysis. Build: full auto (all operations approved).'
66
70
  ),
67
71
  noUi: z.boolean().optional().default(false).describe(
68
72
  'Run headless without GUI. Default false (opens Electron window).'
@@ -159,14 +163,35 @@ function getTools() {
159
163
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
160
164
  description:
161
165
  'Read the results of a completed Amicus session. Returns the summary ' +
162
- 'by default, or full conversation history, or session metadata.',
166
+ 'by default, or full conversation history, or session metadata. ' +
167
+ 'Every mode is capped at ~50KB by default; when content exceeds the ' +
168
+ 'cap and no offset/limit/tail is given, the response is the TAIL of ' +
169
+ 'the content with a "[truncated: ...]" notice as the first line — use ' +
170
+ 'offset/limit or tail to page through the rest.',
163
171
  inputSchema: {
164
172
  taskId: safeTaskId.describe('The task ID to read.'),
165
173
  mode: z.enum(['summary', 'conversation', 'metadata']).optional()
166
174
  .default('summary').describe(
167
175
  'What to read. summary (default): the fold summary. ' +
168
- 'conversation: full message history. metadata: session info.'
176
+ 'conversation: full message history. metadata: session info ' +
177
+ '(always small; offset/limit/tail are ignored in this mode).'
169
178
  ),
179
+ offset: z.number().int().min(0).optional().describe(
180
+ 'Byte offset to start reading from (0-based). When given, no cap or ' +
181
+ 'truncation notice applies — the result is simply bounded by `limit` ' +
182
+ `(default ${READ_CAP_BYTES} bytes). Takes precedence over \`tail\` if ` +
183
+ 'both are given. Ignored in mode "metadata".'
184
+ ),
185
+ limit: z.number().int().min(1).max(READ_CAP_BYTES).optional().describe(
186
+ `Max bytes to return, 1-${READ_CAP_BYTES}. Defaults to the ${READ_CAP_BYTES}-byte ` +
187
+ 'cap. Ignored in mode "metadata".'
188
+ ),
189
+ tail: z.boolean().optional().describe(
190
+ 'Return the last `limit` bytes instead of the start. Ignored if ' +
191
+ '`offset` is also given, and ignored in mode "metadata". This is ' +
192
+ 'also the implicit default when content exceeds the cap and neither ' +
193
+ '`offset` nor `tail` is set.'
194
+ ),
170
195
  project: z.string().optional().describe(
171
196
  'Optional project directory path. Auto-detected from working directory if omitted.'
172
197
  ),
@@ -193,7 +218,8 @@ function getTools() {
193
218
  description:
194
219
  'Reopen a previous Amicus session with full conversation history ' +
195
220
  'preserved. The session continues in the same OpenCode session. ' +
196
- 'Returns a task ID immediately — use amicus_status to poll.',
221
+ 'Returns a task ID immediately — use amicus_wait to block until done ' +
222
+ '(or poll amicus_status).',
197
223
  inputSchema: {
198
224
  taskId: safeTaskId.describe(
199
225
  'The task ID of the session to resume.'
@@ -216,7 +242,7 @@ function getTools() {
216
242
  'Start a new Amicus session that inherits a previous session\'s ' +
217
243
  'conversation as context. The previous session\'s messages become ' +
218
244
  'read-only background for the new task. Returns a task ID ' +
219
- 'immediately — use amicus_status to poll.',
245
+ 'immediately — use amicus_wait to block until done (or poll amicus_status).',
220
246
  inputSchema: {
221
247
  taskId: safeTaskId.describe(
222
248
  'The task ID of the previous session to continue from.'
@@ -274,16 +300,20 @@ function getTools() {
274
300
  description:
275
301
  'Run N models on the SAME prompt in parallel (one shared engine) and ' +
276
302
  'aggregate the results. Headless only. Returns {waveId, taskIds[]} ' +
277
- 'immediately. Poll amicus_status with the waveId (run `sleep 25` between ' +
278
- 'polls); when done, amicus_read the waveId for the aggregated JSON wave ' +
279
- 'document (per-leg summaries inside). Each leg is also an ordinary ' +
280
- 'session readable by taskId.',
303
+ 'immediately. Preferred: call amicus_wait with the waveId one blocking ' +
304
+ 'call replaces polling; re-call it while it returns timedOut: true. ' +
305
+ 'Fallback (no amicus_wait tool available): poll amicus_status with the ' +
306
+ 'waveId (run `sleep 25` between polls). Either way, amicus_read the waveId ' +
307
+ 'when done for the aggregated JSON wave document (per-leg summaries ' +
308
+ 'inside). Each leg is also an ordinary session readable by taskId.',
281
309
  inputSchema: {
282
310
  models: z.array(safeModel).min(1).max(10).optional().describe(
283
311
  `1-10 models (2+ for genuine fan-out). Short aliases (${aliasNames}) or full provider/model IDs. Duplicates allowed. Omit when using 'council'.`
284
312
  ),
285
313
  council: z.string().optional().describe(
286
- "Run a saved council by name (e.g. 'free') instead of 'models'. Expands to the council's members. Mutually exclusive with 'models'."
314
+ "Run a saved council, or a built-in bench ('free', 'budget', 'frontier'), instead of 'models'. " +
315
+ "Expands to the council's members; a saved council of the same name shadows a built-in. " +
316
+ 'Mutually exclusive with \'models\'.'
287
317
  ),
288
318
  prompt: z.string().describe(
289
319
  'The briefing sent to every model. Self-contained briefings work best (set includeContext false).'
@@ -418,16 +448,18 @@ Amicus spawns parallel conversations with different LLMs and folds results back
418
448
 
419
449
  ### Headless Mode (noUi: true)
420
450
  1. amicus_start with model + prompt + noUi: true -> get task ID
421
- 2. Run \`sleep 25\` in your shell (this enforces the polling interval)
422
- 3. amicus_status to check progress
423
- 4. If still running, run \`sleep 25\` again before each subsequent amicus_status call
424
- 5. amicus_read to get the summary once complete
425
- 6. Act on findings
451
+ 2. **Preferred:** call amicus_wait with the task ID one blocking call (up to
452
+ ~50s) replaces the sleep+status loop; re-call it while it returns timedOut: true
453
+ 3. amicus_read to get the summary once complete
454
+ 4. Act on findings
426
455
 
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.)
456
+ **Fallback (only if amicus_wait is unavailable):**
457
+ 1. Run \`sleep 25\` in your shell (this enforces the polling interval)
458
+ 2. amicus_status to check progress
459
+ 3. If still running, run \`sleep 25\` again before each subsequent amicus_status call
460
+ 4. amicus_read to get the summary once complete
429
461
 
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.
462
+ **IMPORTANT (fallback path only):** 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.
431
463
 
432
464
  ### Interactive Mode (noUi: false, default)
433
465
  1. amicus_start with model + prompt -> get task ID
@@ -439,17 +471,19 @@ up to ~50s and returns status; call it again while it returns timedOut: true.)
439
471
  ### Fan-Out (amicus_fanout)
440
472
  Run the SAME prompt across 1-10 models in parallel (one shared engine):
441
473
  1. amicus_fanout with models + prompt -> {waveId, taskIds[]}
442
- 2. sleep 25, then amicus_status with the waveId (repeat until done), or call amicus_wait with the waveId
474
+ 2. **Preferred:** call amicus_wait with the waveId (re-call while timedOut: true). **Fallback:** sleep 25, then amicus_status with the waveId (repeat until done)
443
475
  3. amicus_read the waveId -> aggregated JSON wave document (per-leg summaries inside)
444
476
  Each leg is an ordinary session: read/resume/continue it by taskId.
445
477
 
446
478
  ## Agent Selection
447
479
  | Agent | Reads | Writes | Bash | Use When |
448
480
  |-------|-------|--------|------|----------|
449
- | Chat (default) | auto | asks | asks | Questions, analysis |
481
+ | Chat (interactive default*) | auto | asks | asks | Questions, analysis |
450
482
  | Plan | auto | denied | denied | Read-only analysis |
451
483
  | Build | auto | auto | auto | Implementation tasks |
452
484
 
485
+ * Headless (\`noUi\`) runs auto-convert Chat to Build — Chat would otherwise stall waiting on write/bash approval with no UI to approve it.
486
+
453
487
  ## Writing Good Briefings
454
488
  Include: Objective, Background, Files of interest, Success criteria, Constraints.
455
489
 
package/src/mcp-wait.js CHANGED
@@ -119,11 +119,14 @@ async function runWait(input, project, deps) {
119
119
 
120
120
  // Torn-read tolerance: a statusFn THROW, or a non-error result whose
121
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.
122
+ // failure. As of Phase 15 all metadata.json writers use writeFileAtomic
123
+ // (tmp+rename), so a torn read is no longer expected from any current
124
+ // writer; this tolerance remains as defense-in-depth for metadata.json
125
+ // files left by pre-upgrade writers and for exotic filesystems where
126
+ // rename isn't atomic. This loop reads it up to ~55x per call (2s cadence),
127
+ // multiplying exposure vs the old 25s manual polling. Keep looping on a
128
+ // miss; only surface an error if the deadline passes without EVER having
129
+ // seen a valid snapshot.
127
130
  let lastSnapshot = null;
128
131
  let lastFailure = null;
129
132