amicus 4.5.0 → 4.5.2

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.
@@ -4,53 +4,207 @@
4
4
  "title": "amicus council-verdict document",
5
5
  "description": "Verdict record (`council verdict --json`, amicus_verdict, verdict.json). overallVerdict is the chair's parsed VERDICT line — null in every Stage-4 manual path, populated by the headless engine.",
6
6
  "type": "object",
7
- "required": ["schemaVersion", "type", "runId", "council", "overallVerdict", "findings", "streetCred", "runStats", "tierCounts"],
7
+ "required": [
8
+ "schemaVersion",
9
+ "type",
10
+ "runId",
11
+ "council",
12
+ "overallVerdict",
13
+ "findings",
14
+ "streetCred",
15
+ "runStats",
16
+ "tierCounts"
17
+ ],
8
18
  "properties": {
9
- "schemaVersion": { "const": 2 },
10
- "type": { "const": "council-verdict" },
11
- "runId": { "type": "string" },
12
- "runType": { "type": ["string", "null"] },
13
- "date": { "type": ["string", "null"] },
14
- "chair": { "type": ["string", "null"] },
15
- "council": { "type": "array", "items": { "type": "string" } },
16
- "claudeInCouncil": { "type": "boolean" },
19
+ "schemaVersion": {
20
+ "const": 2
21
+ },
22
+ "type": {
23
+ "const": "council-verdict"
24
+ },
25
+ "runId": {
26
+ "type": "string"
27
+ },
28
+ "runType": {
29
+ "type": [
30
+ "string",
31
+ "null"
32
+ ]
33
+ },
34
+ "date": {
35
+ "type": [
36
+ "string",
37
+ "null"
38
+ ]
39
+ },
40
+ "chair": {
41
+ "type": [
42
+ "string",
43
+ "null"
44
+ ]
45
+ },
46
+ "council": {
47
+ "type": "array",
48
+ "items": {
49
+ "type": "string"
50
+ }
51
+ },
52
+ "claudeInCouncil": {
53
+ "type": "boolean"
54
+ },
17
55
  "overallVerdict": {
18
56
  "oneOf": [
19
- { "enum": ["Ship it", "Fix these first", "Fundamental rethink"] },
20
- { "type": "null" }
57
+ {
58
+ "enum": [
59
+ "Ship it",
60
+ "Fix these first",
61
+ "Fundamental rethink"
62
+ ]
63
+ },
64
+ {
65
+ "type": "null"
66
+ }
21
67
  ]
22
68
  },
23
69
  "findings": {
24
70
  "type": "array",
25
71
  "items": {
26
72
  "type": "object",
27
- "required": ["id", "tier", "decision", "applied"],
73
+ "required": [
74
+ "id",
75
+ "tier",
76
+ "decision",
77
+ "applied"
78
+ ],
28
79
  "properties": {
29
- "id": { "type": "string" },
30
- "raiser": { "type": ["string", "null"] },
31
- "severity": { "type": ["string", "null"] },
32
- "tier": { "enum": ["Confirmed", "Contested", "Singleton", "Disputed"] },
33
- "basis": { "type": "object" },
34
- "confidence": { "enum": ["thin", "solid"] },
35
- "tierOverride": { "type": ["object", "null"] },
36
- "duplicateOf": { "type": ["string", "null"] },
37
- "adjudications": { "type": "array" },
38
- "decision": { "type": ["string", "null"] },
39
- "applied": { "type": "boolean" },
80
+ "id": {
81
+ "type": "string"
82
+ },
83
+ "raiser": {
84
+ "type": [
85
+ "string",
86
+ "null"
87
+ ]
88
+ },
89
+ "severity": {
90
+ "type": [
91
+ "string",
92
+ "null"
93
+ ]
94
+ },
95
+ "tier": {
96
+ "enum": [
97
+ "Confirmed",
98
+ "Contested",
99
+ "Singleton",
100
+ "Disputed"
101
+ ]
102
+ },
103
+ "basis": {
104
+ "type": "object"
105
+ },
106
+ "confidence": {
107
+ "enum": [
108
+ "thin",
109
+ "solid"
110
+ ]
111
+ },
112
+ "tierOverride": {
113
+ "type": [
114
+ "object",
115
+ "null"
116
+ ]
117
+ },
118
+ "duplicateOf": {
119
+ "type": [
120
+ "string",
121
+ "null"
122
+ ]
123
+ },
124
+ "adjudications": {
125
+ "type": "array"
126
+ },
127
+ "decision": {
128
+ "type": [
129
+ "string",
130
+ "null"
131
+ ]
132
+ },
133
+ "applied": {
134
+ "type": "boolean"
135
+ },
40
136
  "debate": {
41
137
  "type": "object",
42
138
  "properties": {
43
- "action": { "enum": ["defended", "amended", "withdrawn", "no-response"] },
44
- "previousTier": { "type": ["string", "null"] }
139
+ "action": {
140
+ "enum": [
141
+ "defended",
142
+ "amended",
143
+ "withdrawn",
144
+ "no-response"
145
+ ]
146
+ },
147
+ "previousTier": {
148
+ "type": [
149
+ "string",
150
+ "null"
151
+ ]
152
+ }
45
153
  },
46
- "required": ["action"],
154
+ "required": [
155
+ "action"
156
+ ],
47
157
  "additionalProperties": false
48
158
  }
49
159
  }
50
160
  }
51
161
  },
52
- "streetCred": { "type": "array", "items": { "type": "object" } },
53
- "runStats": { "type": "array", "items": { "type": "object" } },
54
- "tierCounts": { "type": "object" }
162
+ "streetCred": {
163
+ "type": "array",
164
+ "items": {
165
+ "type": "object"
166
+ }
167
+ },
168
+ "runStats": {
169
+ "type": "array",
170
+ "items": {
171
+ "type": "object"
172
+ }
173
+ },
174
+ "tierCounts": {
175
+ "type": "object"
176
+ },
177
+ "seatLoss": {
178
+ "type": "object",
179
+ "description": "Present only when --critic was requested. Records whether the adversarial seat actually reviewed: a dead critic wave is survivable (the quorum gate guards only the bench), so a run can otherwise reach a full verdict with the critic silently absent.",
180
+ "properties": {
181
+ "criticRequested": {
182
+ "type": "string",
183
+ "description": "The model asked for as critic."
184
+ },
185
+ "criticSeated": {
186
+ "type": "boolean",
187
+ "description": "False when the critic wave died before producing legs."
188
+ },
189
+ "reason": {
190
+ "type": [
191
+ "string",
192
+ "null"
193
+ ],
194
+ "description": "Why the critic wave died, when it did."
195
+ },
196
+ "deadBenchSeats": {
197
+ "type": "array",
198
+ "items": {
199
+ "type": "string"
200
+ },
201
+ "description": "Bench models lost to dead waves, excluding the critic."
202
+ }
203
+ },
204
+ "required": [
205
+ "criticRequested",
206
+ "criticSeated"
207
+ ]
208
+ }
55
209
  }
56
210
  }
@@ -7,6 +7,8 @@ const mcpChecks = require('./utils/doctor-mcp-checks');
7
7
  // engine-mcp check body — verifies the engine in the npx-cache copies the MCP
8
8
  // actually launches (bug report #1). Split out to keep this file under the gate.
9
9
  const engineCheck = require('./utils/doctor-engine-check');
10
+ // electron-mcp check body (#76) — same blind spot, electron-flavored.
11
+ const electronMcpCheck = require('./utils/doctor-electron-mcp-check');
10
12
  // local-providers check body (v4.2 §4.7 C8) — split out to keep this file
11
13
  // under the gate (mirrors the engineCheck/mcpChecks split above).
12
14
  const localProvidersCheck = require('./utils/doctor-local-providers-check');
@@ -47,6 +49,8 @@ function realDeps() {
47
49
  scanEngineInstalls: () => require('./utils/engine-install-scan').scanEngineInstalls(),
48
50
  // report #2: copy-from-sibling self-heal for `doctor --fix`.
49
51
  repairEngine: (o) => require('./utils/engine-repair').repairEngine(o),
52
+ // electron-mcp check (#76): same enumeration, electron probed per copy.
53
+ scanElectronInstalls: () => electronMcpCheck.scanElectronInstalls(),
50
54
  getElectronPath: () => require('./sidecar/interactive-process').getElectronPath(),
51
55
  // #56: self-heal primitive for `doctor --fix`. Pure probe (getElectronPath)
52
56
  // stays separate; repair only runs when fix is requested.
@@ -161,6 +165,12 @@ async function runDoctorChecks(depsOverride = {}) {
161
165
  // install) can't hide a broken copy the MCP would spawn (bug report #1/#4).
162
166
  checks.push(await guardAsync('engine-mcp', 'OpenCode engine (MCP launch path)', () => engineCheck.evaluateEngineMcp(d)));
163
167
 
168
+ // #76: same green-while-broken blind spot for Electron — probe the npx-cache
169
+ // copies `ui: true` actually depends on. fixTimeoutMs forwards the #56
170
+ // never-hang guard to the per-copy repairElectron calls.
171
+ checks.push(await guardAsync('electron-mcp', 'Electron (MCP launch path)',
172
+ () => electronMcpCheck.evaluateElectronMcp({ ...d, fixTimeoutMs: FIX_TIMEOUT_MS })));
173
+
164
174
  checks.push(await guardAsync('electron', 'Electron (interactive GUI)', async () => {
165
175
  if (d.getElectronPath()) {
166
176
  return { id: 'electron', name: 'Electron (interactive GUI)', status: 'ok', message: 'installed', hint: null };
@@ -19,7 +19,7 @@
19
19
  const fs = require('fs');
20
20
  const path = require('path');
21
21
  const { writeFileAtomic } = require('../utils/atomic-write');
22
- const { buildVerdict, writeVerdictAtomic } = require('./verdict');
22
+ const { buildVerdict, summarizeSeatLoss, writeVerdictAtomic } = require('./verdict');
23
23
  const { buildReport } = require('./report');
24
24
  const { validateFindings } = require('./findings');
25
25
  const { toGlobalFindings } = require('./anonymize');
@@ -178,8 +178,12 @@ function writeTallyFiles({ runDir, tallyInput, record }) {
178
178
  * buildVerdict's own signature.
179
179
  * @returns {object} the verdict written to disk
180
180
  */
181
- function writeVerdictFiles({ runDir, record, overallVerdict, chairText }) {
182
- const verdict = buildVerdict(record, []);
181
+ function writeVerdictFiles({ runDir, record, overallVerdict, chairText, critic, deadWaves }) {
182
+ // v4.5.2 — computed here rather than in run.js so verdict assembly stays in
183
+ // one place; see summarizeSeatLoss in ./verdict for why a lost critic has to
184
+ // reach the verdict at all.
185
+ const seatLoss = summarizeSeatLoss({ runId: record.meta.runId, critic, deadWaves });
186
+ const verdict = buildVerdict(record, [], { seatLoss });
183
187
  verdict.overallVerdict = (overallVerdict === undefined) ? null : overallVerdict;
184
188
  writeVerdictAtomic(path.join(runDir, 'verdict.json'), verdict);
185
189
  const html = buildReport({ verdict }, { format: 'html' });
@@ -284,7 +284,8 @@ async function runCouncil(options, deps = {}) {
284
284
  runState.updateStage(o.runDir, tallyStage, { status: 'complete', completedAt: now() });
285
285
  emitStageStarted(o.runDir, o.runId, tallyStage, null, o.follow);
286
286
  emitStageTerminal(o.runDir, o.runId, tallyStage, 'complete', null, o.follow);
287
- asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText });
287
+ asm.writeVerdictFiles({ runDir: o.runDir, record, overallVerdict, chairText,
288
+ critic: o.critic, deadWaves });
288
289
  runState.updateStage(o.runDir, 'verdict', { status: 'complete', completedAt: now() });
289
290
  emitStageStarted(o.runDir, o.runId, 'verdict', null, o.follow);
290
291
  emitStageTerminal(o.runDir, o.runId, 'verdict', 'complete', null, o.follow);
@@ -16,6 +16,44 @@ const VERDICT_SCHEMA_VERSION = 2;
16
16
  * @param {{overallVerdict?: (string|null)}} [opts] engine hook (Plan B): the
17
17
  * parsed chair `VERDICT:` line; omitted/undefined → null.
18
18
  */
19
+ /**
20
+ * Describe which requested seats actually reviewed, for the verdict's own face.
21
+ *
22
+ * ⚠️ ADDED v4.5.2 from a field report. The critic is a SOLO wave with one leg,
23
+ * so losing it loses 100% of the adversarial role — and unlike a dead bench wave
24
+ * (which trips the quorum gate and fails the run loudly) a dead critic is
25
+ * survivable, so the run continues to a full verdict, tally and chair synthesis
26
+ * that never saw the critic's findings. Run `dfb6a692` did exactly that and the
27
+ * only record was `deadWaves` in run.json, a file nobody opens when the verdict
28
+ * reads clean. A user who typed `--critic` asked for adversarial review; a
29
+ * verdict produced without it must say so where the verdict is read.
30
+ *
31
+ * Returns null when no critic was requested — there is nothing to report, and an
32
+ * always-present block would train readers to ignore it.
33
+ *
34
+ * @param {{runId: string, critic: ?string,
35
+ * deadWaves: Array<{waveId: string, models: string[], reason: string}>}} o
36
+ * @returns {?{criticRequested: string, criticSeated: boolean, reason: ?string,
37
+ * deadBenchSeats: string[]}}
38
+ */
39
+ function summarizeSeatLoss({ runId, critic, deadWaves = [] } = {}) {
40
+ if (!critic) { return null; }
41
+ // Match on EITHER carrier. The `-c1` suffix is the convention run-stages.js
42
+ // uses, but a wave that names the critic model is the critic wave whatever it
43
+ // is called — and relying on the id alone would silently under-report if that
44
+ // convention ever changes.
45
+ const isCriticWave = w =>
46
+ w.waveId === `${runId}-c1` || (w.models || []).includes(critic);
47
+ const dead = deadWaves.find(isCriticWave) || null;
48
+ return {
49
+ criticRequested: critic,
50
+ criticSeated: !dead,
51
+ reason: dead ? dead.reason : null,
52
+ deadBenchSeats: deadWaves.filter(w => !isCriticWave(w))
53
+ .flatMap(w => w.models || []),
54
+ };
55
+ }
56
+
19
57
  function buildVerdict(record, decisions = [], opts = {}) {
20
58
  const byId = new Map(decisions.map(d => [d.id, d]));
21
59
  return {
@@ -46,6 +84,9 @@ function buildVerdict(record, decisions = [], opts = {}) {
46
84
  streetCred: record.streetCred.map(s => ({ model: s.model, withSelf: s.withSelf, peersOnly: s.peersOnly })),
47
85
  runStats: record.runStats,
48
86
  tierCounts: record.tierCounts,
87
+ // Additive and OPTIONAL (schemaVersion stays 2): present only when a critic
88
+ // was requested, so its absence never has to be interpreted.
89
+ ...(opts.seatLoss ? { seatLoss: opts.seatLoss } : {}),
49
90
  };
50
91
  }
51
92
 
@@ -93,4 +134,6 @@ function writeVerdictAtomic(filePath, verdict) {
93
134
  fs.renameSync(tmp, filePath);
94
135
  }
95
136
 
96
- module.exports = { buildVerdict, readOverallVerdict, writeVerdictAtomic, VERDICT_SCHEMA_VERSION };
137
+ module.exports = {
138
+ buildVerdict, summarizeSeatLoss, readOverallVerdict, writeVerdictAtomic, VERDICT_SCHEMA_VERSION,
139
+ };
package/src/headless.js CHANGED
@@ -272,6 +272,12 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
272
272
  if (options.mcp) {
273
273
  serverOptions.mcp = options.mcp;
274
274
  }
275
+ // v4.5.2: explicit per-call override only — see the note at the matching
276
+ // hop in src/sidecar/session-utils.js. The default and the env knob both
277
+ // resolve downstream in buildServerOptions.
278
+ if (options.timeout !== undefined) {
279
+ serverOptions.timeout = options.timeout;
280
+ }
275
281
  // v4.4.1 fix wave (F5): this is the OTHER server-start site. It calls
276
282
  // startServer directly rather than going through startOpenCodeServer, so
277
283
  // the lock-class retry added for the concurrent-start race never covered
@@ -241,11 +241,13 @@ async function handleCouncilRunTool(input, project, helpers) {
241
241
  let workspaceOpened = false;
242
242
  let workspaceOpenReason = null;
243
243
  try {
244
- const { isElectronUsable } = require('./sidecar/electron-install');
244
+ const { probeElectronState } = require('./sidecar/electron-state');
245
245
  const { getWorkspaceAutoOpen } = require('./utils/config');
246
+ const es = probeElectronState(); // 3-state (#76): splits absent vs broken
246
247
  const decision = ao.decide({
247
248
  client: helpers.clientName,
248
- electronUsable: isElectronUsable(),
249
+ electronState: es.state,
250
+ electronDir: es.electronDir,
249
251
  platform: process.platform,
250
252
  env: process.env,
251
253
  autoOpenConfig: getWorkspaceAutoOpen(),
@@ -402,11 +402,67 @@ async function getSessionStatus(client, sessionId, directory) {
402
402
  return result.data || {};
403
403
  }
404
404
 
405
+ /**
406
+ * How long to wait for OpenCode to announce it is listening, per platform.
407
+ *
408
+ * ⚠️ ADDED v4.5.2 from a field report. `@opencode-ai/sdk` defaults this to
409
+ * 5000ms (`dist/server.js:4-8`) and lets the caller override it; amicus never
410
+ * passed one, so every start on every platform ran on the SDK's 5s — untunable
411
+ * and invisible to `amicus doctor`. A reporter's Windows box (project on a
412
+ * OneDrive-synced volume, Defender active) blew through it on a cold
413
+ * OpenCode/SQLite open: the council's shared server failed to acquire, the run
414
+ * degraded to the per-wave configuration `src/council/run-server.js` exists to
415
+ * eliminate, and the whole Stage-1 bench died (`COUNCIL_QUORUM: Only 0 …`).
416
+ *
417
+ * The asymmetry decides the number: a slow start costs LATENCY, a failed start
418
+ * costs a REVIEW SEAT. win32 gets the widest window because that is where
419
+ * sync-backed volumes and always-on AV filter drivers are the norm.
420
+ *
421
+ * This is a ceiling, not a sleep — a healthy start still resolves in well under
422
+ * a second and pays none of it.
423
+ */
424
+ const SERVER_START_TIMEOUT_MS = Object.freeze({ win32: 30000, default: 15000 });
425
+
426
+ /**
427
+ * Resolve the start timeout: explicit option → env override → platform default.
428
+ *
429
+ * `0` and negatives are REJECTED rather than honored. For most amicus knobs `0`
430
+ * is a documented disable switch (see src/utils/env-num.js), but a 0ms start
431
+ * timeout disables nothing — it fails every start instantly. That is an own-goal
432
+ * an operator can only reach by accident, so it falls back to the default.
433
+ *
434
+ * @param {object} [options] - Server options ({timeout} respected if positive)
435
+ * @param {object} [env] - Environment (test seam; defaults to process.env)
436
+ * @param {string} [platform] - Platform (test seam; defaults to process.platform)
437
+ * @returns {number} milliseconds
438
+ */
439
+ function resolveServerStartTimeoutMs(options = {}, env, platform) {
440
+ const plat = platform || process.platform;
441
+ const dflt = SERVER_START_TIMEOUT_MS[plat] || SERVER_START_TIMEOUT_MS.default;
442
+ const positive = (v) => {
443
+ const n = Number(v);
444
+ return Number.isFinite(n) && n > 0 ? n : null;
445
+ };
446
+ if (options.timeout !== undefined) {
447
+ const explicit = positive(options.timeout);
448
+ if (explicit) { return explicit; }
449
+ }
450
+ const raw = (env || process.env).AMICUS_SERVER_START_TIMEOUT_MS;
451
+ if (raw !== undefined && raw !== null && String(raw).trim() !== '') {
452
+ const fromEnv = positive(raw);
453
+ if (fromEnv) { return fromEnv; }
454
+ }
455
+ return dflt;
456
+ }
457
+
405
458
  /**
406
459
  * Build the server options object for createOpencodeServer.
407
460
  * Extracted for testability (no SDK dependency).
408
461
  *
409
462
  * @param {object} [options] - Server options
463
+ * @param {number} [options.timeout] - Start timeout in ms. Omit to use
464
+ * AMICUS_SERVER_START_TIMEOUT_MS or the platform default; NEVER omitted from
465
+ * the object handed to the SDK, so the SDK's own 5000ms default is unreachable.
410
466
  * @param {number} [options.port] - Port to run on
411
467
  * @param {string} [options.hostname='127.0.0.1'] - Hostname to bind to
412
468
  * @param {AbortSignal} [options.signal] - Abort signal to stop server
@@ -544,6 +600,9 @@ function buildServerOptions(options = {}) {
544
600
 
545
601
  const serverOptions = {
546
602
  hostname: options.hostname || '127.0.0.1',
603
+ // ALWAYS set — unlike port/signal below, an omitted timeout is not a
604
+ // harmless "let the SDK decide", it is the 5000ms that cost a bench.
605
+ timeout: resolveServerStartTimeoutMs(options),
547
606
  };
548
607
 
549
608
  // Only include port/signal when explicitly set — passing undefined
@@ -660,11 +719,27 @@ async function startServer(options = {}) {
660
719
  }
661
720
  }
662
721
 
663
- const createOpencodeServer = await getCreateOpencodeServer();
722
+ // `_createOpencodeServer` is a test seam, matching `_hasOpencodeBinary` /
723
+ // `_ensureEngine` / `_opencodeRoots` above: the SDK arrives through a dynamic
724
+ // `import()`, which `jest.mock` cannot intercept under CommonJS, so the start
725
+ // path is otherwise unreachable from a unit test.
726
+ const createOpencodeServer = options._createOpencodeServer
727
+ || await getCreateOpencodeServer();
664
728
  const serverOptions = buildServerOptions(options);
665
729
 
730
+ // Measure the healthy path. The v4.5.2 timeout had to be sized from the
731
+ // asymmetry of the failure (a slow start costs latency, a failed one costs a
732
+ // review seat) because nothing recorded how long a GOOD start takes — so the
733
+ // margin against the ceiling was unmeasurable on exactly the slow boxes that
734
+ // needed it. Now it is one debug line, not an inference.
735
+ const startedAt = Date.now();
666
736
  const sdkServer = await createOpencodeServer(serverOptions);
667
- const client = await createClient(sdkServer.url);
737
+ const { logger } = require('./utils/logger');
738
+ logger.debug('OpenCode server started', {
739
+ startMs: Date.now() - startedAt,
740
+ timeoutMs: serverOptions.timeout,
741
+ });
742
+ const client = await (options._createClient || createClient)(sdkServer.url);
668
743
 
669
744
  // Capture the Go server PID once so close() can force-kill it cross-platform
670
745
  // (F3 #15). Prefer a PID the SDK exposes; fall back to the port listener.
@@ -822,6 +897,8 @@ module.exports = {
822
897
  abortSession,
823
898
  checkHealth,
824
899
  buildServerOptions,
900
+ resolveServerStartTimeoutMs,
901
+ SERVER_START_TIMEOUT_MS,
825
902
  buildServerHandle,
826
903
  startServer,
827
904
  loadMcpConfig,
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Electron install-state probes (#76).
3
+ *
4
+ * Distinguishes the two states isElectronUsable() collapses into one boolean:
5
+ * 'package-missing' — the electron optionalDependency was never installed
6
+ * (possibly deliberate: headless-only install)
7
+ * 'binary-missing' — package present but dist/<exe> absent (interrupted
8
+ * postinstall, AV quarantine) — repairElectron territory
9
+ * 'ok' — the resolved exe exists on disk
10
+ *
11
+ * Also owns electronDirFor(): the per-install dual-root layout probe. npm
12
+ * NESTS electron under amicus/node_modules in a global install but HOISTS it
13
+ * to a sibling in the npx cache — the same layout asymmetry that broke the
14
+ * engine probe (#69). In-process require.resolve handles hoisting via walk-up,
15
+ * which is why the running copy probes itself fine; only CROSS-install probing
16
+ * (doctor) needs this explicit dual-root check.
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const path = require('path');
22
+ const fsDefault = require('fs');
23
+ const { isElectronUsable, defaultElectronDir } = require('./electron-install');
24
+
25
+ /**
26
+ * Locate the electron package dir serving a given amicus install. Checks the
27
+ * nested root first (require.resolve walk-up order), then the hoisted sibling.
28
+ * @param {string} pkgDir amicus package root
29
+ * @param {{fs?:object}} [deps]
30
+ * @returns {string|null} electron package dir, or null when not installed
31
+ */
32
+ function electronDirFor(pkgDir, { fs = fsDefault } = {}) {
33
+ const candidates = [
34
+ path.join(pkgDir, 'node_modules', 'electron'),
35
+ path.join(path.dirname(pkgDir), 'electron'),
36
+ ];
37
+ for (const dir of candidates) {
38
+ try { if (fs.existsSync(dir)) { return dir; } } catch { /* unreadable root */ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Probe one electron package dir into the 3-state verdict.
45
+ * `electronDir: null` (electronDirFor found nothing) short-circuits to
46
+ * package-missing; an explicit dir without package.json reports the same.
47
+ * @param {{electronDir?:string|null, fs?:object, platform?:string, env?:object}} [opts]
48
+ * @returns {{state:'ok'|'package-missing'|'binary-missing', electronDir:string|null}}
49
+ */
50
+ function probeElectronState({
51
+ electronDir = defaultElectronDir(), fs = fsDefault, platform = process.platform, env = process.env,
52
+ } = {}) {
53
+ if (!electronDir) { return { state: 'package-missing', electronDir: null }; }
54
+ let hasPkg = false;
55
+ try { hasPkg = fs.existsSync(path.join(electronDir, 'package.json')); } catch { /* unreadable */ }
56
+ if (!hasPkg) { return { state: 'package-missing', electronDir }; }
57
+ const usable = isElectronUsable({ electronDir, fs, platform, env });
58
+ return { state: usable ? 'ok' : 'binary-missing', electronDir };
59
+ }
60
+
61
+ module.exports = { electronDirFor, probeElectronState };
@@ -253,6 +253,10 @@ async function startOpenCodeServer(mcpConfig, options = {}) {
253
253
  if (options.models) { serverOptions.models = options.models; }
254
254
  if (options.systemPrompt) { serverOptions.systemPrompt = options.systemPrompt; }
255
255
  if (options.agentName) { serverOptions.agentName = options.agentName; }
256
+ // Explicit per-call override only. Unset is the normal case and is correct:
257
+ // buildServerOptions resolves AMICUS_SERVER_START_TIMEOUT_MS / the platform
258
+ // default downstream, so forwarding `undefined` here would change nothing.
259
+ if (options.timeout !== undefined) { serverOptions.timeout = options.timeout; }
256
260
 
257
261
  // v4.4.1 Task 0.5: a LOCK-CLASS start failure is retried (5 attempts,
258
262
  // 250/500/1000/2000ms — widened from 3/750ms by Step 10.5, see server-setup).
@@ -173,7 +173,22 @@ async function robustExtract(zip, opts = {}) {
173
173
  deps = {},
174
174
  } = opts;
175
175
  const fs = deps.fs || fsDefault;
176
- const extractZip = deps.extractZip || require('extract-zip');
176
+ // GUARDED (v4.5.2). This `require` used to be bare, and `extract-zip` was
177
+ // never declared in dependencies — it resolved in the dev tree only because
178
+ // `puppeteer` (a devDependency) pulls it transitively, so a published install
179
+ // threw MODULE_NOT_FOUND here and took the WHOLE function with it: the native
180
+ // fallback below, the bounded idle/max timers, and `doctor --fix` all became
181
+ // unreachable. The dependency is now declared, so this should never fire —
182
+ // but Strategy 1 being unavailable is precisely what the native strategies
183
+ // exist for, so it must degrade into them rather than out of the function.
184
+ let extractZip = deps.extractZip;
185
+ if (!extractZip) {
186
+ try {
187
+ extractZip = require('extract-zip');
188
+ } catch (e) {
189
+ extractZip = () => { throw new Error(`extract-zip unavailable: ${e.message}`); };
190
+ }
191
+ }
177
192
  const spawn = deps.spawn || spawnSync;
178
193
  const setTimer = deps.setTimeout || setTimeout;
179
194
  const clearTimer = deps.clearTimeout || clearTimeout;
@@ -7,7 +7,10 @@
7
7
  * Decision order (spec §6 guard 4):
8
8
  * 1. uiParam === false → 'param-suppressed' (explicit user request beats everything, checked first)
9
9
  * 2. Hard guards (always checked, beat even explicit true):
10
- * - !electronUsable → 'electron-absent'
10
+ * - electron package-missing → 'electron-absent'
11
+ * - electron binary-missing → 'electron-broken: …' (#76: package present but
12
+ * the exe never arrived — a repairable state the old boolean conflated
13
+ * with never-installed; the reason names the dir and the fix)
11
14
  * - platform === 'linux' && !env.DISPLAY → 'no-display'
12
15
  * 3. uiParam === true → 'ok' (explicit request overrides config and client gate, never hard guards)
13
16
  * 4. autoOpenConfig === false → 'config-disabled'
@@ -16,7 +19,9 @@
16
19
  *
17
20
  * @param {object} options
18
21
  * @param {string} options.client - The client type (e.g., 'code-local', 'cowork', 'code-web')
19
- * @param {boolean} options.electronUsable - Whether Electron is available
22
+ * @param {boolean} [options.electronUsable] - Legacy boolean probe (used only when electronState is absent)
23
+ * @param {'ok'|'package-missing'|'binary-missing'} [options.electronState] - 3-state probe (#76); takes precedence
24
+ * @param {string|null} [options.electronDir] - Resolved electron dir, named in the electron-broken reason
20
25
  * @param {string} options.platform - The platform (e.g., 'win32', 'darwin', 'linux')
21
26
  * @param {object} options.env - Environment variables object
22
27
  * @param {boolean} options.autoOpenConfig - The config.workspace.autoOpen setting
@@ -26,6 +31,8 @@
26
31
  function shouldAutoOpenWorkspace({
27
32
  client,
28
33
  electronUsable,
34
+ electronState,
35
+ electronDir,
29
36
  platform,
30
37
  env,
31
38
  autoOpenConfig,
@@ -36,10 +43,17 @@ function shouldAutoOpenWorkspace({
36
43
  return { open: false, reason: 'param-suppressed' };
37
44
  }
38
45
 
39
- // Step 2: Hard guards (always checked, beat even explicit uiParam === true)
40
- if (!electronUsable) {
46
+ // Step 2: Hard guards (always checked, beat even explicit uiParam === true).
47
+ // electronState (3-state, #76) wins over the legacy electronUsable boolean,
48
+ // whose false collapses to package-missing (the old 'electron-absent').
49
+ const state = electronState || (electronUsable ? 'ok' : 'package-missing');
50
+ if (state === 'package-missing') {
41
51
  return { open: false, reason: 'electron-absent' };
42
52
  }
53
+ if (state === 'binary-missing') {
54
+ const where = electronDir ? ` under ${electronDir}` : '';
55
+ return { open: false, reason: `electron-broken: binary missing${where} — run \`amicus doctor --fix\`` };
56
+ }
43
57
 
44
58
  if (platform === 'linux' && !env.DISPLAY) {
45
59
  return { open: false, reason: 'no-display' };