amicus 1.9.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +138 -0
  3. package/README.md +40 -170
  4. package/bin/amicus.js +14 -20
  5. package/commands/council.md +3 -1
  6. package/electron/fold.js +10 -1
  7. package/electron/ipc-setup.js +10 -15
  8. package/electron/main.js +21 -16
  9. package/electron/preload-setup.js +0 -1
  10. package/electron/setup-ui-council.js +64 -10
  11. package/electron/setup-ui-styles.js +34 -3
  12. package/electron/setup-ui.js +44 -12
  13. package/package.json +2 -5
  14. package/skills/second-opinion/MODEL-NOTES.md +2 -2
  15. package/skills/second-opinion/SKILL.md +24 -23
  16. package/skills/sidecar/SKILL.md +3 -3
  17. package/src/cli-handlers-council.js +101 -1
  18. package/src/cli-handlers-doctor.js +7 -0
  19. package/src/cli-handlers-run.js +4 -4
  20. package/src/cli-handlers-spend.js +198 -0
  21. package/src/cli.js +35 -0
  22. package/src/council/presets-cli.js +141 -0
  23. package/src/headless.js +146 -38
  24. package/src/index.js +1 -9
  25. package/src/mcp-server.js +132 -108
  26. package/src/mcp-tools.js +27 -3
  27. package/src/mcp-wait.js +8 -5
  28. package/src/opencode-client.js +33 -10
  29. package/src/prompt-builder.js +32 -11
  30. package/src/session-manager.js +7 -14
  31. package/src/sidecar/continue.js +12 -5
  32. package/src/sidecar/conversation-mirror.js +22 -1
  33. package/src/sidecar/crash-handler.js +2 -1
  34. package/src/sidecar/fanout-leg.js +12 -3
  35. package/src/sidecar/fanout.js +27 -10
  36. package/src/sidecar/interactive-process.js +6 -17
  37. package/src/sidecar/interactive.js +5 -6
  38. package/src/sidecar/models.js +33 -4
  39. package/src/sidecar/progress.js +2 -1
  40. package/src/sidecar/read.js +4 -6
  41. package/src/sidecar/resume.js +19 -4
  42. package/src/sidecar/session-finalize.js +2 -1
  43. package/src/sidecar/session-utils.js +13 -35
  44. package/src/sidecar/setup-window.js +2 -3
  45. package/src/sidecar/start.js +22 -7
  46. package/src/utils/abort-coordinator.js +57 -7
  47. package/src/utils/api-key-store.js +2 -13
  48. package/src/utils/config.js +30 -43
  49. package/src/utils/council-presets.js +87 -0
  50. package/src/utils/env-loader.js +1 -2
  51. package/src/utils/fold-marker.js +79 -0
  52. package/src/utils/idle-watchdog.js +9 -12
  53. package/src/utils/lifecycle.js +1 -1
  54. package/src/utils/mcp-discovery.js +29 -5
  55. package/src/utils/mcp-self-identity.js +12 -5
  56. package/src/utils/model-catalog.js +54 -6
  57. package/src/utils/read-slice.js +73 -0
  58. package/src/utils/remediation-hints.js +9 -0
  59. package/src/utils/result-schema.js +8 -2
  60. package/src/utils/session-abort.js +1 -1
  61. package/src/utils/session-index-tmp-sweep.js +80 -0
  62. package/src/utils/session-index.js +4 -5
  63. package/src/utils/session-path.js +6 -10
  64. package/src/utils/shared-server.js +7 -5
  65. package/src/utils/spend-ledger.js +80 -0
  66. package/src/utils/updater.js +2 -3
  67. package/src/utils/env-compat.js +0 -38
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
  /**
@@ -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,7 +664,10 @@ 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';
@@ -632,14 +680,22 @@ const handlers = {
632
680
 
633
681
  const mode = input.mode || 'summary';
634
682
  if (mode === 'metadata') {
635
- return textResult(fs.readFileSync(path.join(sessionDir, 'metadata.json'), 'utf-8'));
683
+ // metadata is small structured JSON: exempt from offset/limit/tail (a
684
+ // caller slicing JSON would just break parsing), but still cap-defended
685
+ // defensively — apply the SAME notice convention, left unfenced to
686
+ // match its unfenced (structured-data) status.
687
+ const metaText = fs.readFileSync(path.join(sessionDir, 'metadata.json'), 'utf-8');
688
+ const { body } = sliceForRead(metaText, {});
689
+ return textResult(body);
636
690
  }
637
691
  if (mode === 'conversation') {
638
692
  const convPath = path.join(sessionDir, 'conversation.jsonl');
639
693
  if (!fs.existsSync(convPath)) { return textResult('No conversation recorded.'); }
640
694
  // 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')));
695
+ // untrusted model prose entering the parent context. Sliced BEFORE
696
+ // fencing (15a.3/B17) so the fence markup itself is never truncated.
697
+ const { body } = sliceForRead(fs.readFileSync(convPath, 'utf-8'), input);
698
+ return textResult(fenceSidecarOutput(body));
643
699
  }
644
700
  // Default: summary
645
701
  const summaryPath = path.join(sessionDir, 'summary.md');
@@ -667,49 +723,47 @@ const handlers = {
667
723
  // parent context (inbound mirror of prompt-builder's outbound fence). Same
668
724
  // fence also wraps wave-summary and conversation-mode reads above (B03);
669
725
  // mode=metadata and every --json contract stay unfenced (structured data).
670
- return textResult(fenceSidecarOutput(header + summaryText));
726
+ // Sliced BEFORE fencing (15a.3/B17). The model header is prepended before
727
+ // slicing, so it counts against offset/limit/the cap like the rest of the
728
+ // body — an explicit offset can page past it, same as any other prose.
729
+ const { body: slicedSummary } = sliceForRead(header + summaryText, input);
730
+ return textResult(fenceSidecarOutput(slicedSummary));
671
731
  },
672
732
 
673
733
  async amicus_list(input, project) {
674
734
  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.'); }
735
+ const root = path.join(cwd, '.claude', SESSIONS_DIR);
736
+ if (!fs.existsSync(root)) { return textResult('No amicus sessions found.'); }
680
737
 
681
- // Dedup by task id — amicus (first root) wins over legacy.
682
738
  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
739
+ for (const d of fs.readdirSync(root)) {
740
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(d)) { continue; }
741
+ if (byId.has(d)) { continue; }
742
+ const metaPath = path.join(root, d, 'metadata.json');
743
+ if (!fs.existsSync(metaPath)) { continue; }
744
+ try {
745
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
746
+ const entry = {
747
+ id: d, model: meta.model, status: meta.status, agent: meta.agent,
748
+ briefing: sanitizePreview(String(meta.briefing || ''), 80),
749
+ createdAt: meta.createdAt,
750
+ mode: meta.mode
751
+ || (meta.headless === undefined ? undefined : (meta.headless ? 'headless' : 'interactive')),
752
+ };
753
+ // Live-progress enrichment for RUNNING sessions only — readProgress
754
+ // parses conversation.jsonl, so terminal rows stay cheap.
755
+ if (meta.status === 'running') {
756
+ try {
757
+ const p = readProgress(path.join(root, d));
758
+ entry.phase = deriveStage(meta.status, p.stage);
759
+ entry.messageCount = p.messages;
760
+ entry.lastActivityAt = p.lastActivityAt;
761
+ entry.latestPreview = p.latestPreview;
762
+ } catch { /* progress optional */ }
712
763
  }
764
+ byId.set(d, entry);
765
+ } catch {
766
+ // Skip unreadable metadata
713
767
  }
714
768
  }
715
769
 
@@ -881,7 +935,7 @@ const handlers = {
881
935
  // The prompt goes via file: the spawned command line must NOT carry it,
882
936
  // or it re-hits the ~32KB Windows argument cap (F4 spec §4.2).
883
937
  fs.writeFileSync(briefingPath, input.prompt, { mode: 0o600 });
884
- fs.writeFileSync(path.join(waveDir, 'metadata.json'), JSON.stringify({
938
+ writeFileAtomic(path.join(waveDir, 'metadata.json'), JSON.stringify({
885
939
  taskId: waveId, type: 'wave', status: 'running', legs: legIds,
886
940
  models: effectiveModels, headless: true, createdAt: new Date().toISOString(),
887
941
  }, null, 2), { mode: 0o600 });
@@ -915,7 +969,7 @@ const handlers = {
915
969
  try {
916
970
  const m = JSON.parse(fs.readFileSync(path.join(waveDir, 'metadata.json'), 'utf-8'));
917
971
  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 });
972
+ writeFileAtomic(path.join(waveDir, 'metadata.json'), JSON.stringify(m, null, 2), { mode: 0o600 });
919
973
  } catch { /* best-effort */ }
920
974
  return textResult(`Failed to start fan-out: ${err.message}`, true);
921
975
  }
@@ -970,31 +1024,6 @@ const handlers = {
970
1024
  async amicus_guide() { return textResult(getGuideText()); },
971
1025
  };
972
1026
 
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
1027
  /** Start the MCP server on stdio transport */
999
1028
  async function startMcpServer() {
1000
1029
  const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
@@ -1005,9 +1034,6 @@ async function startMcpServer() {
1005
1034
  // can request them (roots/list) when no explicit project is supplied.
1006
1035
  { capabilities: { roots: {} } }
1007
1036
  );
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
1037
 
1012
1038
  for (const tool of getTools()) {
1013
1039
  const register = (name) => server.registerTool(
@@ -1025,7 +1051,6 @@ async function startMcpServer() {
1025
1051
  }
1026
1052
  );
1027
1053
  register(tool.name);
1028
- if (withLegacyAliases && LEGACY_TOOL_ALIASES[tool.name]) { register(LEGACY_TOOL_ALIASES[tool.name]); }
1029
1054
  }
1030
1055
  process.on('SIGTERM', () => {
1031
1056
  sharedServer.shutdown();
@@ -1042,5 +1067,4 @@ async function startMcpServer() {
1042
1067
 
1043
1068
  module.exports = {
1044
1069
  handlers, startMcpServer, getProjectDir, resolveProjectDir, getClientRoot,
1045
- LEGACY_TOOL_ALIASES, legacyAliasesEnabled,
1046
1070
  };
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(
@@ -159,14 +160,35 @@ function getTools() {
159
160
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
160
161
  description:
161
162
  'Read the results of a completed Amicus session. Returns the summary ' +
162
- 'by default, or full conversation history, or session metadata.',
163
+ 'by default, or full conversation history, or session metadata. ' +
164
+ 'Every mode is capped at ~50KB by default; when content exceeds the ' +
165
+ 'cap and no offset/limit/tail is given, the response is the TAIL of ' +
166
+ 'the content with a "[truncated: ...]" notice as the first line — use ' +
167
+ 'offset/limit or tail to page through the rest.',
163
168
  inputSchema: {
164
169
  taskId: safeTaskId.describe('The task ID to read.'),
165
170
  mode: z.enum(['summary', 'conversation', 'metadata']).optional()
166
171
  .default('summary').describe(
167
172
  'What to read. summary (default): the fold summary. ' +
168
- 'conversation: full message history. metadata: session info.'
173
+ 'conversation: full message history. metadata: session info ' +
174
+ '(always small; offset/limit/tail are ignored in this mode).'
169
175
  ),
176
+ offset: z.number().int().min(0).optional().describe(
177
+ 'Byte offset to start reading from (0-based). When given, no cap or ' +
178
+ 'truncation notice applies — the result is simply bounded by `limit` ' +
179
+ `(default ${READ_CAP_BYTES} bytes). Takes precedence over \`tail\` if ` +
180
+ 'both are given. Ignored in mode "metadata".'
181
+ ),
182
+ limit: z.number().int().min(1).max(READ_CAP_BYTES).optional().describe(
183
+ `Max bytes to return, 1-${READ_CAP_BYTES}. Defaults to the ${READ_CAP_BYTES}-byte ` +
184
+ 'cap. Ignored in mode "metadata".'
185
+ ),
186
+ tail: z.boolean().optional().describe(
187
+ 'Return the last `limit` bytes instead of the start. Ignored if ' +
188
+ '`offset` is also given, and ignored in mode "metadata". This is ' +
189
+ 'also the implicit default when content exceeds the cap and neither ' +
190
+ '`offset` nor `tail` is set.'
191
+ ),
170
192
  project: z.string().optional().describe(
171
193
  'Optional project directory path. Auto-detected from working directory if omitted.'
172
194
  ),
@@ -283,7 +305,9 @@ function getTools() {
283
305
  `1-10 models (2+ for genuine fan-out). Short aliases (${aliasNames}) or full provider/model IDs. Duplicates allowed. Omit when using 'council'.`
284
306
  ),
285
307
  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'."
308
+ "Run a saved council, or a built-in bench ('free', 'budget', 'frontier'), instead of 'models'. " +
309
+ "Expands to the council's members; a saved council of the same name shadows a built-in. " +
310
+ 'Mutually exclusive with \'models\'.'
287
311
  ),
288
312
  prompt: z.string().describe(
289
313
  'The briefing sent to every model. Self-contained briefings work best (set includeContext false).'
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
 
@@ -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;