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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +138 -0
- package/README.md +40 -170
- package/bin/amicus.js +14 -20
- package/commands/council.md +3 -1
- package/electron/fold.js +10 -1
- package/electron/ipc-setup.js +10 -15
- package/electron/main.js +21 -16
- package/electron/preload-setup.js +0 -1
- package/electron/setup-ui-council.js +64 -10
- package/electron/setup-ui-styles.js +34 -3
- package/electron/setup-ui.js +44 -12
- package/package.json +2 -5
- package/skills/second-opinion/MODEL-NOTES.md +2 -2
- package/skills/second-opinion/SKILL.md +24 -23
- package/skills/sidecar/SKILL.md +3 -3
- package/src/cli-handlers-council.js +101 -1
- package/src/cli-handlers-doctor.js +7 -0
- package/src/cli-handlers-run.js +4 -4
- package/src/cli-handlers-spend.js +198 -0
- package/src/cli.js +35 -0
- package/src/council/presets-cli.js +141 -0
- package/src/headless.js +146 -38
- package/src/index.js +1 -9
- package/src/mcp-server.js +132 -108
- package/src/mcp-tools.js +27 -3
- package/src/mcp-wait.js +8 -5
- package/src/opencode-client.js +33 -10
- package/src/prompt-builder.js +32 -11
- package/src/session-manager.js +7 -14
- package/src/sidecar/continue.js +12 -5
- package/src/sidecar/conversation-mirror.js +22 -1
- package/src/sidecar/crash-handler.js +2 -1
- package/src/sidecar/fanout-leg.js +12 -3
- package/src/sidecar/fanout.js +27 -10
- package/src/sidecar/interactive-process.js +6 -17
- package/src/sidecar/interactive.js +5 -6
- package/src/sidecar/models.js +33 -4
- package/src/sidecar/progress.js +2 -1
- package/src/sidecar/read.js +4 -6
- package/src/sidecar/resume.js +19 -4
- package/src/sidecar/session-finalize.js +2 -1
- package/src/sidecar/session-utils.js +13 -35
- package/src/sidecar/setup-window.js +2 -3
- package/src/sidecar/start.js +22 -7
- package/src/utils/abort-coordinator.js +57 -7
- package/src/utils/api-key-store.js +2 -13
- package/src/utils/config.js +30 -43
- package/src/utils/council-presets.js +87 -0
- package/src/utils/env-loader.js +1 -2
- package/src/utils/fold-marker.js +79 -0
- package/src/utils/idle-watchdog.js +9 -12
- package/src/utils/lifecycle.js +1 -1
- package/src/utils/mcp-discovery.js +29 -5
- package/src/utils/mcp-self-identity.js +12 -5
- package/src/utils/model-catalog.js +54 -6
- package/src/utils/read-slice.js +73 -0
- package/src/utils/remediation-hints.js +9 -0
- package/src/utils/result-schema.js +8 -2
- package/src/utils/session-abort.js +1 -1
- package/src/utils/session-index-tmp-sweep.js +80 -0
- package/src/utils/session-index.js +4 -5
- package/src/utils/session-path.js +6 -10
- package/src/utils/shared-server.js +7 -5
- package/src/utils/spend-ledger.js +80 -0
- package/src/utils/updater.js +2 -3
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
676
|
-
|
|
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
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
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
|
|
package/src/opencode-client.js
CHANGED
|
@@ -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
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
},
|
|
570
|
-
|
|
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;
|