amicus 4.2.1 → 4.4.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 +46 -1
- package/README.md +8 -4
- package/bin/amicus.js +5 -0
- package/electron/ipc-workspace.js +283 -0
- package/electron/main.js +27 -0
- package/electron/preload-workspace.js +40 -0
- package/electron/workspace-shell.js +85 -0
- package/electron/workspace-ui/index.html +111 -0
- package/electron/workspace-ui/live-model.js +101 -0
- package/electron/workspace-ui/md-lite.js +119 -0
- package/electron/workspace-ui/workspace-app.js +240 -0
- package/electron/workspace-ui/workspace-matrix.js +212 -0
- package/electron/workspace-ui/workspace-panels.js +226 -0
- package/electron/workspace-ui/workspace-render.js +271 -0
- package/electron/workspace-ui/workspace-verbs.js +247 -0
- package/electron/workspace-ui/workspace.css +172 -0
- package/package.json +1 -1
- package/schemas/council-run-live.schema.json +57 -0
- package/schemas/council-run.schema.json +14 -0
- package/schemas/event.schema.json +15 -0
- package/schemas/progress.schema.json +37 -0
- package/schemas/run-live.schema.json +15 -0
- package/schemas/spend.schema.json +26 -1
- package/schemas/wave-live.schema.json +15 -0
- package/skills/second-opinion/MODEL-NOTES.md +53 -5
- package/src/cli-handlers-council-run.js +86 -8
- package/src/cli-handlers-run.js +26 -0
- package/src/cli-handlers-spend.js +94 -32
- package/src/cli-handlers-watch.js +116 -0
- package/src/cli.js +58 -1
- package/src/council/briefings.js +35 -2
- package/src/council/run-budget.js +224 -0
- package/src/council/run-chair.js +10 -2
- package/src/council/run-debate.js +5 -1
- package/src/council/run-launch.js +58 -7
- package/src/council/run-stages.js +30 -3
- package/src/council/run.js +44 -15
- package/src/headless.js +356 -15
- package/src/mcp-council-awareness.js +98 -3
- package/src/mcp-council-run.js +28 -4
- package/src/mcp-notify.js +54 -0
- package/src/mcp-server.js +51 -1
- package/src/mcp-spend.js +125 -0
- package/src/mcp-tools.js +39 -0
- package/src/mcp-wait.js +28 -2
- package/src/observe/council-legs.js +183 -0
- package/src/observe/events.js +156 -0
- package/src/observe/follow.js +26 -0
- package/src/observe/live-doc.js +56 -0
- package/src/observe/on-complete.js +117 -0
- package/src/observe/watch-render.js +168 -0
- package/src/opencode-client.js +15 -3
- package/src/sidecar/child-sessions.js +198 -0
- package/src/sidecar/continue.js +32 -0
- package/src/sidecar/conversation-mirror.js +111 -37
- package/src/sidecar/fallback-chains.js +65 -0
- package/src/sidecar/fanout-budget.js +71 -0
- package/src/sidecar/fanout-leg-fallback.js +189 -0
- package/src/sidecar/fanout-leg.js +81 -27
- package/src/sidecar/fanout-retry.js +208 -0
- package/src/sidecar/fanout-validate.js +42 -4
- package/src/sidecar/fanout.js +54 -41
- package/src/sidecar/progress.js +5 -0
- package/src/sidecar/resume.js +12 -0
- package/src/sidecar/start.js +13 -1
- package/src/sidecar/tool-part.js +196 -0
- package/src/sidecar/workspace-window.js +62 -0
- package/src/spend-query.js +119 -0
- package/src/utils/env-num.js +42 -0
- package/src/utils/error-classify.js +31 -0
- package/src/utils/model-tiers.js +1 -1
- package/src/utils/path-fence.js +82 -0
- package/src/utils/pricing.js +98 -9
- package/src/utils/spend-ledger.js +24 -1
- package/src/workspace/artifact-guard.js +187 -0
- package/src/workspace/blind-mode.js +32 -0
- package/src/workspace/fold-format.js +95 -0
- package/src/workspace/live-normalize.js +156 -0
- package/src/workspace/matrix-model.js +94 -0
- package/src/workspace/run-detail.js +223 -0
- package/src/workspace/run-scan.js +148 -0
package/src/mcp-council-run.js
CHANGED
|
@@ -16,6 +16,7 @@ const path = require('path');
|
|
|
16
16
|
const runState = require('./council/run-state');
|
|
17
17
|
const { fenceSidecarOutput } = require('./utils/untrusted-fence');
|
|
18
18
|
const { isPathInside } = require('./project-root-allowlist');
|
|
19
|
+
const { validateOnComplete, requestMcpNotify } = require('./mcp-notify');
|
|
19
20
|
|
|
20
21
|
function textResult(text, isError) {
|
|
21
22
|
const result = { content: [{ type: 'text', text }] };
|
|
@@ -23,7 +24,14 @@ function textResult(text, isError) {
|
|
|
23
24
|
return result;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the bench: models XOR council preset (amicus_fanout parity).
|
|
29
|
+
* Also returns `presetName` (v4.3 Task 3, spec §7.1): the trimmed council
|
|
30
|
+
* preset name when that branch was taken, else null — this handler always
|
|
31
|
+
* spawns the CLI child with an already-expanded `--models` list (never
|
|
32
|
+
* `--council`), so the preset name would otherwise be lost; the caller
|
|
33
|
+
* forwards it via the internal `--council-name` passthrough instead.
|
|
34
|
+
*/
|
|
27
35
|
function resolveBenchInput(input) {
|
|
28
36
|
const inputModels = Array.isArray(input.models) ? input.models : [];
|
|
29
37
|
const hasModels = inputModels.length > 0;
|
|
@@ -34,11 +42,12 @@ function resolveBenchInput(input) {
|
|
|
34
42
|
const { resolveCouncilMembers } = require('./utils/config');
|
|
35
43
|
const { readCache } = require('./utils/model-catalog');
|
|
36
44
|
const catalog = (readCache() || {}).models || [];
|
|
37
|
-
const
|
|
45
|
+
const presetName = input.council.trim();
|
|
46
|
+
const expanded = resolveCouncilMembers(presetName, catalog);
|
|
38
47
|
if (expanded.error) { return { error: expanded.error }; }
|
|
39
|
-
return { bench: expanded.models };
|
|
48
|
+
return { bench: expanded.models, presetName };
|
|
40
49
|
}
|
|
41
|
-
return { bench: inputModels };
|
|
50
|
+
return { bench: inputModels, presetName: null };
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
/**
|
|
@@ -49,6 +58,12 @@ function resolveBenchInput(input) {
|
|
|
49
58
|
* @param {{spawnFn: Function, clientName: string}} helpers injected by mcp-server
|
|
50
59
|
*/
|
|
51
60
|
async function handleCouncilRunTool(input, project, helpers) {
|
|
61
|
+
// Task 15 (spec §5.3): validate onComplete FIRST, before any run dir is
|
|
62
|
+
// prepared — exec strings are rejected over MCP (the Zod enum on the tool
|
|
63
|
+
// def already rejects them at the call boundary; this is defense-in-depth
|
|
64
|
+
// for any caller that bypasses schema validation).
|
|
65
|
+
const oc = validateOnComplete(input.onComplete);
|
|
66
|
+
if (!oc.ok) { return textResult(oc.error, true); }
|
|
52
67
|
const CHAIR_DEFAULT = 'deepseek';
|
|
53
68
|
if (typeof input.briefingFile !== 'string' || !input.briefingFile.trim()) {
|
|
54
69
|
return textResult("amicus_council_run requires 'briefingFile' (a path to the briefing).", true);
|
|
@@ -62,6 +77,7 @@ async function handleCouncilRunTool(input, project, helpers) {
|
|
|
62
77
|
const benchRes = resolveBenchInput(input);
|
|
63
78
|
if (benchRes.error) { return textResult(benchRes.error, true); }
|
|
64
79
|
const bench = benchRes.bench;
|
|
80
|
+
const presetName = benchRes.presetName;
|
|
65
81
|
if (bench.length < 2) { return textResult('A council needs at least 2 seats.', true); }
|
|
66
82
|
const chair = (typeof input.chair === 'string' && input.chair.trim()) ? input.chair.trim() : CHAIR_DEFAULT;
|
|
67
83
|
if (bench.includes(chair)) {
|
|
@@ -123,6 +139,10 @@ async function handleCouncilRunTool(input, project, helpers) {
|
|
|
123
139
|
if (input.timeoutMinutes) { args.push('--timeout', String(input.timeoutMinutes)); }
|
|
124
140
|
if (typeof input.maxCost === 'number') { args.push('--max-cost', String(input.maxCost)); }
|
|
125
141
|
if (input.gateway) { args.push('--gateway', input.gateway); }
|
|
142
|
+
// v4.3 Task 3 (spec §7.1): the bench above is already expanded, so `--council`
|
|
143
|
+
// itself is never spawned (it would collide with `--models`) — this internal,
|
|
144
|
+
// undocumented flag carries the preset NAME through for attribution only.
|
|
145
|
+
if (presetName) { args.push('--council-name', presetName); }
|
|
126
146
|
// v4.1 §4.5b/§4.5d. claudeReviewFile is resolved against `project` for the same
|
|
127
147
|
// reason outDir is — an MCP client may send a relative path, and the child's cwd
|
|
128
148
|
// is the run dir. Validation of the file itself stays in the spawned engine's
|
|
@@ -145,6 +165,10 @@ async function handleCouncilRunTool(input, project, helpers) {
|
|
|
145
165
|
// read-merge-write has no lock (see run-state.writeSpawnPid).
|
|
146
166
|
try { if (typeof child?.pid === 'number') { runState.writeSpawnPid(runDir, child.pid); } }
|
|
147
167
|
catch { /* best-effort */ }
|
|
168
|
+
// Task 15 (spec §5.3): the run is now known-launched under runId — mark it
|
|
169
|
+
// for a best-effort terminal notify. runWait's poll loop (mcp-wait.js) is
|
|
170
|
+
// the only code that later sees this council run reach terminal state.
|
|
171
|
+
if (oc.mode === 'mcp-notify') { requestMcpNotify(runId); }
|
|
148
172
|
|
|
149
173
|
const body = JSON.stringify({
|
|
150
174
|
schemaVersion: 2, type: 'council-run', runId, runDir, status: 'running',
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers + in-process registry for the MCP `onComplete: 'mcp-notify'`
|
|
3
|
+
* input (spec §5.3, Task 15).
|
|
4
|
+
* @module mcp-notify
|
|
5
|
+
*
|
|
6
|
+
* Security property (THE defining property of this module): exec is
|
|
7
|
+
* deliberately NOT exposed over MCP. A shell-exec tool input would be a
|
|
8
|
+
* prompt-injection amplifier — an MCP client acting on untrusted content
|
|
9
|
+
* could make amicus run arbitrary shell commands. So onComplete over MCP
|
|
10
|
+
* accepts ONLY 'mcp-notify'; any other value (especially a command string)
|
|
11
|
+
* is a validation error. Nothing is ever spawned/exec'd from this module or
|
|
12
|
+
* from the MCP path that consumes it — it only ever sends a notification.
|
|
13
|
+
*
|
|
14
|
+
* Delivery seam: amicus_fanout/amicus_council_run spawn a DETACHED CLI child
|
|
15
|
+
* and return 'running' immediately — there is no in-process finalize to
|
|
16
|
+
* notify from. Instead, a run that requested notify is marked here (mirrors
|
|
17
|
+
* mcp-wait.js's `_inProcessRuns` in-process Map), and the ONE place that
|
|
18
|
+
* later sees the run reach terminal state — the `runWait` poll loop in
|
|
19
|
+
* mcp-wait.js — consumes the mark and sends the notification. Advisory/
|
|
20
|
+
* best-effort throughout: a send failure never changes the run outcome.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
/** MCP onComplete accepts ONLY 'mcp-notify' — exec is deliberately not exposed
|
|
26
|
+
* over MCP (a shell-exec tool input would be a prompt-injection amplifier). */
|
|
27
|
+
function validateOnComplete(value) {
|
|
28
|
+
if (value === undefined || value === null) { return { ok: true, mode: null }; }
|
|
29
|
+
if (value === 'mcp-notify') { return { ok: true, mode: 'mcp-notify' }; }
|
|
30
|
+
return { ok: false, error: 'onComplete over MCP supports only \'mcp-notify\'; exec commands are not accepted over MCP.' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Wrap a terminal event doc as an MCP logging notification payload. */
|
|
34
|
+
function buildNotifyPayload(terminalEvent) {
|
|
35
|
+
return { level: 'info', logger: 'amicus', data: terminalEvent };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** taskId/runId -> true, for runs (owned by THIS MCP server process) that
|
|
39
|
+
* requested a best-effort mcp-notify on terminal. */
|
|
40
|
+
const _notifyRequests = new Map();
|
|
41
|
+
|
|
42
|
+
/** Mark a run for a best-effort terminal notify (called once the run's id is known and launch succeeded). */
|
|
43
|
+
function requestMcpNotify(taskId) {
|
|
44
|
+
_notifyRequests.set(taskId, true);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Once-semantics: true + delete on first read for a requested id; false (no-op) otherwise,
|
|
48
|
+
* including on a second call — so a re-wait on an already-terminal run never double-sends. */
|
|
49
|
+
function consumeMcpNotify(taskId) {
|
|
50
|
+
if (_notifyRequests.has(taskId)) { _notifyRequests.delete(taskId); return true; }
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { validateOnComplete, buildNotifyPayload, requestMcpNotify, consumeMcpNotify };
|
package/src/mcp-server.js
CHANGED
|
@@ -18,6 +18,7 @@ const { recordSession } = require('./utils/session-index');
|
|
|
18
18
|
const { fileURLToPath } = require('url');
|
|
19
19
|
const { RUNNING_VERSION, versionWarning } = require('./utils/version-info');
|
|
20
20
|
const { runWait, registerInProcessRun, settleInProcessRun } = require('./mcp-wait');
|
|
21
|
+
const { validateOnComplete, requestMcpNotify } = require('./mcp-notify');
|
|
21
22
|
const { detectClient } = require('./utils/client-detect');
|
|
22
23
|
const { fenceSidecarOutput } = require('./utils/untrusted-fence');
|
|
23
24
|
const { sliceForRead } = require('./utils/read-slice');
|
|
@@ -587,6 +588,7 @@ const handlers = {
|
|
|
587
588
|
}
|
|
588
589
|
|
|
589
590
|
if (metadata.type === 'wave') {
|
|
591
|
+
const { enrichLegUsage, markLive, rollupWaveUsage } = require('./observe/live-doc');
|
|
590
592
|
const legs = (metadata.legs || []).map((legId) => {
|
|
591
593
|
const m = readMetadata(legId, cwd);
|
|
592
594
|
const leg = { taskId: legId, model: (m && m.model) || null, status: (m && m.status) || 'unknown' };
|
|
@@ -599,6 +601,11 @@ const handlers = {
|
|
|
599
601
|
leg.phase = deriveStage(leg.status, p.stage); // coarse: starting|generating|folding|terminal
|
|
600
602
|
leg.latestPreview = p.latestPreview;
|
|
601
603
|
leg.lastActivityAt = p.lastActivityAt;
|
|
604
|
+
// N3: enrichLegUsage returns the bare leg (no `usage` key) when
|
|
605
|
+
// progress carries no usage yet (e.g. solo interactive legs, Task 8)
|
|
606
|
+
// — merge only when present so we never put an undefined key on doc.
|
|
607
|
+
const enriched = enrichLegUsage(leg, p.usage);
|
|
608
|
+
if (enriched.usage) { leg.usage = enriched.usage; }
|
|
602
609
|
} catch { /* no progress yet — leave base fields only */ }
|
|
603
610
|
return leg;
|
|
604
611
|
});
|
|
@@ -649,6 +656,11 @@ const handlers = {
|
|
|
649
656
|
if (metadata.status === 'crashed' || metadata.status === 'error') {
|
|
650
657
|
response.reason = metadata.reason || 'Unknown error';
|
|
651
658
|
}
|
|
659
|
+
// Surface C (spec 4.3): additive read-time usage rollup + live marker.
|
|
660
|
+
// sumWaveUsage tolerates legs with no usage (A8: cost-by-seat from
|
|
661
|
+
// progress.json only, never a ledger) — safe even when no leg priced.
|
|
662
|
+
response.usage = rollupWaveUsage(legs);
|
|
663
|
+
markLive(response);
|
|
652
664
|
const content = [{ type: 'text', text: JSON.stringify(response) }];
|
|
653
665
|
appendVersionWarning(content);
|
|
654
666
|
if (metadata.status === 'running') {
|
|
@@ -692,6 +704,17 @@ const handlers = {
|
|
|
692
704
|
response.messageCount = progress.messages; // stable agent-facing alias
|
|
693
705
|
response.phase = deriveStage(metadata.status, progress.stage); // coarse lifecycle
|
|
694
706
|
|
|
707
|
+
// Surface C (spec 4.3): read-time cost resolution over the RAW usage the
|
|
708
|
+
// Object.assign above just copied from progress.json (Task 8). N3: most
|
|
709
|
+
// interactive/GUI runs write no progress.usage at all — enrichLegUsage
|
|
710
|
+
// returns no `usage` key in that case, so NEVER assign it unconditionally
|
|
711
|
+
// (that would leave the doc holding the unresolved raw {tokens,costReported}
|
|
712
|
+
// shape, or an undefined key when progress.usage was absent).
|
|
713
|
+
const { enrichLegUsage } = require('./observe/live-doc');
|
|
714
|
+
const enr = enrichLegUsage({ model: metadata.model }, progress.usage);
|
|
715
|
+
if (enr.usage) { response.usage = enr.usage; }
|
|
716
|
+
else { delete response.usage; }
|
|
717
|
+
|
|
695
718
|
// Stall detection: flag when no activity for 2+ minutes
|
|
696
719
|
const STALL_THRESHOLD_MS = 120000;
|
|
697
720
|
if (metadata.headless && progress.lastActivityMs !== null && progress.lastActivityMs > STALL_THRESHOLD_MS) {
|
|
@@ -711,6 +734,7 @@ const handlers = {
|
|
|
711
734
|
if (metadata.status === 'crashed' || metadata.status === 'error') {
|
|
712
735
|
response.reason = metadata.reason || 'Unknown error';
|
|
713
736
|
}
|
|
737
|
+
require('./observe/live-doc').markLive(response);
|
|
714
738
|
const content = [{ type: 'text', text: JSON.stringify(response) }];
|
|
715
739
|
appendVersionWarning(content);
|
|
716
740
|
if (metadata.status === 'running' && metadata.headless) {
|
|
@@ -719,11 +743,20 @@ const handlers = {
|
|
|
719
743
|
return { content };
|
|
720
744
|
},
|
|
721
745
|
|
|
722
|
-
async amicus_wait(input, project) {
|
|
746
|
+
async amicus_wait(input, project, mcpServer) {
|
|
723
747
|
// statusFn injection avoids a circular require and inherits amicus_status's
|
|
724
748
|
// crash detection + wave leg rollup on every poll tick.
|
|
725
749
|
return runWait(input, project, {
|
|
726
750
|
statusFn: (i, p) => handlers.amicus_status(i, p),
|
|
751
|
+
// Task 15 (spec §5.3): best-effort mcp-notify delivery. mcpServer here
|
|
752
|
+
// is the McpServer instance the dispatch loop passes as the 3rd arg
|
|
753
|
+
// (server.js:register); mcpServer.server is the underlying low-level
|
|
754
|
+
// SDK Server that exposes sendLoggingMessage. A throw or an unsupported
|
|
755
|
+
// transport degrades silently — advisory only, never affects the wait.
|
|
756
|
+
notify: (payload) => {
|
|
757
|
+
try { if (mcpServer && mcpServer.server) { mcpServer.server.sendLoggingMessage(payload); } }
|
|
758
|
+
catch { /* best-effort; a send failure is a debug log only */ }
|
|
759
|
+
},
|
|
727
760
|
});
|
|
728
761
|
},
|
|
729
762
|
|
|
@@ -1026,6 +1059,12 @@ const handlers = {
|
|
|
1026
1059
|
|
|
1027
1060
|
async amicus_fanout(input, project, mcpServer) {
|
|
1028
1061
|
const cwd = project || getProjectDir(input.project);
|
|
1062
|
+
// Task 15 (spec §5.3): validate onComplete FIRST, before any wave dir /
|
|
1063
|
+
// metadata is written — exec strings are rejected over MCP (the Zod enum
|
|
1064
|
+
// on the tool def already rejects them at the call boundary; this is
|
|
1065
|
+
// defense-in-depth for any caller that bypasses schema validation).
|
|
1066
|
+
const oc = validateOnComplete(input.onComplete);
|
|
1067
|
+
if (!oc.ok) { return textResult(oc.error, true); }
|
|
1029
1068
|
const { generateTaskId } = require('./sidecar/start');
|
|
1030
1069
|
const { deriveLegIds, DEFAULT_MAX_LEGS } = require('./sidecar/fanout');
|
|
1031
1070
|
|
|
@@ -1110,6 +1149,10 @@ const handlers = {
|
|
|
1110
1149
|
} catch { /* best-effort */ }
|
|
1111
1150
|
return textResult(`Failed to start fan-out: ${err.message}`, true);
|
|
1112
1151
|
}
|
|
1152
|
+
// Task 15 (spec §5.3): the run is now known-launched under waveId — mark
|
|
1153
|
+
// it for a best-effort terminal notify. runWait's poll loop (mcp-wait.js)
|
|
1154
|
+
// is the only code that later sees this wave reach terminal state.
|
|
1155
|
+
if (oc.mode === 'mcp-notify') { requestMcpNotify(waveId); }
|
|
1113
1156
|
|
|
1114
1157
|
const body = JSON.stringify(stampEnvelope('wave', {
|
|
1115
1158
|
waveId, taskIds: legIds, status: 'running', mode: 'headless',
|
|
@@ -1196,6 +1239,13 @@ const handlers = {
|
|
|
1196
1239
|
return textResult('Setup wizard launched. The Electron window should appear on your desktop.');
|
|
1197
1240
|
},
|
|
1198
1241
|
async amicus_guide() { return textResult(getGuideText()); },
|
|
1242
|
+
|
|
1243
|
+
// Read-only; deliberately NOT fenced (spec 7.3 — spend docs are ids/numbers/
|
|
1244
|
+
// paths by construction, never model-generated prose). `project` here is the
|
|
1245
|
+
// dispatch-resolved cwd, used only to expand a literal '.' filterProject —
|
|
1246
|
+
// see src/mcp-spend.js's module docblock for why the row filter isn't named
|
|
1247
|
+
// `project` itself.
|
|
1248
|
+
amicus_spend: (input, project) => require('./mcp-spend').amicus_spend(input, project),
|
|
1199
1249
|
};
|
|
1200
1250
|
|
|
1201
1251
|
/** Start the MCP server on stdio transport */
|
package/src/mcp-spend.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// src/mcp-spend.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module mcp-spend
|
|
6
|
+
* Read-only `amicus_spend` MCP tool (spec §7.3, resolved Q1). Mirrors the CLI
|
|
7
|
+
* `amicus spend` query flags over spend-ledger.jsonl. No fence: spend docs are
|
|
8
|
+
* ids/numbers/paths by construction (never model-generated text) — the schema
|
|
9
|
+
* commits to keeping it that way, so unlike other sidecar-facing MCP tools
|
|
10
|
+
* this handler never calls fenceSidecarOutput. `buildSpendResult` is the pure
|
|
11
|
+
* core (dir/cwd DI seam, no I/O beyond readSpendRows); `amicus_spend` is the
|
|
12
|
+
* async MCP entry the server dispatch loop calls.
|
|
13
|
+
*
|
|
14
|
+
* Every query primitive (filterRows/groupRows/computeWasted/GROUP_DIMS/
|
|
15
|
+
* ROWS_CAP from ./spend-query, aggregateSpend/buildSpendDoc from
|
|
16
|
+
* ./cli-handlers-spend) is reused verbatim from Task 4 — nothing here
|
|
17
|
+
* reimplements filtering, grouping, or the wasted rollup.
|
|
18
|
+
*
|
|
19
|
+
* Row-filter naming note: the CLI's `--project` flag mirrors 1:1 onto every
|
|
20
|
+
* other filter EXCEPT this one, which is `filterProject` here rather than
|
|
21
|
+
* `project`. The MCP dispatch wrapper (mcp-server.js) treats an input key
|
|
22
|
+
* literally named `project` as the tool's own working-directory selector and
|
|
23
|
+
* resolves/validates it against the allowed project roots BEFORE the handler
|
|
24
|
+
* ever runs — throwing for a path outside them. That's the right behavior for
|
|
25
|
+
* a cwd selector; it is wrong for this field, which is a pure ledger-row
|
|
26
|
+
* filter that must be able to name ANY project the ledger has ever recorded
|
|
27
|
+
* spend for (the ledger itself is global, keyed by config dir, not by cwd —
|
|
28
|
+
* see readSpendRows). Reusing `project` here would make filtering by an
|
|
29
|
+
* out-of-roots historical project throw instead of returning rows. Hence the
|
|
30
|
+
* distinct name.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const { readSpendRows } = require('./utils/spend-ledger');
|
|
34
|
+
const { aggregateSpend, buildSpendDoc, parseSinceDays } = require('./cli-handlers-spend');
|
|
35
|
+
const { filterRows, groupRows, computeWasted, GROUP_DIMS, ROWS_CAP } = require('./spend-query');
|
|
36
|
+
const { buildErrorDoc, ERROR_CODES } = require('./utils/error-doc');
|
|
37
|
+
|
|
38
|
+
/** @param {string} message @param {string} hint @returns {{content:Array, isError:true}} */
|
|
39
|
+
function errorResult(message, hint) {
|
|
40
|
+
const doc = buildErrorDoc({ code: ERROR_CODES.BAD_ARGS, message, hint });
|
|
41
|
+
return { content: [{ type: 'text', text: JSON.stringify(doc) }], isError: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pure core: build the spend-doc MCP result for a given input + test seam.
|
|
46
|
+
* @param {{since?:string, wave?:string, council?:string, filterProject?:string,
|
|
47
|
+
* model?:string, op?:string, failed?:boolean, groupBy?:string, rows?:boolean}} [input]
|
|
48
|
+
* @param {{dir?:string, cwd?:string, now?:()=>number}} [ctx] test/DI seam — dir
|
|
49
|
+
* overrides the ledger's config dir (readSpendRows); cwd is the resolved
|
|
50
|
+
* project dir used to expand a literal '.' filterProject (CLI --project .
|
|
51
|
+
* parity); now overrides the clock used for `since` windowing (CLI --since
|
|
52
|
+
* test parity).
|
|
53
|
+
* @returns {{content:[{type:'text', text:string}], isError?:true}}
|
|
54
|
+
*/
|
|
55
|
+
function buildSpendResult(input = {}, ctx = {}) {
|
|
56
|
+
const groupBy = input.groupBy || 'model';
|
|
57
|
+
if (!GROUP_DIMS.includes(groupBy)) {
|
|
58
|
+
return errorResult(
|
|
59
|
+
`invalid groupBy '${groupBy}'`,
|
|
60
|
+
`groupBy one of: ${GROUP_DIMS.join('|')}`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let windowDays = null;
|
|
65
|
+
if (input.since !== undefined) {
|
|
66
|
+
windowDays = parseSinceDays(input.since);
|
|
67
|
+
if (windowDays === null) {
|
|
68
|
+
return errorResult(
|
|
69
|
+
`invalid since '${input.since}'`,
|
|
70
|
+
"since must be an integer followed by 'd' (e.g. '7d')"
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const rows = readSpendRows(ctx.dir);
|
|
76
|
+
const filters = {
|
|
77
|
+
wave: input.wave,
|
|
78
|
+
council: input.council,
|
|
79
|
+
model: input.model,
|
|
80
|
+
op: input.op,
|
|
81
|
+
failed: !!input.failed,
|
|
82
|
+
project: input.filterProject === '.' ? (ctx.cwd || process.cwd()) : input.filterProject,
|
|
83
|
+
};
|
|
84
|
+
const now = windowDays !== null ? (ctx.now ? ctx.now() : Date.now()) : undefined;
|
|
85
|
+
const filtered = filterRows(rows, { ...filters, since: windowDays, now });
|
|
86
|
+
const { total, byModel } = aggregateSpend(filtered);
|
|
87
|
+
const groups = groupRows(filtered, groupBy);
|
|
88
|
+
const wasted = computeWasted(filtered);
|
|
89
|
+
|
|
90
|
+
const doc = buildSpendDoc({
|
|
91
|
+
// credit stays null over MCP: the OpenRouter credit footer is a
|
|
92
|
+
// best-effort network probe the CLI path accepts blocking on —
|
|
93
|
+
// deliberately skipped here so a read-only local-file query never waits
|
|
94
|
+
// on the network. `since`/windowDays, by contrast, is a pure local
|
|
95
|
+
// filter (filterRows) with no network involved, so it IS threaded here.
|
|
96
|
+
total, byModel, windowDays, credit: null,
|
|
97
|
+
filters, groupBy, groups, wasted,
|
|
98
|
+
rows: input.rows ? filtered.slice(0, ROWS_CAP) : undefined,
|
|
99
|
+
rowsTruncated: input.rows ? filtered.length > ROWS_CAP : undefined,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Spec §7.3: spend docs are ids/numbers/paths only, by construction — never
|
|
103
|
+
// fenced (contrast amicus_council_stats etc., which DO fence because they
|
|
104
|
+
// summarize model-raised prose).
|
|
105
|
+
return { content: [{ type: 'text', text: JSON.stringify(doc) }] };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* MCP entry point. `project` is the dispatch-resolved cwd (mcp-server.js
|
|
110
|
+
* calls `handlers[name](input, project, server)`); this tool has no
|
|
111
|
+
* `project` input of its own (see module docblock), so it only uses it to
|
|
112
|
+
* expand a literal '.' `filterProject`. The 3rd positional slot is `server`
|
|
113
|
+
* in production (read only for `.dir`/`.now`, which it never has — a no-op)
|
|
114
|
+
* and a `{dir, now}` test seam in tests, mirroring handleSpend's
|
|
115
|
+
* depsOverride shape.
|
|
116
|
+
* @param {object} [input]
|
|
117
|
+
* @param {string} [project]
|
|
118
|
+
* @param {{dir?:string, now?:()=>number}} [testOverride]
|
|
119
|
+
* @returns {Promise<{content:Array, isError?:true}>}
|
|
120
|
+
*/
|
|
121
|
+
async function amicus_spend(input, project, testOverride = {}) {
|
|
122
|
+
return buildSpendResult(input || {}, { cwd: project, dir: testOverride.dir, now: testOverride.now });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { amicus_spend, buildSpendResult };
|
package/src/mcp-tools.js
CHANGED
|
@@ -11,6 +11,7 @@ const { z } = require('zod');
|
|
|
11
11
|
const { formatAliasNames } = require('./utils/config');
|
|
12
12
|
const { READ_CAP_BYTES } = require('./utils/read-slice');
|
|
13
13
|
const { GATEWAY_MODES } = require('./utils/model-descriptor');
|
|
14
|
+
const { GROUP_DIMS, ROWS_CAP } = require('./spend-query');
|
|
14
15
|
|
|
15
16
|
/** Zod pattern for safe task IDs (alphanumeric, hyphens, underscores only) */
|
|
16
17
|
const safeTaskId = z.string().regex(
|
|
@@ -360,6 +361,11 @@ function getTools() {
|
|
|
360
361
|
'Claude Code session UUID for exact context matching. ' +
|
|
361
362
|
'Prevents ambiguity when multiple sessions are active in the same project.'
|
|
362
363
|
),
|
|
364
|
+
onComplete: z.enum(['mcp-notify']).optional().describe(
|
|
365
|
+
'Advisory: send an MCP info notification carrying the terminal event doc when the run ' +
|
|
366
|
+
'finishes (best-effort; amicus_wait remains the reliable completion mechanism). Exec ' +
|
|
367
|
+
'commands are NOT accepted over MCP.'
|
|
368
|
+
),
|
|
363
369
|
project: z.string().optional().describe(
|
|
364
370
|
'Optional project directory path. Auto-detected from working directory if omitted.'
|
|
365
371
|
),
|
|
@@ -486,11 +492,44 @@ function getTools() {
|
|
|
486
492
|
'Disable the per-leg price gate for the WHOLE run (repairs and chair included). ' +
|
|
487
493
|
'Use for an intentional o3-class council. Independent of maxCost, which still caps the total.'
|
|
488
494
|
),
|
|
495
|
+
onComplete: z.enum(['mcp-notify']).optional().describe(
|
|
496
|
+
'Advisory: send an MCP info notification carrying the terminal event doc when the run ' +
|
|
497
|
+
'finishes (best-effort; amicus_wait remains the reliable completion mechanism). Exec ' +
|
|
498
|
+
'commands are NOT accepted over MCP.'
|
|
499
|
+
),
|
|
489
500
|
project: z.string().optional().describe(
|
|
490
501
|
'Optional project directory path. Auto-detected from working directory if omitted.'
|
|
491
502
|
),
|
|
492
503
|
},
|
|
493
504
|
},
|
|
505
|
+
{
|
|
506
|
+
name: 'amicus_spend',
|
|
507
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
508
|
+
description:
|
|
509
|
+
'Read-only cross-run cost rollup from the spend ledger (mirrors the CLI ' +
|
|
510
|
+
'`amicus spend` query flags). Filter by since/wave/council/project/model/op, keep ' +
|
|
511
|
+
'only failed (wasted) rows, and group by a dimension. Returns a versioned ' +
|
|
512
|
+
'spend doc (ids/numbers/paths only, never model-generated text) — never fenced.',
|
|
513
|
+
inputSchema: {
|
|
514
|
+
since: z.string().optional().describe(
|
|
515
|
+
"Only rows from the last N days, as an integer followed by 'd' (e.g. '7d')."
|
|
516
|
+
),
|
|
517
|
+
wave: z.string().optional().describe('Only rows from this fan-out wave id.'),
|
|
518
|
+
council: z.string().optional().describe('Only rows from this council run id or preset name.'),
|
|
519
|
+
filterProject: z.string().optional().describe(
|
|
520
|
+
'Only rows whose recorded project dir matches this absolute path (a ledger-row ' +
|
|
521
|
+
'filter, NOT this tool\'s working-directory selector — the ledger is global, not ' +
|
|
522
|
+
'per-project). Pass \'.\' to mean the current project dir.'
|
|
523
|
+
),
|
|
524
|
+
model: z.string().optional().describe('Only rows whose model id starts with this.'),
|
|
525
|
+
op: z.enum(['start', 'continue', 'resume', 'leg']).optional().describe('Only rows for this operation.'),
|
|
526
|
+
failed: z.boolean().optional().describe('Only non-complete (wasted) rows.'),
|
|
527
|
+
groupBy: z.enum(GROUP_DIMS).optional().describe(
|
|
528
|
+
`Group the rollup by this dimension (default 'model'). One of: ${GROUP_DIMS.join(', ')}.`
|
|
529
|
+
),
|
|
530
|
+
rows: z.boolean().optional().describe(`Include matching raw rows in the result, capped at ${ROWS_CAP}.`),
|
|
531
|
+
},
|
|
532
|
+
},
|
|
494
533
|
{
|
|
495
534
|
name: 'amicus_guide',
|
|
496
535
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
package/src/mcp-wait.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
'use strict';
|
|
20
20
|
|
|
21
21
|
const { versionWarning } = require('./utils/version-info');
|
|
22
|
+
const { consumeMcpNotify, buildNotifyPayload } = require('./mcp-notify');
|
|
22
23
|
|
|
23
24
|
const DEFAULT_WAIT_MS = Number(process.env.AMICUS_WAIT_DEFAULT_MS) || 50000;
|
|
24
25
|
const MAX_WAIT_MS = Number(process.env.AMICUS_WAIT_MAX_MS) || 110000;
|
|
@@ -98,8 +99,12 @@ function buildWaitResult(snapshot, timedOut, waitedMs) {
|
|
|
98
99
|
* Wait for a session/wave to reach a terminal state, or time out.
|
|
99
100
|
* @param {{taskId?:string, waveId?:string, timeoutMs?:number, project?:string}} input
|
|
100
101
|
* @param {string} project resolved project dir
|
|
101
|
-
* @param {{statusFn:Function, sleep?:Function, now?:Function, pollIntervalMs?:number}} deps
|
|
102
|
+
* @param {{statusFn:Function, sleep?:Function, now?:Function, pollIntervalMs?:number, notify?:Function}} deps
|
|
102
103
|
* statusFn(input, project) must be the amicus_status handler (or compatible).
|
|
104
|
+
* notify(payload), if given, is called at most once per run — only when the
|
|
105
|
+
* run requested it via requestMcpNotify (Task 15, spec §5.3) — right before
|
|
106
|
+
* returning a terminal result. Best-effort: a notify() throw is swallowed
|
|
107
|
+
* and never affects the wait result.
|
|
103
108
|
* @returns {Promise<object>} MCP tool result
|
|
104
109
|
*/
|
|
105
110
|
async function runWait(input, project, deps) {
|
|
@@ -143,7 +148,28 @@ async function runWait(input, project, deps) {
|
|
|
143
148
|
|
|
144
149
|
if (snapshot) {
|
|
145
150
|
lastSnapshot = snapshot;
|
|
146
|
-
if (isTerminalSnapshot(snapshot)) {
|
|
151
|
+
if (isTerminalSnapshot(snapshot)) {
|
|
152
|
+
// Task 15 (spec §5.3): best-effort mcp-notify delivery. This is the
|
|
153
|
+
// ONE place that sees a fanout/council-run reach terminal state (they
|
|
154
|
+
// spawn a detached CLI child and return 'running' immediately, so
|
|
155
|
+
// there is no in-process finalize to notify from). consumeMcpNotify
|
|
156
|
+
// gives once-semantics; a notify() throw is swallowed and never
|
|
157
|
+
// changes the wait result below. Consume FIRST (unconditionally on a
|
|
158
|
+
// requested run) so the registry entry always drains — even if this
|
|
159
|
+
// runWait caller supplied no notify capability, the entry must not
|
|
160
|
+
// leak; the send is then gated on deps.notify.
|
|
161
|
+
if (consumeMcpNotify(taskId) && deps.notify) {
|
|
162
|
+
try {
|
|
163
|
+
const evt = {
|
|
164
|
+
event: snapshot.type === 'council-run' ? 'run-terminal' : 'wave-terminal',
|
|
165
|
+
id: taskId, status: snapshot.status,
|
|
166
|
+
exitCode: (snapshot.exitCode === undefined || snapshot.exitCode === null) ? undefined : snapshot.exitCode,
|
|
167
|
+
};
|
|
168
|
+
deps.notify(buildNotifyPayload(evt));
|
|
169
|
+
} catch { /* advisory; never affects the wait result */ }
|
|
170
|
+
}
|
|
171
|
+
return buildWaitResult(snapshot, false, now() - started);
|
|
172
|
+
}
|
|
147
173
|
}
|
|
148
174
|
|
|
149
175
|
const remaining = deadline - now();
|