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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +246 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +3 -3
- package/docs/architecture-map.md +24 -4
- package/docs/configuration.md +43 -15
- package/docs/council.md +140 -3
- package/docs/electron-testing.md +133 -0
- package/docs/troubleshooting.md +14 -7
- package/docs/usage.md +8 -4
- package/package.json +1 -1
- package/schemas/council-verdict.schema.json +3 -1
- package/skills/second-opinion/SEAT-BRIEFS.md +6 -0
- package/src/cli-council-run-tools.js +168 -0
- package/src/cli-handlers-council-run.js +6 -6
- package/src/cli.js +23 -1
- package/src/council/briefings-chair.js +1 -1
- package/src/council/briefings-task.js +11 -5
- package/src/council/briefings.js +25 -7
- package/src/council/report-lost-rows.js +89 -0
- package/src/council/report-md.js +3 -1
- package/src/council/report.js +3 -2
- package/src/council/run-degrade.js +22 -1
- package/src/council/run-finish.js +23 -1
- package/src/council/run-launch.js +33 -4
- package/src/council/run-retry-launch.js +9 -4
- package/src/council/run-retry.js +3 -0
- package/src/council/run-seat-tools-verify.js +296 -0
- package/src/council/run-seat-tools.js +274 -0
- package/src/council/run-server.js +41 -6
- package/src/council/run-stage1-launch.js +8 -3
- package/src/council/run.js +21 -21
- package/src/council/seat-tools.js +299 -0
- package/src/council/verdict-seats-reviewed.js +76 -6
- package/src/headless.js +136 -6
- package/src/mcp-council-pack-map.js +24 -0
- package/src/mcp-council-run.js +17 -15
- package/src/mcp-server.js +2 -2
- package/src/mcp-tools.js +15 -4
- package/src/opencode-client.js +26 -0
- package/src/pack/pack-validate.js +3 -1
- package/src/prompt-builder.js +2 -2
- package/src/sidecar/electron-exe-rel.js +131 -0
- package/src/sidecar/electron-install.js +7 -12
- package/src/sidecar/electron-layout.js +31 -31
- package/src/sidecar/electron-native-plan.js +23 -5
- package/src/sidecar/electron-native-rescue.js +55 -16
- package/src/sidecar/electron-rescue-notice.js +18 -1
- package/src/sidecar/fanout.js +7 -1
- package/src/sidecar/heartbeat.js +46 -0
- package/src/sidecar/session-utils.js +7 -34
- package/src/sidecar/zip-from-buffer.js +16 -5
- package/src/sidecar/zip-local-name-scan.js +238 -0
- package/src/sidecar/zip-name-scan.js +5 -0
- package/src/utils/agent-mapping.js +1 -1
- 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
|
|
1205
|
-
//
|
|
1206
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
package/src/mcp-council-run.js
CHANGED
|
@@ -27,21 +27,11 @@ function textResult(text, isError) {
|
|
|
27
27
|
return result;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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-
|
|
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:
|
|
77
|
-
'
|
|
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:
|
|
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 |
|
|
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.
|
package/src/opencode-client.js
CHANGED
|
@@ -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
|
|
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
|
package/src/prompt-builder.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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-
|
|
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-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
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);
|