amicus 4.9.6 → 4.9.8

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.
Files changed (56) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +246 -0
  3. package/README.md +1 -1
  4. package/docs/ROADMAP.md +3 -3
  5. package/docs/architecture-map.md +24 -4
  6. package/docs/configuration.md +43 -15
  7. package/docs/council.md +140 -3
  8. package/docs/electron-testing.md +133 -0
  9. package/docs/troubleshooting.md +14 -7
  10. package/docs/usage.md +8 -4
  11. package/package.json +1 -1
  12. package/schemas/council-verdict.schema.json +3 -1
  13. package/skills/second-opinion/SEAT-BRIEFS.md +6 -0
  14. package/src/cli-council-run-tools.js +168 -0
  15. package/src/cli-handlers-council-run.js +6 -6
  16. package/src/cli.js +23 -1
  17. package/src/council/briefings-chair.js +1 -1
  18. package/src/council/briefings-task.js +11 -5
  19. package/src/council/briefings.js +25 -7
  20. package/src/council/report-lost-rows.js +89 -0
  21. package/src/council/report-md.js +3 -1
  22. package/src/council/report.js +3 -2
  23. package/src/council/run-degrade.js +22 -1
  24. package/src/council/run-finish.js +23 -1
  25. package/src/council/run-launch.js +33 -4
  26. package/src/council/run-retry-launch.js +9 -4
  27. package/src/council/run-retry.js +3 -0
  28. package/src/council/run-seat-tools-verify.js +296 -0
  29. package/src/council/run-seat-tools.js +274 -0
  30. package/src/council/run-server.js +41 -6
  31. package/src/council/run-stage1-launch.js +8 -3
  32. package/src/council/run.js +21 -21
  33. package/src/council/seat-tools.js +299 -0
  34. package/src/council/verdict-seats-reviewed.js +76 -6
  35. package/src/headless.js +136 -6
  36. package/src/mcp-council-pack-map.js +24 -0
  37. package/src/mcp-council-run.js +17 -15
  38. package/src/mcp-server.js +2 -2
  39. package/src/mcp-tools.js +15 -4
  40. package/src/opencode-client.js +26 -0
  41. package/src/pack/pack-validate.js +3 -1
  42. package/src/prompt-builder.js +2 -2
  43. package/src/sidecar/electron-exe-rel.js +131 -0
  44. package/src/sidecar/electron-install.js +7 -12
  45. package/src/sidecar/electron-layout.js +31 -31
  46. package/src/sidecar/electron-native-plan.js +23 -5
  47. package/src/sidecar/electron-native-rescue.js +55 -16
  48. package/src/sidecar/electron-rescue-notice.js +18 -1
  49. package/src/sidecar/fanout.js +7 -1
  50. package/src/sidecar/heartbeat.js +46 -0
  51. package/src/sidecar/session-utils.js +7 -34
  52. package/src/sidecar/zip-from-buffer.js +16 -5
  53. package/src/sidecar/zip-local-name-scan.js +238 -0
  54. package/src/sidecar/zip-name-scan.js +5 -0
  55. package/src/utils/agent-mapping.js +1 -1
  56. package/src/utils/degrade.js +8 -0
package/src/headless.js CHANGED
@@ -877,6 +877,29 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
877
877
  // block comment beside `sessionId`) so the catch-all return can carry it.
878
878
  let toolStalled = false; // B53: distinct from completed/timedOut/aborted — see resolveTerminalState
879
879
  let lastSettledToolCount = 0; // B4: tool calls observed reaching a terminal status
880
+ // 2026-09-11 spec §3: what session.status said on the most recent read this
881
+ // poll. The stable-idle heuristic below defers to it — a busy engine with no
882
+ // live tool is a model still answering, not a dead leg (study run D0: three
883
+ // deliverables discarded 39–107 s before they finished while this said busy).
884
+ let lastSdkStatus = 'unread';
885
+ // One trace line per flat stretch the veto holds open (spec §3 as amended 2026-09-11):
886
+ // cleared whenever a poll progresses, set on the first vetoed poll of the stretch.
887
+ let vetoLoggedThisStretch = false;
888
+ // Council #246 (2026-09-11): the `next` half of the bound finding. When session.status
889
+ // is `retry`, the engine's own timestamp for the next attempt — epoch ms: opencode's
890
+ // session/processor.ts sets `next: Date.now() + delay`. If it lies past this leg's
891
+ // deadline, waiting cannot produce a
892
+ // deliverable; the leg ends at once with the named reason RETRY_BEYOND_DEADLINE and the
893
+ // session is aborted post-loop like the backstop path. Under a relative reading of
894
+ // `next` the comparison stays inert (a delay in ms never exceeds an epoch deadline),
895
+ // so the only failure mode is "no early exit". Named mutant "NEXTIGNORED" (drop the
896
+ // comparison) reddens exactly the retry-beyond-deadline case in headless-idle-completion
897
+ // — measured 1 of 13, killed by that case's own 10 s jest timeout because the leg then
898
+ // runs to its 60 s --timeout instead. "FINISHEDRETRYEXIT" (drop the assistantFinished
899
+ // guard) reddens the finalized-plus-retry case. "RETRYOVERTOOL" (drop the
900
+ // liveTools guard) reddens the retry-with-live-tool case.
901
+ let lastSdkRetryNext = null;
902
+ let retryBeyondDeadline = false;
880
903
 
881
904
  // ---- v4.4 B4 part 1: the tool-settle deferral -----------------------------
882
905
  // Recomputed once per poll (see the loop body) so every completion gate in a
@@ -1201,9 +1224,12 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1201
1224
  break;
1202
1225
  }
1203
1226
 
1204
- // Authoritative idle signal from the OpenCode SDK (preferred over the heuristic).
1205
- // Gate on real output so a pre-processing 'idle' cannot end the run early.
1206
- // Best-effort: on any error, fall back to the activity heuristic below.
1227
+ // Authoritative signal from the OpenCode SDK: `idle` ends the leg here; `busy` or
1228
+ // `retry` vetoes the activity heuristic below (2026-09-11 spec §3) unless a tool
1229
+ // call is live, in which case the B4 ceiling governs. Once the message has finalized
1230
+ // the stable-finished path ends it regardless of status. Gate on real output so a
1231
+ // pre-processing 'idle' cannot end the run early. On any error the heuristic
1232
+ // runs as the fallback it was always meant to be.
1207
1233
  if (mirror.output.length > 0) {
1208
1234
  try {
1209
1235
  const remainingForStatus = deadline - Date.now();
@@ -1213,6 +1239,26 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1213
1239
  'getSessionStatus'
1214
1240
  );
1215
1241
  const s = (statusData && statusData.type) ? statusData : (statusData && statusData[sessionId]);
1242
+ lastSdkStatus = (s && typeof s.type === 'string') ? s.type : 'other';
1243
+ lastSdkRetryNext = (s && s.type === 'retry' && Number.isFinite(s.next)) ? s.next : null;
1244
+ // Council #246 round 2 (C1/B2): never on a finalized last message — the
1245
+ // stable-finished path ends that leg in two polls whatever the session-level
1246
+ // status says (a `retry` here belongs to the engine's next step), and its text
1247
+ // must not be discarded. Named mutant "FINISHEDRETRYEXIT" (drop
1248
+ // `!assistantFinished`) reddens the finalized-plus-retry case.
1249
+ // Council #246 round 3 (D1): never while a tool call is live either — the B4
1250
+ // bounded tool-settle ceiling governs a live tool everywhere else in this loop and
1251
+ // must here too; a retry-with-live-tool is not a shape the engine produces, but a
1252
+ // lagging mirror can show one. Named mutant "RETRYOVERTOOL" (drop the liveTools
1253
+ // guard) reddens the live-tool case.
1254
+ if (!assistantFinished && liveTools.length === 0 && lastSdkRetryNext !== null && lastSdkRetryNext > deadline) {
1255
+ retryBeyondDeadline = true;
1256
+ sessionError = `RETRY_BEYOND_DEADLINE: the engine schedules the next attempt at ${new Date(lastSdkRetryNext).toISOString()}, after this leg's deadline ${new Date(deadline).toISOString()}${formatSessionStatusSuffix(s)}`;
1257
+ logger.warn('Provider backoff exceeds the leg deadline; ending the leg now instead of waiting', {
1258
+ taskId, attempt: s.attempt, next: lastSdkRetryNext, deadline,
1259
+ });
1260
+ break;
1261
+ }
1216
1262
  if (s && s.type === 'idle' && !deferForUnsettledTools('sdk-idle')) {
1217
1263
  logger.debug('Session reported idle by SDK — completing', { sessionId });
1218
1264
  completed = true;
@@ -1220,6 +1266,8 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1220
1266
  }
1221
1267
  } catch (statusErr) {
1222
1268
  logger.debug('session.status unavailable; using activity heuristic', { error: statusErr.message });
1269
+ lastSdkStatus = 'unavailable';
1270
+ lastSdkRetryNext = null;
1223
1271
  }
1224
1272
  }
1225
1273
 
@@ -1271,7 +1319,51 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1271
1319
  if (!progressed) {
1272
1320
  // Require real output before counting toward completion — the SDK creates an
1273
1321
  // empty assistant-message placeholder on promptAsync that is NOT a finished response.
1274
- if (currentAssistantMsgId !== null && mirror.output.length > 0) {
1322
+ //
1323
+ // 2026-09-11 spec §3: the heuristic is the FALLBACK for when session.status is
1324
+ // unavailable, not a second opinion on it. While the engine says `busy` or
1325
+ // `retry` (provider backoff — the engine has not given up; utils/session-status.js
1326
+ // reads the same arm) and no tool call is live, the model is generating —
1327
+ // in-flight parts are invisible to this poller (measured: in-flight parts never
1328
+ // grow outputLength — 0 for the whole stream without tools, A1/E1; flat at the
1329
+ // narration length with them, D0), so flat output here is not silence.
1330
+ // `liveTools.length === 0` keeps the v4.4 B4 bounded tool-settle ceiling (below)
1331
+ // in charge whenever a tool IS live: that path fires through this gate while busy,
1332
+ // then aborts the session (LC-2). The trace line fires on the first vetoed poll of
1333
+ // each flat stretch and whenever a non-zero count is reset, and "Polling loop
1334
+ // exited" carries the last SDK status, so a leg that is held and then dies by
1335
+ // --timeout leaves a record of what the engine said.
1336
+ // Named mutants, ALL RE-MEASURED 2026-09-11 for the council #246 fix set — every
1337
+ // count below is what was observed, not what was expected. "BUSYIGNORED" (the veto
1338
+ // never fires: replace the SDK-status test with `false`) reddens FIVE cases in
1339
+ // headless-idle-completion — the three D0-shape cases (busy, retry, settled tool
1340
+ // part) plus the wedge-until-timeout and status-flip-flop cases this fix set added
1341
+ // — and nothing else in the four suites (5 failed / 149). "VETOOVERCEILING" (drop
1342
+ // the liveTools guard) reddens the 12 B4 ceiling/abort tests in premature-completion,
1343
+ // every `stuck()` call site, and NOT the ALREADY-terminal case (12 failed / 42).
1344
+ // "RETRYHARVEST" (drop the retry arm) reddens exactly the retry case (1 of 13) — the
1345
+ // retry-beyond-deadline case SURVIVES it, because that exit fires in the status-read
1346
+ // block above, before this gate ever runs. "FLAPSILENT" (drop `|| stablePolls > 0`
1347
+ // from the latch below) reddens exactly the flip-flop case (1 of 13). Three further
1348
+ // mutants are documented at their own sites and were measured in the same pass:
1349
+ // "FALLBACKSILENT" (2 of 13, the stable-idle exit below), "NEXTIGNORED" (1 of 13, the
1350
+ // status-read block above) and "BACKOFFNOTFORCED" (1 of 13, failedWithNoUsableOutput
1351
+ // at the finalization). "FINISHEDVETO" (drop
1352
+ // `!assistantFinished`) is pinned by premature-completion "message FINALIZES" and
1353
+ // "ALREADY terminal" and by headless.test.js's BL-7 case: a finalized message with
1354
+ // a session-level `busy` must still end on the stable-finished path. A tool part
1355
+ // with no `state` is
1356
+ // not live here (pendingTools > 0, liveTools === 0), so that mock-only shape is now
1357
+ // bounded by B53's stall detector rather than the 30-poll heuristic; the SDK always
1358
+ // carries `state`.
1359
+ if (currentAssistantMsgId !== null && mirror.output.length > 0
1360
+ && !assistantFinished && (lastSdkStatus === 'busy' || lastSdkStatus === 'retry') && liveTools.length === 0) {
1361
+ if (!vetoLoggedThisStretch || stablePolls > 0) {
1362
+ logger.debug('Idle heuristic reset: SDK busy, no live tools', { taskId, stablePolls, sdkStatus: lastSdkStatus });
1363
+ vetoLoggedThisStretch = true;
1364
+ }
1365
+ stablePolls = 0;
1366
+ } else if (currentAssistantMsgId !== null && mirror.output.length > 0) {
1275
1367
  stablePolls++;
1276
1368
  const threshold = assistantFinished ? stableFinishedPolls : stableIdlePolls;
1277
1369
  // v4.4 B4 part 1 — THE MEASURED DEFECT SITE. This is the gate that
@@ -1288,6 +1380,20 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1288
1380
  // Gating it would add pure hang risk for no truth gained.
1289
1381
  if (stablePolls >= threshold
1290
1382
  && !(!assistantFinished && deferForUnsettledTools('stable-idle'))) {
1383
+ if (!assistantFinished && liveTools.length === 0) {
1384
+ // Council #246 C2/D2: the fallback ended an UNFINALIZED message with no tool
1385
+ // live — the D0 shape the veto exists for — reachable only because
1386
+ // session.status did not say busy/retry (the read threw, or returned a type
1387
+ // this gate does not know). Loud at warn level, so a stuttering status
1388
+ // endpoint cannot re-open the mid-answer harvest silently. A live tool is
1389
+ // excluded because that exit is the B4 ceiling, which is loud on its own.
1390
+ // Named mutant "FALLBACKSILENT" (drop this warning, keep the `if`) reddens
1391
+ // exactly the two fallback-warning cases in headless-idle-completion
1392
+ // (measured 2 of 13).
1393
+ logger.warn('Idle heuristic ended an unfinalized message on the fallback path', {
1394
+ taskId, stablePolls, sdkStatus: lastSdkStatus, outputLength: mirror.output.length,
1395
+ });
1396
+ }
1291
1397
  logger.debug('Session appears complete (idle)', { stablePolls, assistantFinished });
1292
1398
  completed = true;
1293
1399
  break;
@@ -1299,6 +1405,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1299
1405
  }
1300
1406
  } else {
1301
1407
  stablePolls = 0;
1408
+ vetoLoggedThisStretch = false;
1302
1409
  }
1303
1410
  lastAssistantMsgId = currentAssistantMsgId;
1304
1411
 
@@ -1328,6 +1435,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1328
1435
  aborted,
1329
1436
  pollCount,
1330
1437
  stablePolls,
1438
+ sdkStatus: lastSdkStatus,
1331
1439
  outputLength: mirror.output.length,
1332
1440
  elapsed: Date.now() - startTime,
1333
1441
  hasAssistantMsg: lastAssistantMsgId !== null,
@@ -1343,7 +1451,13 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1343
1451
  // signal per leg: statusFromResult() (src/utils/result-schema.js) checks
1344
1452
  // timedOut BEFORE error, so a leg carrying both would misreport as an
1345
1453
  // ordinary 'timeout' instead of the distinctly-named backstop reason.
1346
- if (!completed && !aborted && !backstopFired && (Date.now() - startTime) >= timeoutMs) {
1454
+ // Council #246 re-review: `!retryBeyondDeadline` for the same reason as
1455
+ // `!backstopFired`. The status read that sets it may legally return
1456
+ // at deadline−ε (its budget runs to the deadline), and without
1457
+ // this guard a same-pass timeout would double-classify the named
1458
+ // RETRY_BEYOND_DEADLINE error as `timeout` in run.json and abort the
1459
+ // session twice; its own block below aborts exactly once.
1460
+ if (!completed && !aborted && !backstopFired && !retryBeyondDeadline && (Date.now() - startTime) >= timeoutMs) {
1347
1461
  timedOut = true;
1348
1462
  logger.warn('Task timed out', { taskId, elapsed: Date.now() - startTime });
1349
1463
 
@@ -1371,6 +1485,18 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1371
1485
  }
1372
1486
  }
1373
1487
 
1488
+ // Council #246: a retry scheduled past the deadline ended the leg early. Abort the
1489
+ // session exactly like the backstop path — the engine would otherwise keep retrying.
1490
+ if (retryBeyondDeadline && !completed && !aborted) {
1491
+ try {
1492
+ const { abortSession } = require('./opencode-client');
1493
+ await abortSession(client, sessionId, ...dirArgs);
1494
+ logger.info('Session aborted after retry-beyond-deadline exit', { taskId, sessionId });
1495
+ } catch (abortErr) {
1496
+ logger.warn('Failed to abort session after retry-beyond-deadline exit', { error: abortErr.message });
1497
+ }
1498
+ }
1499
+
1374
1500
  watchdog.cancel();
1375
1501
  if (uninstallSignals) { uninstallSignals(); }
1376
1502
 
@@ -1661,7 +1787,11 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1661
1787
  // #218 PR 3: an OUTPUT_LENGTH death can have a non-empty mirror.output -- a
1662
1788
  // tool loop's earlier message text, or reasoning promoted before the answer
1663
1789
  // was known -- and must still fail.
1664
- const failedWithNoUsableOutput = !!(sessionError && (!mirror.output || pollFailureBail || toolStalled || outputLengthDeath));
1790
+ // Council #246 (2026-09-11): `|| retryBeyondDeadline` names the retry-past-deadline death
1791
+ // through the same channel — without it the leg left as Incomplete with its narration
1792
+ // promoted and the reason lost. Named mutant "BACKOFFNOTFORCED" (drop it) reddens exactly
1793
+ // the beyond-deadline case in headless-idle-completion (measured 1 of 13).
1794
+ const failedWithNoUsableOutput = !!(sessionError && (!mirror.output || pollFailureBail || toolStalled || outputLengthDeath || retryBeyondDeadline));
1665
1795
  const { resolveTerminalState } = require('./sidecar/session-finalize');
1666
1796
  const terminalStage = resolveTerminalState({
1667
1797
  completed,
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @module mcp-council-pack-map
3
+ * COUNCIL_PACK_PARAM_MAP, split out of mcp-council-run.js for the 300-line size gate (P2-R16).
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ /**
9
+ * v4.5 Task 15 (B7/F5): maps amicus_council_run's MCP input keys to the CLI
10
+ * arg-key names applyPackToArgs's knob tables use (pack-resolve.js), so
11
+ * applyPackToMcpInput can reuse those tables unchanged. `template` has no
12
+ * Zod-declared counterpart on this tool (MCP has no template param of its
13
+ * own — template/apply.js's own docblock: "MCP has no template params of its
14
+ * own") — a pack's briefing.template is the ONLY way a template reaches this
15
+ * handler, carried through as a plain (non-schema) `input.template` property
16
+ * consumed by the render step in mcp-council-run.js.
17
+ */
18
+ const COUNCIL_PACK_PARAM_MAP = {
19
+ models: 'models', council: 'council', chair: 'chair', critic: 'critic', lenses: 'lenses',
20
+ debate: 'debate', timeoutMinutes: 'timeout', maxCost: 'max-cost', gateway: 'gateway',
21
+ template: 'template',
22
+ };
23
+
24
+ module.exports = { COUNCIL_PACK_PARAM_MAP };
@@ -27,21 +27,11 @@ function textResult(text, isError) {
27
27
  return result;
28
28
  }
29
29
 
30
- /**
31
- * v4.5 Task 15 (B7/F5): maps amicus_council_run's MCP input keys to the CLI
32
- * arg-key names applyPackToArgs's knob tables use (pack-resolve.js), so
33
- * applyPackToMcpInput can reuse those tables unchanged. `template` has no
34
- * Zod-declared counterpart on this tool (MCP has no template param of its
35
- * own — template/apply.js's own docblock: "MCP has no template params of its
36
- * own") — a pack's briefing.template is the ONLY way a template reaches this
37
- * handler, carried through as a plain (non-schema) `input.template` property
38
- * consumed by the render step below.
39
- */
40
- const COUNCIL_PACK_PARAM_MAP = {
41
- models: 'models', council: 'council', chair: 'chair', critic: 'critic', lenses: 'lenses',
42
- debate: 'debate', timeoutMinutes: 'timeout', maxCost: 'max-cost', gateway: 'gateway',
43
- template: 'template',
44
- };
30
+ // COUNCIL_PACK_PARAM_MAP lives in its own leaf (P2-R16, the 300-line size
31
+ // gate: this file was at 298 with no room for Task 6's tools/agent block) —
32
+ // re-exported below unchanged so tests/pack/mcp-pack-params.test.js's
33
+ // existing `require('../../src/mcp-council-run')` import keeps working.
34
+ const { COUNCIL_PACK_PARAM_MAP } = require('./mcp-council-pack-map');
45
35
 
46
36
  /**
47
37
  * amicus_council_run: validate → prep run dir → spawn CLI child → return
@@ -131,6 +121,14 @@ async function handleCouncilRunTool(input, project, helpers) {
131
121
  (typeof input.maxCost !== 'number' || !Number.isFinite(input.maxCost) || input.maxCost <= 0)) {
132
122
  return textResult('maxCost must be a positive number.', true);
133
123
  }
124
+ // Spec 2026-09-11 §4 (P2-R28 supersedes P2-R25): --tools/--agent are refused together, before either is consulted, on every door. Tools that never touch the tree (webfetch, websearch, todowrite) ride through; local tools are refused naming the CLI.
125
+ const toolsIn = (input.tools === undefined || input.tools === null) ? undefined : (Array.isArray(input.tools) ? input.tools : [String(input.tools)]);
126
+ const mcpWording = (m) => m.replace(/^--tools:/, 'tools:').replace(/--agent Build/g, 'agent: "Build"').replace(/(?<!run )--tools/g, 'tools').replace(/--agent/g, 'agent'); // C6 (P2-R35): rewrites flag wording EXCEPT inside an actual `council run --tools ...` CLI suggestion (resolveRemoteOnlyTools's local-tool message), which stays literal.
127
+ const conflict = require('./council/seat-tools').agentToolsConflict(input.agent, toolsIn);
128
+ if (conflict) { return textResult(mcpWording(conflict), true); }
129
+ const mt = (toolsIn !== undefined)
130
+ ? require('./council/seat-tools').resolveRemoteOnlyTools(toolsIn) : { ok: true, ids: [] };
131
+ if (!mt.ok) { return textResult(mcpWording(mt.message), true); }
134
132
 
135
133
  const { generateTaskId } = require('./sidecar/start');
136
134
  const runId = generateTaskId();
@@ -207,6 +205,10 @@ async function handleCouncilRunTool(input, project, helpers) {
207
205
  // v4.9 W5.2: emit-when-'task' — 'review' (the zod-declared default spelled
208
206
  // out) never reaches the child's argv; review-run argv stays byte-identical.
209
207
  if (input.intent === 'task') { args.push('--intent', 'task'); }
208
+ // Spec 2026-09-11 §4: remote tools + the Plan|Build override ride to the child as argv (the CLI door and runCouncil validate them again).
209
+ // Named mutant TOOLSEMITALWAYS: `mt.ids` (an array) is always truthy even when empty — swapping `.length` for a bare `mt.ids` check pushes `--tools ''` on every run; reddens 'absent tools/agent leave the argv byte-identical' in tests/mcp-council-run.test.js.
210
+ if (mt.ids.length) { args.push('--tools', mt.ids.join(',')); }
211
+ if (input.agent) { args.push('--agent', input.agent); }
210
212
 
211
213
  let child;
212
214
  try { child = helpers.spawnFn(args, runDir); } catch (err) {
package/src/mcp-server.js CHANGED
@@ -229,8 +229,8 @@ const HEADLESS_STATUS_REMINDER = '<system-reminder>Preferred: call amicus_wait w
229
229
  * v4.5 Task 15 (B7/F5): map amicus_fanout / amicus_start's MCP input keys to
230
230
  * the CLI arg-key names applyPackToArgs's knob tables use (pack-resolve.js),
231
231
  * so applyPackToMcpInput can reuse those tables unchanged — see
232
- * src/mcp-council-run.js's COUNCIL_PACK_PARAM_MAP for the sibling map and its
233
- * fuller docblock. `includeContext` is the one inverted-polarity knob: the
232
+ * src/mcp-council-pack-map.js's COUNCIL_PACK_PARAM_MAP for the sibling map and
233
+ * its fuller docblock. `includeContext` is the one inverted-polarity knob: the
234
234
  * pack/CLI side is `no-context` (true = drop context), the MCP side is
235
235
  * `includeContext` (true = keep context, default true).
236
236
  */
package/src/mcp-tools.js CHANGED
@@ -73,8 +73,8 @@ function getTools() {
73
73
  agent: z.enum(['Chat', 'Plan', 'Build']).optional()
74
74
  .describe(
75
75
  'Agent mode. Chat (interactive default; headless runs auto-convert ' +
76
- 'to Build): reads auto, writes ask permission. Plan: read-only ' +
77
- 'analysis. Build: full auto (all operations approved).'
76
+ 'to Build): reads auto, writes ask permission. Plan: analysis without ' +
77
+ 'edits (reads, searches and shell allowed). Build: full auto (all operations approved).'
78
78
  ),
79
79
  noUi: z.boolean().optional().describe(
80
80
  'Run headless without GUI. Default false (opens Electron window).'
@@ -345,7 +345,7 @@ function getTools() {
345
345
  'The briefing sent to every model. Self-contained briefings work best (set includeContext false).'
346
346
  ),
347
347
  agent: z.enum(['Plan', 'Build']).optional().describe(
348
- 'Agent mode for every leg. Build (default): full tool access. Plan: read-only analysis. Chat is not supported headless.'
348
+ 'Agent mode for every leg. Build (default): full tool access. Plan: analysis without edits (reads, searches and shell allowed). Chat is not supported headless.'
349
349
  ),
350
350
  thinking: z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']).optional().describe(
351
351
  'Reasoning effort for every leg. Omitted: nothing is sent and each provider\'s own default effort governs. A leg whose model does not declare the level is refused before anything is spent; the other legs run. A leg whose model the engine\'s catalogue does not know in time is sent the level unverified.'
@@ -622,6 +622,17 @@ function getTools() {
622
622
  "run.json/verdict.json and kept out of the reliability ledger; 'review' is the " +
623
623
  'default and is never stored.'
624
624
  ),
625
+ tools: z.array(z.string().min(1)).max(12).optional().describe(
626
+ 'Tool ids stage-1 seats may use, by the engine\'s own ids (task mode defaults to webfetch, review to none). ' +
627
+ 'Over MCP only tools that never touch the tree (webfetch, websearch, todowrite) can be opted in: the MCP run ' +
628
+ 'directory stays inside the project, and a seat with local tools must not run there — use the CLI with ' +
629
+ '--out-dir outside the project for read/grep/glob/bash. task, skill, question, invalid, edit, write and ' +
630
+ 'apply_patch are always refused. Cannot be combined with agent.'
631
+ ),
632
+ agent: z.enum(['Plan', 'Build']).optional().describe(
633
+ 'Escape hatch: run every leg on the engine\'s own agent instead of the council agents (no tool allowlist). ' +
634
+ 'Cannot be combined with tools.'
635
+ ),
625
636
  ui: z.boolean().optional().describe(
626
637
  'Auto-open the Council Workspace window on this run. Default: opens when the client is ' +
627
638
  'Claude Code (local), Electron is installed, a display exists, and config workspace.autoOpen is ' +
@@ -742,7 +753,7 @@ Each leg is an ordinary session: read/resume/continue it by taskId.
742
753
  | Agent | Reads | Writes | Bash | Use When |
743
754
  |-------|-------|--------|------|----------|
744
755
  | Chat (interactive default*) | auto | asks | asks | Questions, analysis |
745
- | Plan | auto | denied | denied | Read-only analysis |
756
+ | Plan | auto | denied | auto | Analysis without edits |
746
757
  | Build | auto | auto | auto | Implementation tasks |
747
758
 
748
759
  * Headless (\`noUi\`) runs auto-convert Chat to Build — Chat would otherwise stall waiting on write/bash approval with no UI to approve it.
@@ -530,6 +530,7 @@ function resolveServerStartTimeoutMs(options = {}, env, platform) {
530
530
  * @param {string} [options.agentName] - Agent to set systemPrompt on (default: 'chat')
531
531
  * @param {number|null} [options.outputBudget] - #218 PR 3: the per-leg output budget startServer
532
532
  * already read; omitted means buildProviderModels reads config itself
533
+ * @param {Object<string, object>} [options.agents] - Extra agent configs to register (council seat agents, spec 2026-09-11 §4)
533
534
  * @returns {object} Server options ready for createOpencodeServer
534
535
  */
535
536
  function buildServerOptions(options = {}) {
@@ -655,6 +656,31 @@ function buildServerOptions(options = {}) {
655
656
  chat: chatAgent
656
657
  };
657
658
 
659
+ // Council seat agents (spec 2026-09-11 §4, PR 2): a per-run map of extra agents
660
+ // the caller already computed (council/seat-tools.js :: buildCouncilAgents).
661
+ // Merged AFTER `chat`, and a `chat` key is skipped outright (fix round 1 nit)
662
+ // so a caller genuinely can never displace the chat registration — without
663
+ // the skip, `agents: { chat: {...} }` would replace `config.agent.chat`
664
+ // with a brand-new object, detaching it from the `chatAgent` object the
665
+ // systemPrompt branch below still mutates by reference; seat-tools.js never
666
+ // emits a `chat` key, but the guard holds regardless of the caller. Absent,
667
+ // or not a plain object (arrays rejected too), this block is a no-op and
668
+ // every non-council server's config is byte-identical to today.
669
+ //
670
+ // council #247 round 6 (P2-R53): a council agent REPLACES any pre-existing
671
+ // same-name entry outright, rather than merging onto it — measured
672
+ // 2026-09-13 (probe-r6.js) that the old shallow merge below let a
673
+ // pre-existing entry's `prompt`/`temperature` survive and render on the
674
+ // seat. Named mutant AGENTMERGE: restoring the spread
675
+ // (`{ ...(config.agent[name] || {}), ...agentConfig }`) lets `prompt`
676
+ // survive again.
677
+ if (options.agents && typeof options.agents === 'object' && !Array.isArray(options.agents)) {
678
+ for (const [name, agentConfig] of Object.entries(options.agents)) {
679
+ if (name === 'chat') { continue; }
680
+ config.agent[name] = { ...agentConfig };
681
+ }
682
+ }
683
+
658
684
  // Set system prompt on the target agent's config (hidden from UI).
659
685
  // The promptAsync `system` field is rendered as a visible chat message,
660
686
  // but agent.prompt is injected as the system instruction invisibly.
@@ -16,7 +16,9 @@ const KINDS = ['council', 'fanout', 'solo'];
16
16
  /** Per-kind allowed `options` keys (spec §5.1; solo UI-suppression key per Task 0's verified flag set).
17
17
  * v4.5 HOLD-gate decision 2 (final-review F1): `agent`/`thinking`/`summaryLength`
18
18
  * are inert on EVERY council surface — handleCouncilRun never reads a pack-filled
19
- * one, and the engine hardcodes agent 'Plan'/summaryLength 'verbose' regardless.
19
+ * one; `--agent` (CLI) / the MCP `agent` param are a council run's only agent
20
+ * setters (spec 2026-09-11 §4, v4.9.8), and summaryLength stays hardcoded
21
+ * 'verbose' on every council launch regardless of what a pack sets.
20
22
  * Dropped from `council` pre-release rather than shipped as dead weight a pack
21
23
  * author would reasonably expect to work; a council pack that still sets one now
22
24
  * fails save/run validation (PACK_INVALID) like any other unknown option for the
@@ -234,7 +234,7 @@ ${context}`;
234
234
  * Note: Tool restrictions are now handled by OpenCode's native agent framework.
235
235
  * The agent parameter passed to OpenCode API controls permissions:
236
236
  * - Build: Full tool access (default)
237
- * - Plan: Read-only access
237
+ * - Plan: Edits denied; reads, searches and shell allowed (measured, opencode 1.18.15)
238
238
  * - Explore: Read-only subagent
239
239
  * - General: Full-access subagent
240
240
  *
@@ -258,7 +258,7 @@ Tool permissions are managed by the OpenCode agent framework based on your agent
258
258
  // buildPlanModeEnvironment) have been removed. OpenCode's native agent framework now handles
259
259
  // tool permissions based on the agent type:
260
260
  // - Build: Full tool access (default)
261
- // - Plan: Read-only access
261
+ // - Plan: Edits denied; reads, searches and shell allowed
262
262
  // - Explore: Read-only subagent
263
263
  // - General: Full-access subagent
264
264
  // See: https://opencode.ai/docs/agents/
@@ -0,0 +1,131 @@
1
+ /**
2
+ * WHICH exe a package resolves through, and whether a `dist/` HOLDS one.
3
+ *
4
+ * ONE RULE, ONE HOME. `resolveElectronBinary` (electron-install.js) decides what
5
+ * amicus will SPAWN; `promoteDist`'s retirement guard (electron-layout.js)
6
+ * decides what amicus may DELETE. Until v4.9.7 only the first one read
7
+ * `path.txt` — the second asked whether `dist/` held THIS HOST'S default exe —
8
+ * and a package cross-installed through `npm_config_platform` holds a different
9
+ * basename, so a failed promote destroyed a working tree (A1). Two copies of a
10
+ * rule are free to drift; this module exists so there is one.
11
+ *
12
+ * WHICH VALUE `promoteDist`'S GUARD MAY READ, since getting this wrong is how
13
+ * the fix would have been as blind as the defect:
14
+ * - `raw`, the bytes `path.txt` held BEFORE the promote's step 0 — YES.
15
+ * - `replaced` (electron-layout.js) — NO. It is nulled in exactly the branch
16
+ * the guard most needs a name for, and it is untrimmed, so feeding it to
17
+ * `path.join` fails OPEN on a trailing newline.
18
+ * - A RE-READ of `path.txt` at the guard — NO, and this is the sharp one.
19
+ * Step 0 has already written `platformExe` there, so a re-reading guard
20
+ * reads its own writer's value and learns nothing. MEASURED: at the guard
21
+ * the file says `electron.exe` even for a package cross-installed as
22
+ * `electron`, and the tree is deleted exactly as before the fix. That is
23
+ * failure mode #21, the echoed read-back.
24
+ *
25
+ * `ELECTRON_OVERRIDE_DIST_PATH` IS DELIBERATELY NOT PART OF THIS RULE, and
26
+ * `promoteDist` gains no `env`. The guard governs a DELETE of `distDir` and
27
+ * nothing else, so the only question is what THAT tree holds; under an override
28
+ * `resolveElectronBinary` does not look in `dist/` at all. Ignoring it can only
29
+ * make the guard readier to find an exe — the fail-CLOSED direction.
30
+ *
31
+ * TRUE LEAF: `path` only, with `fs` injected by the caller — so both callers can
32
+ * require it with no risk of a cycle.
33
+ *
34
+ * @module sidecar/electron-exe-rel
35
+ */
36
+
37
+ 'use strict';
38
+
39
+ const path = require('path');
40
+
41
+ /** Platform exe basename, matching electron's getPlatformPath(). */
42
+ function platformExe(platform) {
43
+ switch (platform) {
44
+ case 'mas':
45
+ case 'darwin':
46
+ return path.join('Electron.app', 'Contents', 'MacOS', 'Electron');
47
+ case 'win32':
48
+ return 'electron.exe';
49
+ default:
50
+ return 'electron';
51
+ }
52
+ }
53
+
54
+ /**
55
+ * The relative exe path a package RESOLVES through, from `path.txt`'s RAW bytes.
56
+ *
57
+ * `resolveElectronBinary`'s rule, stated once: TRIM, and fall back to
58
+ * `platformExe` when the file is absent, unreadable OR blank. `null` is the
59
+ * caller's "the read threw".
60
+ *
61
+ * THE TRIM AND THE BLANK ARM ARE BOTH LOAD-BEARING, and the A1 filing named
62
+ * neither — it said "absent or unreadable". MEASURED: a guard that skips the
63
+ * trim deletes a real `dist/electron.exe` under a `path.txt` of
64
+ * `"electron.exe\n"`, because `existsSync(join(dist, 'electron.exe\n'))` is
65
+ * false on Windows; and one that returns a blank value names `dist/` ITSELF,
66
+ * which exists, so it would refuse every promote forever.
67
+ */
68
+ function heldExeRel(raw, platform) {
69
+ const rel = typeof raw === 'string' ? raw.trim() : '';
70
+ return rel || platformExe(platform);
71
+ }
72
+
73
+ /**
74
+ * WHICH executable `distDir` holds — under either name it could resolve
75
+ * through — or `null` for a tree that is not an install under any of them.
76
+ *
77
+ * A UNION, NEVER A REPLACEMENT, and the union is why this returns a NAME. The
78
+ * filing's literal rule ("judge by what `path.txt` names") was MEASURED to open
79
+ * three new holes it does not mention: a whitespace-only `path.txt`, one with a
80
+ * trailing newline, and a TRUNCATED one (`electr` — the shape `promoteDist`'s
81
+ * own best-effort put-back can leave) each turned a real `dist/electron.exe`
82
+ * into "not an install, delete it". So `platformExe` is not replaced by the
83
+ * `path.txt` name; it is joined by it, and the set of trees this licenses
84
+ * deleting can only ever SHRINK.
85
+ *
86
+ * ARM 1 IS THE PRE-FIX RULE, BYTE FOR BYTE, and it runs first and
87
+ * unconditionally. That ordering is the guarantee: no tree the shipped guard
88
+ * protects today can be deleted by this one.
89
+ *
90
+ * ARM 2 CARRIES TWO BOUNDS THE FIRST DOES NOT NEED.
91
+ * CONTAINED — a `path.txt` of `..`, `.`, `''` or `../SIBLING` joins to
92
+ * something that EXISTS outside `dist/` (all MEASURED true), which would
93
+ * refuse every promote forever while claiming `dist/` held an exe it never
94
+ * held. The predicate is `zip-entry-write.js :: writeSymlink`'s, verbatim —
95
+ * including the `path.sep`, whose absence MEASURABLY fails OPEN: a legal
96
+ * `dist/..electron.exe` reads as escaping and the tree is deleted.
97
+ * A FILE, NOT A DIRECTORY — every natural truncation of the darwin name
98
+ * (`Electron.app`, `Electron.app/Contents`, `Electron.app/Contents/MacOS`) is
99
+ * a real DIRECTORY in a real tree, and `existsSync` says true for all three.
100
+ * Accepting one would wedge the self-heal permanently on the AV-quarantine
101
+ * shape it exists for, printing "dist/ holds a usable Electron.app".
102
+ *
103
+ * A throwing `existsSync` (only an injected fs does this) reads as "I could not
104
+ * establish that this tree is empty", which refuses. Fail closed.
105
+ *
106
+ * @param {object} o
107
+ * @param {string} o.distDir
108
+ * @param {string|null} o.raw path.txt's bytes BEFORE any writer touched them
109
+ * @param {string} o.platform
110
+ * @param {object} o.fs
111
+ * @returns {string|null} the exe path, relative to `distDir`, that was found
112
+ */
113
+ function distHeldExe({ distDir, raw, platform, fs }) {
114
+ const fallback = platformExe(platform);
115
+ // ARM 1 — the pre-fix rule, unchanged and first.
116
+ try { if (fs.existsSync(path.join(distDir, fallback))) { return fallback; } } catch { return fallback; }
117
+ const held = heldExeRel(raw, platform);
118
+ if (held === fallback) { return null; }
119
+ // ARM 2 — the name path.txt gives, contained and required to be a file.
120
+ const full = path.join(distDir, held);
121
+ const inside = path.relative(distDir, full);
122
+ if (inside === '' || inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) { return null; }
123
+ try { return fs.statSync(full).isFile() ? held : null; } catch { return null; }
124
+ }
125
+
126
+ /** Write path.txt: the basename `electron/index.js` joins onto `dist/`. */
127
+ function writePathTxt({ electronDir, platform, fs }) {
128
+ fs.writeFileSync(path.join(electronDir, 'path.txt'), platformExe(platform));
129
+ }
130
+
131
+ module.exports = { platformExe, writePathTxt, heldExeRel, distHeldExe };
@@ -16,7 +16,9 @@
16
16
  *
17
17
  * AT THE SIZE GATE, so pieces live next door: `./electron-custody` reads the
18
18
  * artifact into memory once, `./zip-from-buffer` extracts what was read,
19
- * `./electron-layout` holds `platformExe`/`writePathTxt`/`extractBytesToDist`
19
+ * `./electron-exe-rel` holds the ONE `path.txt` rule (`platformExe`/`heldExeRel`/
20
+ * `writePathTxt`/`distHeldExe`), shared with `promoteDist`'s retirement guard since
21
+ * v4.9.7 (A1); `./electron-layout` holds `promoteDist`/`extractBytesToDist`
20
22
  * (`platformExe` re-exported here for `ei.platformExe`), `./electron-refuse`
21
23
  * holds the refusal messages, `./electron-repair-cache` the whole cached-artifact
22
24
  * route, `./electron-provision` the pinned download. The arrow points one way out
@@ -32,7 +34,7 @@ const { cachedZip } = require('./electron-cache');
32
34
  const { isSafeArtifactName } = require('./electron-custody');
33
35
  const { avHint, verifyExtractOutcome: verifyQuarantine } = require('./electron-quarantine');
34
36
  const { acquireRepairLock } = require('./electron-lock');
35
- const { platformExe } = require('./electron-layout');
37
+ const { platformExe, heldExeRel } = require('./electron-exe-rel');
36
38
  const { controlledProvision } = require('./electron-provision');
37
39
  const { repairFromCache } = require('./electron-repair-cache');
38
40
  const { isUnsafeArchive, refuseUnsafeArchive } = require('./electron-refuse');
@@ -61,16 +63,9 @@ function defaultElectronDir() {
61
63
  * @returns {string|null} resolved exe path, or null if path.txt is unreadable.
62
64
  */
63
65
  function resolveElectronBinary({ electronDir = defaultElectronDir(), env = process.env, platform = process.platform, fs = fsDefault } = {}) {
64
- let exeRel;
65
- const pathFile = path.join(electronDir, 'path.txt');
66
- try {
67
- exeRel = fs.readFileSync(pathFile, 'utf-8').trim();
68
- } catch {
69
- exeRel = '';
70
- }
71
- if (!exeRel) {
72
- exeRel = platformExe(platform);
73
- }
66
+ let raw = null;
67
+ try { raw = fs.readFileSync(path.join(electronDir, 'path.txt'), 'utf-8'); } catch { /* absent or unreadable */ }
68
+ const exeRel = heldExeRel(raw, platform); // the ONE rule (electron-exe-rel.js)
74
69
  const override = env.ELECTRON_OVERRIDE_DIST_PATH;
75
70
  if (override) {
76
71
  return path.join(override, exeRel);