amicus 4.4.0 → 4.5.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 +162 -0
- package/README.md +17 -2
- package/bin/amicus.js +10 -0
- package/docs/DISTRIBUTION.md +234 -0
- package/docs/ROADMAP.md +226 -0
- package/docs/SHIMS.md +62 -0
- package/docs/architecture.md +104 -0
- package/docs/configuration.md +395 -0
- package/docs/council.md +970 -0
- package/docs/doc-system.md +92 -0
- package/docs/electron-testing.md +471 -0
- package/docs/jsdoc-setup.md +75 -0
- package/docs/opencode-integration.md +114 -0
- package/docs/publishing.md +60 -0
- package/docs/schemas.md +56 -0
- package/docs/testing.md +589 -0
- package/docs/troubleshooting.md +298 -0
- package/docs/usage.md +849 -0
- package/electron/fold.js +1 -1
- package/electron/main.js +4 -1
- package/electron/setup-ui-aliases.js +6 -6
- package/electron/workspace-ui/live-model.js +12 -1
- package/electron/workspace-ui/md-lite.js +52 -8
- package/electron/workspace-ui/workspace-app.js +39 -17
- package/electron/workspace-ui/workspace-matrix.js +46 -9
- package/electron/workspace-ui/workspace-panels.js +88 -19
- package/electron/workspace-ui/workspace-render.js +17 -1
- package/electron/workspace-ui/workspace-verbs.js +48 -2
- package/package.json +8 -3
- package/schemas/council-run-live.schema.json +1 -1
- package/schemas/council-run.schema.json +34 -0
- package/schemas/error.schema.json +1 -1
- package/schemas/event.schema.json +1 -1
- package/schemas/pack.schema.json +30 -0
- package/schemas/progress.schema.json +13 -1
- package/schemas/run-live.schema.json +1 -1
- package/schemas/run.schema.json +2 -1
- package/schemas/spend.schema.json +52 -4
- package/schemas/wave-live.schema.json +1 -1
- package/schemas/wave.schema.json +2 -1
- package/skills/second-opinion/SKILL.md +5 -0
- package/src/cli-handlers-council-run.js +51 -8
- package/src/cli-handlers-pack.js +238 -0
- package/src/cli-handlers-run.js +36 -8
- package/src/cli-handlers-spend.js +20 -2
- package/src/cli-handlers-template.js +53 -0
- package/src/cli-handlers-watch.js +11 -0
- package/src/cli.js +68 -5
- package/src/council/briefings-debate.js +27 -7
- package/src/council/briefings-stage2.js +155 -25
- package/src/council/briefings.js +24 -1
- package/src/council/findings.js +199 -9
- package/src/council/parse-stage2.js +10 -2
- package/src/council/presets-cli.js +23 -11
- package/src/council/report.js +19 -8
- package/src/council/run-assemble.js +42 -1
- package/src/council/run-budget.js +64 -11
- package/src/council/run-chair.js +4 -1
- package/src/council/run-debate.js +4 -2
- package/src/council/run-finalize.js +102 -0
- package/src/council/run-launch.js +29 -1
- package/src/council/run-server.js +248 -0
- package/src/council/run-stage2.js +118 -0
- package/src/council/run-stages.js +134 -110
- package/src/council/run-state.js +40 -1
- package/src/council/run.js +45 -47
- package/src/council/tally.js +10 -0
- package/src/headless.js +180 -7
- package/src/mcp-council-run.js +108 -4
- package/src/mcp-server.js +203 -7
- package/src/mcp-tools.js +15 -5
- package/src/observe/council-legs.js +60 -3
- package/src/observe/live-doc.js +18 -1
- package/src/observe/watch-render.js +4 -1
- package/src/pack/pack-cli.js +38 -0
- package/src/pack/pack-forward.js +96 -0
- package/src/pack/pack-resolve.js +297 -0
- package/src/pack/pack-store.js +130 -0
- package/src/pack/pack-validate.js +113 -0
- package/src/sidecar/child-sessions.js +1 -2
- package/src/sidecar/fanout-leg-fallback.js +69 -21
- package/src/sidecar/fanout-leg.js +6 -0
- package/src/sidecar/fanout-signals.js +61 -0
- package/src/sidecar/fanout-wave-io.js +75 -0
- package/src/sidecar/fanout.js +82 -74
- package/src/sidecar/progress-fields.js +26 -4
- package/src/sidecar/progress.js +42 -1
- package/src/sidecar/session-utils.js +23 -14
- package/src/sidecar/start.js +5 -4
- package/src/sidecar/workspace-auto-open.js +69 -0
- package/src/sidecar/workspace-window.js +46 -1
- package/src/spend-query.js +17 -5
- package/src/template/apply.js +88 -0
- package/src/template/render.js +86 -0
- package/src/template/store.js +106 -0
- package/src/utils/config.js +65 -25
- package/src/utils/error-doc.js +5 -0
- package/src/utils/lifecycle.js +37 -1
- package/src/utils/path-fence.js +39 -1
- package/src/utils/pricing.js +26 -10
- package/src/utils/result-schema-rebuild.js +1 -0
- package/src/utils/result-schema.js +8 -2
- package/src/utils/server-setup.js +79 -1
- package/src/utils/spend-ledger.js +24 -3
- package/src/workspace/artifact-guard.js +66 -7
- package/src/workspace/fold-format.js +33 -4
- package/src/workspace/live-normalize.js +28 -15
- package/src/workspace/run-detail.js +13 -1
package/src/headless.js
CHANGED
|
@@ -11,7 +11,7 @@ const { logger } = require('./utils/logger');
|
|
|
11
11
|
const { ensureNodeModulesBinInPath } = require('./utils/path-setup');
|
|
12
12
|
const { ensurePortAvailable } = require('./utils/server-setup');
|
|
13
13
|
const { mapAgentToOpenCode } = require('./utils/agent-mapping');
|
|
14
|
-
const { writeProgress } = require('./sidecar/progress');
|
|
14
|
+
const { writeProgress, writeTerminalProgressSafe } = require('./sidecar/progress');
|
|
15
15
|
const { writeFileAtomic } = require('./utils/atomic-write');
|
|
16
16
|
const { createMirrorState, mirrorMessages, logMessage, getPendingToolCalls,
|
|
17
17
|
getLiveToolCalls, mirrorUsageOnly, allAssistantUsagePresent } = require('./sidecar/conversation-mirror');
|
|
@@ -122,8 +122,22 @@ const USAGE_SETTLE_CALL_TIMEOUT_MS = envNumber('AMICUS_USAGE_SETTLE_CALL_TIMEOUT
|
|
|
122
122
|
* ON EXCEEDING IT the leg COMPLETES anyway — never fails — carrying
|
|
123
123
|
* `toolSettleTimedOut` on the result, the terminal progress record and the
|
|
124
124
|
* error log channel. Owner's standing ruling: fail LOUD, not fail CLOSED.
|
|
125
|
+
*
|
|
126
|
+
* v4.4.1 LC-2 (owner ruling, 2026-07-26): the leg's completion and its partial
|
|
127
|
+
* output are unchanged, but its OpenCode session is now ABORTED at the ceiling
|
|
128
|
+
* (see the finalization block) so it stops billing for work nobody will read.
|
|
125
129
|
*/
|
|
126
130
|
const TOOL_SETTLE_GRACE_MS = envNumber('AMICUS_TOOL_SETTLE_GRACE_MS', 300000);
|
|
131
|
+
/**
|
|
132
|
+
* v4.4.1 LC-2 — how long the ceiling's abort call may take before we stop
|
|
133
|
+
* waiting on it. A hard constant rather than an env knob (the same disposition
|
|
134
|
+
* as src/sidecar/child-sessions.js's bounds): it exists to stop a pathological
|
|
135
|
+
* hang, not to be tuned. The leg is already complete and already paid for when
|
|
136
|
+
* this runs, so an unbounded wait here would hold a finished answer hostage to a
|
|
137
|
+
* best-effort cost optimization — exactly the trade A-8 forbids. Injectable via
|
|
138
|
+
* `options.toolSettleAbortTimeoutMs` so the bound itself is testable.
|
|
139
|
+
*/
|
|
140
|
+
const TOOL_SETTLE_ABORT_TIMEOUT_MS = 5000;
|
|
127
141
|
|
|
128
142
|
/**
|
|
129
143
|
* Race a promise against a timeout. Returns the promise's result, or rejects with
|
|
@@ -258,12 +272,28 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
258
272
|
if (options.mcp) {
|
|
259
273
|
serverOptions.mcp = options.mcp;
|
|
260
274
|
}
|
|
261
|
-
|
|
275
|
+
// v4.4.1 fix wave (F5): this is the OTHER server-start site. It calls
|
|
276
|
+
// startServer directly rather than going through startOpenCodeServer, so
|
|
277
|
+
// the lock-class retry added for the concurrent-start race never covered
|
|
278
|
+
// it — a plain `amicus start` that lost the race still died on the first
|
|
279
|
+
// `database is locked`. "Two separate amicus processes contending" is half
|
|
280
|
+
// that retry's stated justification, and this is one of the two processes.
|
|
281
|
+
// Same bounded, narrow policy: 5 attempts (Step 10.5 widened it from 3),
|
|
282
|
+
// lock-class messages only, final failure rethrown unchanged into the
|
|
283
|
+
// degrade path below.
|
|
284
|
+
const { retryOnLockRace } = require('./utils/server-setup');
|
|
285
|
+
const result = await retryOnLockRace(() => startServer(serverOptions),
|
|
286
|
+
{ retryDelayMs: options.retryDelayMs });
|
|
262
287
|
client = result.client;
|
|
263
288
|
server = result.server;
|
|
264
289
|
logger.debug('Server started', { url: server.url });
|
|
265
290
|
} catch (error) {
|
|
266
291
|
logger.error('Failed to start OpenCode server', { error: error.message });
|
|
292
|
+
// FR-1: this return predates the outer try — A3's terminal write never ran
|
|
293
|
+
// for it, leaving 'initializing' on disk while metadata said 'error'.
|
|
294
|
+
if (!writeTerminalProgressSafe(sessionDir, `Failed to start server: ${error.message}`)) {
|
|
295
|
+
logger.debug('terminal progress write failed on server-start failure (best-effort)', { taskId });
|
|
296
|
+
}
|
|
267
297
|
return {
|
|
268
298
|
summary: '',
|
|
269
299
|
completed: false,
|
|
@@ -289,6 +319,10 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
289
319
|
|
|
290
320
|
if (!serverReady) {
|
|
291
321
|
await server.close();
|
|
322
|
+
// FR-1: returns out of the outer try — the A3 catch never sees it.
|
|
323
|
+
if (!writeTerminalProgressSafe(sessionDir, 'OpenCode server failed to start')) {
|
|
324
|
+
logger.debug('terminal progress write failed on server-not-ready (best-effort)', { taskId });
|
|
325
|
+
}
|
|
292
326
|
return {
|
|
293
327
|
summary: '',
|
|
294
328
|
completed: false,
|
|
@@ -326,6 +360,12 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
326
360
|
} catch (error) {
|
|
327
361
|
if (watchdog) { watchdog.cancel(); }
|
|
328
362
|
if (!externalServer) { await server.close(); }
|
|
363
|
+
// FR-1: the one path a council leg can hit under T0.5's shared server —
|
|
364
|
+
// pre-fix, the Workspace showed the seat perpetually live off a
|
|
365
|
+
// non-terminal 'server_ready' while metadata.json said 'error'.
|
|
366
|
+
if (!writeTerminalProgressSafe(sessionDir, error.message)) {
|
|
367
|
+
logger.debug('terminal progress write failed on createSession failure (best-effort)', { taskId });
|
|
368
|
+
}
|
|
329
369
|
return {
|
|
330
370
|
summary: '',
|
|
331
371
|
completed: false,
|
|
@@ -457,6 +497,8 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
457
497
|
// `=== undefined` rather than `||`: 0 is meaningful (disable the deferral).
|
|
458
498
|
const toolSettleGraceMs = options.toolSettleGraceMs === undefined
|
|
459
499
|
? TOOL_SETTLE_GRACE_MS : options.toolSettleGraceMs;
|
|
500
|
+
const toolSettleAbortTimeoutMs = options.toolSettleAbortTimeoutMs === undefined
|
|
501
|
+
? TOOL_SETTLE_ABORT_TIMEOUT_MS : options.toolSettleAbortTimeoutMs;
|
|
460
502
|
let consecutivePollFailures = 0;
|
|
461
503
|
let pollFailureBail = false;
|
|
462
504
|
let lastAssistantMsgId = null;
|
|
@@ -478,6 +520,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
478
520
|
let toolSettleDeferredSince = null; // ms timestamp of the first deferral, or null
|
|
479
521
|
let toolSettleTimedOut = false; // the grace ceiling was exceeded
|
|
480
522
|
let unsettledAtCeiling = []; // what was still live when it was exceeded
|
|
523
|
+
let toolSettleAborted = false; // LC-2: the ceiling's abort landed (see finalization)
|
|
481
524
|
|
|
482
525
|
/**
|
|
483
526
|
* Should this poll's completion signal be DEFERRED because a tool call has
|
|
@@ -917,6 +960,54 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
917
960
|
}
|
|
918
961
|
}
|
|
919
962
|
|
|
963
|
+
// ---- v4.4.1 LC-2: stop paying for a session nobody will read -------------
|
|
964
|
+
// OWNER RULING (2026-07-26). The leg is complete and runHeadless is returning,
|
|
965
|
+
// so nothing will ever read further session output — the outcome was already
|
|
966
|
+
// discarded by completing. Aborting here does not truncate an answer that
|
|
967
|
+
// would have been used; it stops paying for work nobody will read. The
|
|
968
|
+
// original objection ("stops the bleeding at the cost of truncating a
|
|
969
|
+
// possibly-healthy call") applied to aborting ON the completion route, where
|
|
970
|
+
// the call might still have mattered. Here it cannot.
|
|
971
|
+
//
|
|
972
|
+
// ORDER IS LOAD-BEARING, on BOTH sides:
|
|
973
|
+
// AFTER the child-session walk above — aborting first risks losing the
|
|
974
|
+
// subtree cost data v4.4.0 exists to capture, trading one silent
|
|
975
|
+
// under-report for another.
|
|
976
|
+
// BEFORE server.close() below — the abort is an SDK call and needs a live
|
|
977
|
+
// server. It is not redundant with close(): on a SHARED server (every
|
|
978
|
+
// council run) close() is never called here, and the session would go on
|
|
979
|
+
// billing against a server that outlives this leg.
|
|
980
|
+
//
|
|
981
|
+
// A-8 APPLIES: "never lose the answer" outranks "never report inaccurate
|
|
982
|
+
// usage". This is an optimization layered on an already-successful,
|
|
983
|
+
// already-paid-for leg, so every failure — rejection, hang, or a missing
|
|
984
|
+
// session id — is logged and dropped. Nothing here may alter `completed`,
|
|
985
|
+
// `summary`, `usage` or `error`. `toolSettleAborted: false` is the honest
|
|
986
|
+
// record of "we tried and could not; it may still be billing".
|
|
987
|
+
if (toolSettleTimedOut && sessionId) {
|
|
988
|
+
try {
|
|
989
|
+
const { abortSession } = require('./opencode-client');
|
|
990
|
+
await withTimeout(
|
|
991
|
+
abortSession(client, sessionId, ...dirArgs),
|
|
992
|
+
toolSettleAbortTimeoutMs,
|
|
993
|
+
'abortSession(tool-settle)',
|
|
994
|
+
);
|
|
995
|
+
toolSettleAborted = true;
|
|
996
|
+
// Task 6 review X2: a LANDED abort is the good outcome of a condition
|
|
997
|
+
// that is already logged at `error` (the ceiling itself). Logging the
|
|
998
|
+
// remedy at `warn` reads as a second problem; `info` reads honestly.
|
|
999
|
+
// The FAILED abort below stays at `warn` — that one really is a problem
|
|
1000
|
+
// ("it may still be billing").
|
|
1001
|
+
logger.info('Aborted the OpenCode session after the tool-settle ceiling', {
|
|
1002
|
+
taskId, sessionId, unsettled: unsettledAtCeiling.length,
|
|
1003
|
+
});
|
|
1004
|
+
} catch (abortErr) {
|
|
1005
|
+
toolSettleAborted = false;
|
|
1006
|
+
logger.warn('Could not abort the session after the tool-settle ceiling — it may '
|
|
1007
|
+
+ 'still be billing', { taskId, sessionId, error: abortErr.message });
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
920
1011
|
if (!externalServer) { await server.close(); }
|
|
921
1012
|
if (mirror.toolCalls.length > 0) {
|
|
922
1013
|
logger.info('Tool calls summary', {
|
|
@@ -958,8 +1049,13 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
958
1049
|
// live GUI reads this file (src/observe/live-doc.js). `unsettledToolCalls` is
|
|
959
1050
|
// a COUNT here (progress.json is a compact snapshot); the full list is on the
|
|
960
1051
|
// returned result for the caller's metadata.
|
|
1052
|
+
//
|
|
1053
|
+
// v4.4.1 LC-2: `toolSettleAborted` rides alongside it — `true` = the session
|
|
1054
|
+
// was told to stop, `false` = it may still be billing. Both are meaningful
|
|
1055
|
+
// ONLY when the ceiling was hit, so neither appears on a clean leg.
|
|
961
1056
|
const settleFlags = toolSettleTimedOut
|
|
962
|
-
? { toolSettleTimedOut: true, unsettledToolCalls: unsettledAtCeiling.length
|
|
1057
|
+
? { toolSettleTimedOut: true, unsettledToolCalls: unsettledAtCeiling.length,
|
|
1058
|
+
toolSettleAborted }
|
|
963
1059
|
: {};
|
|
964
1060
|
// v4.4 B4 (Task 2) + v4.4.1 CA-1: a leg that made a SUBAGENT call has spend
|
|
965
1061
|
// in a CHILD OpenCode session that is billed separately and is NOT rolled
|
|
@@ -981,15 +1077,46 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
981
1077
|
: {}),
|
|
982
1078
|
...(subtree.unknown ? { subtreeUnknown: true } : {}) }
|
|
983
1079
|
: (subagentToolCalls.length > 0 ? { subtreeUnknown: true } : {});
|
|
984
|
-
|
|
1080
|
+
// ---- v4.4.1 LC-3: the terminal stage is DERIVED, never hardcoded ---------
|
|
1081
|
+
// This write sits above BOTH returns below, so EVERY terminal path reaches
|
|
1082
|
+
// it — the external-abort break, the --timeout, the poll-failure bail, the
|
|
1083
|
+
// tool-call wedge — and it used to stamp 'complete' on all of them. The live
|
|
1084
|
+
// workspace reads progress.json directly (src/observe/live-doc.js
|
|
1085
|
+
// enrichLegUsage), so an aborted or errored leg rendered with a green check
|
|
1086
|
+
// until metadata.json landed. Fix the WRITER: src/observe/council-legs.js
|
|
1087
|
+
// already prefers metadata.json for a terminal leg and is NOT the problem.
|
|
1088
|
+
//
|
|
1089
|
+
// resolveTerminalState is the codebase's single source of truth for the
|
|
1090
|
+
// (completed, timedOut, aborted, error) → status mapping, and it is what
|
|
1091
|
+
// start.js / continue.js / resume.js / finalizeHeadlessResult will run on
|
|
1092
|
+
// THIS function's return value to stamp metadata.json. Deriving the stage
|
|
1093
|
+
// from it — rather than re-deriving a second, hand-rolled expression here —
|
|
1094
|
+
// is what guarantees progress.json's stage and metadata.json's status cannot
|
|
1095
|
+
// disagree. It also covers the two cases a hand-rolled `aborted ? … :
|
|
1096
|
+
// sessionError ? …` would get wrong: a TIMED-OUT leg (which would still have
|
|
1097
|
+
// read 'complete'), and the F1 case where a session error arrived alongside
|
|
1098
|
+
// usable output and the leg legitimately returns completed (which would have
|
|
1099
|
+
// read a false 'error').
|
|
1100
|
+
//
|
|
1101
|
+
// `failedWithNoUsableOutput` is hoisted out of the `if` below so the stage
|
|
1102
|
+
// and the returned shape are decided by ONE predicate and cannot drift.
|
|
1103
|
+
const failedWithNoUsableOutput = !!(sessionError && (!mirror.output || pollFailureBail || toolStalled));
|
|
1104
|
+
const { resolveTerminalState } = require('./sidecar/session-finalize');
|
|
1105
|
+
const terminalStage = resolveTerminalState({
|
|
1106
|
+
completed,
|
|
1107
|
+
timedOut,
|
|
1108
|
+
aborted,
|
|
1109
|
+
error: failedWithNoUsableOutput ? sessionError : null,
|
|
1110
|
+
}).status;
|
|
1111
|
+
try { writeProgress(sessionDir, terminalStage, { usage: { ...usage, ...subtreeProgress }, ...settleFlags }); }
|
|
985
1112
|
catch (progressErr) {
|
|
986
1113
|
logger.debug('terminal progress write failed (best-effort)', { taskId, error: progressErr.message });
|
|
987
1114
|
}
|
|
988
1115
|
const settleResult = toolSettleTimedOut
|
|
989
|
-
? { toolSettleTimedOut: true, unsettledToolCalls: unsettledAtCeiling }
|
|
1116
|
+
? { toolSettleTimedOut: true, unsettledToolCalls: unsettledAtCeiling, toolSettleAborted }
|
|
990
1117
|
: {};
|
|
991
1118
|
|
|
992
|
-
if (
|
|
1119
|
+
if (failedWithNoUsableOutput) {
|
|
993
1120
|
return {
|
|
994
1121
|
summary: mirror.output ? extractSummary(mirror.output, foldNonce) : '',
|
|
995
1122
|
completed: false,
|
|
@@ -1036,7 +1163,53 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
1036
1163
|
}
|
|
1037
1164
|
if (watchdog) { watchdog.cancel(); }
|
|
1038
1165
|
if (uninstallSignals) { uninstallSignals(); }
|
|
1039
|
-
|
|
1166
|
+
// ⚠️ v4.4.1 M2: guarded — this used to be a bare `await server.close()` sitting directly
|
|
1167
|
+
// above A3's terminal-write block, OUTSIDE any try, in the one place a close failure is
|
|
1168
|
+
// least affordable: the error handler. A rejection here would have skipped the terminal
|
|
1169
|
+
// progress write below entirely AND replaced the original `error` (the one A3 exists to
|
|
1170
|
+
// preserve) with this close failure instead. `close()` has already done its job of freeing
|
|
1171
|
+
// the port by the time we get here; a failure to close cleanly is not this handler's
|
|
1172
|
+
// problem to propagate, so it is logged and swallowed, same discipline as every other
|
|
1173
|
+
// best-effort close in this file (see the signal handler above).
|
|
1174
|
+
if (!externalServer) {
|
|
1175
|
+
try { await server.close(); } catch (closeErr) {
|
|
1176
|
+
logger.debug('server.close() failed in the outer exception handler (best-effort)', {
|
|
1177
|
+
taskId, error: closeErr.message,
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
// ⚠️ v4.4.1 A3 — the last hole in LC-3's story. LC-3 made the SUCCESS path's terminal
|
|
1182
|
+
// progress write derive its stage from resolveTerminalState instead of hardcoding 'complete',
|
|
1183
|
+
// so an aborted/errored/timed-out leg stopped rendering with a green check. This path — the
|
|
1184
|
+
// outer exception handler — wrote NO terminal progress record at all, so progress.json kept
|
|
1185
|
+
// whatever non-terminal stage the last flush left on it (usually 'receiving') while the
|
|
1186
|
+
// caller's finalizeHeadlessResult stamped metadata.json 'error' off the return value below.
|
|
1187
|
+
// The live workspace and `amicus watch` read progress.json DIRECTLY (live-doc.js
|
|
1188
|
+
// enrichLegUsage, council-legs.js), so a leg that exploded rendered as still-streaming
|
|
1189
|
+
// forever: exactly the stale-state class LC-3 closed one path over.
|
|
1190
|
+
//
|
|
1191
|
+
// ⚠️ This runs INSIDE an error handler: it must never throw and must never mask the original
|
|
1192
|
+
// error, so the whole thing sits in its own try and its failure is a debug line, exactly like
|
|
1193
|
+
// the success path's write. The stage comes from the same single source of truth that path
|
|
1194
|
+
// uses, so progress.json's stage and metadata.json's status still cannot disagree.
|
|
1195
|
+
//
|
|
1196
|
+
// ⚠️ The prior `usage` is READ BACK and re-attached deliberately. writeProgress REBUILDS
|
|
1197
|
+
// progress.json from `{stage, stageLabel, updatedAt, ...extra}` — it does not merge — so a
|
|
1198
|
+
// bare terminal write would silently delete whatever real spend the last 'receiving' flush had
|
|
1199
|
+
// already recorded, trading a stale-stage bug for a cost-under-report on exactly the legs that
|
|
1200
|
+
// failed. There are no settled totals on this path (that is what the exception cost us), so
|
|
1201
|
+
// carrying the last known usage forward unchanged is the honest maximum.
|
|
1202
|
+
//
|
|
1203
|
+
// v4.4.1 M3 — scope of "the last known ones": `usage` ONLY. That same 'receiving' flush also
|
|
1204
|
+
// wrote `p.extra` (e.g. `messagesReceived`), and that is deliberately left to drop here, not
|
|
1205
|
+
// carried forward too — the same call LC-3's success-path terminal write already made (see
|
|
1206
|
+
// that block's comment above): readProgress() derives `messages` from conversation.jsonl's
|
|
1207
|
+
// assistant entries directly and only falls back to `messagesReceived` when there are none, so
|
|
1208
|
+
// restating a stale count on an exception — where conversation.jsonl is the more truthful,
|
|
1209
|
+
// already-mirrored source — could only disagree with the file it exists to summarize.
|
|
1210
|
+
if (!writeTerminalProgressSafe(sessionDir, error.message)) {
|
|
1211
|
+
logger.debug('terminal progress write failed after exception (best-effort)', { taskId });
|
|
1212
|
+
}
|
|
1040
1213
|
const { emptyUsageTotals } = require('./utils/pricing');
|
|
1041
1214
|
return {
|
|
1042
1215
|
summary: '',
|
package/src/mcp-council-run.js
CHANGED
|
@@ -24,6 +24,22 @@ function textResult(text, isError) {
|
|
|
24
24
|
return result;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* v4.5 Task 15 (B7/F5): maps amicus_council_run's MCP input keys to the CLI
|
|
29
|
+
* arg-key names applyPackToArgs's knob tables use (pack-resolve.js), so
|
|
30
|
+
* applyPackToMcpInput can reuse those tables unchanged. `template` has no
|
|
31
|
+
* Zod-declared counterpart on this tool (MCP has no template param of its
|
|
32
|
+
* own — template/apply.js's own docblock: "MCP has no template params of its
|
|
33
|
+
* own") — a pack's briefing.template is the ONLY way a template reaches this
|
|
34
|
+
* handler, carried through as a plain (non-schema) `input.template` property
|
|
35
|
+
* consumed by the render step below.
|
|
36
|
+
*/
|
|
37
|
+
const COUNCIL_PACK_PARAM_MAP = {
|
|
38
|
+
models: 'models', council: 'council', chair: 'chair', critic: 'critic', lenses: 'lenses',
|
|
39
|
+
debate: 'debate', timeoutMinutes: 'timeout', maxCost: 'max-cost', gateway: 'gateway',
|
|
40
|
+
template: 'template',
|
|
41
|
+
};
|
|
42
|
+
|
|
27
43
|
/**
|
|
28
44
|
* Resolve the bench: models XOR council preset (amicus_fanout parity).
|
|
29
45
|
* Also returns `presetName` (v4.3 Task 3, spec §7.1): the trimmed council
|
|
@@ -45,9 +61,11 @@ function resolveBenchInput(input) {
|
|
|
45
61
|
const presetName = input.council.trim();
|
|
46
62
|
const expanded = resolveCouncilMembers(presetName, catalog);
|
|
47
63
|
if (expanded.error) { return { error: expanded.error }; }
|
|
48
|
-
|
|
64
|
+
// v4.5 Wave 2: the child never re-resolves (bench is spawned pre-expanded
|
|
65
|
+
// to --models) — the pre-seed below is the only place this is recorded.
|
|
66
|
+
return { bench: expanded.models, presetName, droppedMembers: expanded.droppedMembers || [] };
|
|
49
67
|
}
|
|
50
|
-
return { bench: inputModels, presetName: null };
|
|
68
|
+
return { bench: inputModels, presetName: null, droppedMembers: [] };
|
|
51
69
|
}
|
|
52
70
|
|
|
53
71
|
/**
|
|
@@ -55,7 +73,8 @@ function resolveBenchInput(input) {
|
|
|
55
73
|
* {runId, runDir} immediately (fenced).
|
|
56
74
|
* @param {object} input tool input
|
|
57
75
|
* @param {string} project resolved project dir
|
|
58
|
-
* @param {{spawnFn: Function, clientName: string}} helpers
|
|
76
|
+
* @param {{spawnFn: Function, clientName: string, autoOpen?: {decide: Function, launch: Function}}} helpers
|
|
77
|
+
* injected by mcp-server; `autoOpen` is a v4.5 test seam (real modules used when absent)
|
|
59
78
|
*/
|
|
60
79
|
async function handleCouncilRunTool(input, project, helpers) {
|
|
61
80
|
// Task 15 (spec §5.3): validate onComplete FIRST, before any run dir is
|
|
@@ -74,10 +93,45 @@ async function handleCouncilRunTool(input, project, helpers) {
|
|
|
74
93
|
if (briefing.charCodeAt(0) === 0xFEFF) { briefing = briefing.slice(1); }
|
|
75
94
|
if (!briefing.trim()) { return textResult(`briefingFile ${input.briefingFile} is empty.`, true); }
|
|
76
95
|
|
|
96
|
+
// v4.5 Task 15 (B7/F5): resolve `pack` IN-PROCESS, before bench/chair/etc
|
|
97
|
+
// resolution, so a pack-filled input.models/council/chair/critic/lenses/
|
|
98
|
+
// timeoutMinutes/maxCost/gateway/debate flows through the SAME validation
|
|
99
|
+
// below a typed value would (single-resolution rule: never spawn --pack —
|
|
100
|
+
// this is the only place the pack is resolved).
|
|
101
|
+
let packRecord = null;
|
|
102
|
+
const notices = [];
|
|
103
|
+
if (input.pack !== undefined) {
|
|
104
|
+
const { applyPackToMcpInput } = require('./pack/pack-resolve');
|
|
105
|
+
const pr = applyPackToMcpInput({
|
|
106
|
+
packRef: input.pack, expectedKind: 'council', input, paramMap: COUNCIL_PACK_PARAM_MAP,
|
|
107
|
+
});
|
|
108
|
+
// v4.5 final-review T15-m1: amicus_start's own pack-error branch
|
|
109
|
+
// (mcp-server.js) keeps code+hint via buildErrorDoc's JSON envelope; this
|
|
110
|
+
// handler's error surface is plain text (born-fenced, not JSON), so the
|
|
111
|
+
// hint (e.g. PACK_NOT_FOUND's 'amicus pack list') is appended to the
|
|
112
|
+
// message instead of being converted into a JSON envelope, which would
|
|
113
|
+
// change this tool's established response shape.
|
|
114
|
+
if (pr.error) { return textResult(pr.error.message + (pr.error.hint ? `\n${pr.error.hint}` : ''), true); }
|
|
115
|
+
packRecord = pr.packRecord;
|
|
116
|
+
notices.push(...pr.notices);
|
|
117
|
+
}
|
|
118
|
+
// MCP has no template param of its own — a pack's briefing.template (merged
|
|
119
|
+
// onto input.template above) is the only way one reaches this handler.
|
|
120
|
+
// {{prompt}} = the briefingFile content; the RENDERED text is what lands in
|
|
121
|
+
// briefing.md below (mirrors the CLI's single template-application point).
|
|
122
|
+
if (input.template !== undefined) {
|
|
123
|
+
const { applyTemplate } = require('./template/apply');
|
|
124
|
+
const t = applyTemplate({ templateRef: input.template, prompt: briefing, project });
|
|
125
|
+
if (t.error) { return textResult(t.error.message, true); }
|
|
126
|
+
briefing = t.prompt;
|
|
127
|
+
notices.push(...t.notices);
|
|
128
|
+
}
|
|
129
|
+
|
|
77
130
|
const benchRes = resolveBenchInput(input);
|
|
78
131
|
if (benchRes.error) { return textResult(benchRes.error, true); }
|
|
79
132
|
const bench = benchRes.bench;
|
|
80
133
|
const presetName = benchRes.presetName;
|
|
134
|
+
const droppedMembers = benchRes.droppedMembers || [];
|
|
81
135
|
if (bench.length < 2) { return textResult('A council needs at least 2 seats.', true); }
|
|
82
136
|
const chair = (typeof input.chair === 'string' && input.chair.trim()) ? input.chair.trim() : CHAIR_DEFAULT;
|
|
83
137
|
if (bench.includes(chair)) {
|
|
@@ -121,6 +175,14 @@ async function handleCouncilRunTool(input, project, helpers) {
|
|
|
121
175
|
maxCost: (typeof input.maxCost === 'number') ? input.maxCost : null,
|
|
122
176
|
gateway: input.gateway || 'auto', outDir: runDir,
|
|
123
177
|
},
|
|
178
|
+
// v4.5 Task 15: additive-only — absent (not null) without a pack. The
|
|
179
|
+
// spawned child's own seed omits `pack` (never passed --pack); initRun's
|
|
180
|
+
// plain shallow merge (run-state.js) preserves this pre-seeded value —
|
|
181
|
+
// pinned behavior, Task 12.
|
|
182
|
+
...(packRecord ? { pack: packRecord } : {}),
|
|
183
|
+
// v4.5 Wave 2: additive, same preserved-across-the-child's-own-initRun
|
|
184
|
+
// precedent as `pack` above — absent (never []) when nothing dropped.
|
|
185
|
+
...(droppedMembers.length ? { droppedMembers } : {}),
|
|
124
186
|
usage: null, createdAt: new Date().toISOString(),
|
|
125
187
|
});
|
|
126
188
|
runState.writePointer(project, runId, runDir);
|
|
@@ -170,14 +232,56 @@ async function handleCouncilRunTool(input, project, helpers) {
|
|
|
170
232
|
// the only code that later sees this council run reach terminal state.
|
|
171
233
|
if (oc.mode === 'mcp-notify') { requestMcpNotify(runId); }
|
|
172
234
|
|
|
235
|
+
// ★ v4.5 auto-open (spec §6): decide via the pure helper, launch detached,
|
|
236
|
+
// never await, never fail the run. helpers.autoOpen is a test seam.
|
|
237
|
+
const ao = helpers.autoOpen || {
|
|
238
|
+
decide: (ctx) => require('./sidecar/workspace-auto-open').shouldAutoOpenWorkspace(ctx),
|
|
239
|
+
launch: (opts) => require('./sidecar/workspace-window').launchWorkspaceWindowDetached(opts),
|
|
240
|
+
};
|
|
241
|
+
let workspaceOpened = false;
|
|
242
|
+
let workspaceOpenReason = null;
|
|
243
|
+
try {
|
|
244
|
+
const { isElectronUsable } = require('./sidecar/electron-install');
|
|
245
|
+
const { getWorkspaceAutoOpen } = require('./utils/config');
|
|
246
|
+
const decision = ao.decide({
|
|
247
|
+
client: helpers.clientName,
|
|
248
|
+
electronUsable: isElectronUsable(),
|
|
249
|
+
platform: process.platform,
|
|
250
|
+
env: process.env,
|
|
251
|
+
autoOpenConfig: getWorkspaceAutoOpen(),
|
|
252
|
+
uiParam: input.ui,
|
|
253
|
+
});
|
|
254
|
+
if (decision.open) {
|
|
255
|
+
const r = ao.launch({ project, runId });
|
|
256
|
+
if (r && r.launched === false) {
|
|
257
|
+
workspaceOpenReason = r.reason;
|
|
258
|
+
} else {
|
|
259
|
+
workspaceOpened = true;
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
workspaceOpenReason = decision.reason;
|
|
263
|
+
}
|
|
264
|
+
} catch (err) {
|
|
265
|
+
workspaceOpenReason = `auto-open-failed: ${err.message}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
173
268
|
const body = JSON.stringify({
|
|
174
269
|
schemaVersion: 2, type: 'council-run', runId, runDir, status: 'running',
|
|
175
270
|
message: 'Council run started. Preferred: call amicus_wait with the runId — one blocking ' +
|
|
176
271
|
'call replaces polling; re-call it while it returns timedOut: true. Fallback: poll ' +
|
|
177
272
|
'amicus_status with the runId. Artifacts land in runDir (verdict.json, report.html).',
|
|
273
|
+
workspaceOpened,
|
|
274
|
+
...(workspaceOpenReason ? { workspaceOpenReason } : {}),
|
|
275
|
+
// v4.5 Wave 2: otherwise invisible here short of separately reading run.json.
|
|
276
|
+
...(droppedMembers.length ? { droppedMembers } : {}),
|
|
178
277
|
});
|
|
179
278
|
// Born-fenced (spec §8): council MCP tool text is wrapped like amicus_read.
|
|
180
|
-
|
|
279
|
+
const content = [{ type: 'text', text: fenceSidecarOutput(body) }];
|
|
280
|
+
// v4.5 Task 15: pack/template notices (e.g. a bench-override) are non-fatal —
|
|
281
|
+
// surfaced as extra unfenced content blocks, same precedent as
|
|
282
|
+
// mcp-server.js's routeResult.notice (amicus_start).
|
|
283
|
+
for (const n of notices) { content.push({ type: 'text', text: n }); }
|
|
284
|
+
return { content };
|
|
181
285
|
}
|
|
182
286
|
|
|
183
287
|
// The council-awareness helpers live in their own module; re-exported here so
|