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