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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +200 -0
- package/README.md +40 -170
- package/bin/amicus.js +19 -107
- package/commands/council.md +7 -3
- 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 +30 -28
- package/skills/sidecar/SKILL.md +20 -17
- package/src/cli-handlers-abort.js +244 -0
- package/src/cli-handlers-council.js +101 -1
- package/src/cli-handlers-doctor.js +20 -53
- package/src/cli-handlers-resume-continue.js +103 -0
- package/src/cli-handlers-run.js +9 -8
- package/src/cli-handlers-spend.js +198 -0
- package/src/cli-handlers.js +5 -120
- package/src/cli.js +55 -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 +140 -113
- package/src/mcp-tools.js +58 -24
- 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 +34 -12
- 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 +41 -11
- 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/abort-result.js +36 -0
- package/src/utils/api-key-store.js +2 -13
- package/src/utils/cli-preflight.js +43 -0
- package/src/utils/config.js +30 -43
- package/src/utils/council-presets.js +87 -0
- package/src/utils/doctor-mcp-checks.js +84 -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/input-validators.js +52 -1
- package/src/utils/lifecycle.js +1 -1
- package/src/utils/mcp-discovery.js +80 -19
- 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-version.js +14 -0
- package/src/utils/result-schema.js +18 -12
- 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
|
/**
|
|
@@ -195,13 +205,13 @@ function appendVersionWarning(content) {
|
|
|
195
205
|
*/
|
|
196
206
|
function computeNextPoll() {
|
|
197
207
|
return {
|
|
198
|
-
hint: '
|
|
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>
|
|
204
|
-
const HEADLESS_STATUS_REMINDER = '<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
|
-
|
|
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,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
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
676
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
47
|
-
'
|
|
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
|
|
64
|
-
'permission. Plan: read-only
|
|
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
|
|
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
|
|
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.
|
|
278
|
-
'
|
|
279
|
-
'
|
|
280
|
-
'
|
|
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
|
|
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.
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
(
|
|
428
|
-
|
|
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)
|
|
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.
|
|
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
|
|