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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +71 -0
- package/README.md +105 -89
- package/docs/ROADMAP.md +6 -8
- package/docs/configuration.md +16 -0
- package/docs/council.md +5 -2
- package/docs/usage.md +1 -1
- package/package.json +3 -2
- package/schemas/council-verdict.schema.json +183 -29
- package/src/cli-handlers-doctor.js +10 -0
- package/src/council/run-assemble.js +7 -3
- package/src/council/run.js +2 -1
- package/src/council/verdict.js +44 -1
- package/src/headless.js +6 -0
- package/src/mcp-council-run.js +4 -2
- package/src/opencode-client.js +79 -2
- package/src/sidecar/electron-state.js +61 -0
- package/src/sidecar/session-utils.js +4 -0
- package/src/sidecar/unzip.js +16 -1
- package/src/sidecar/workspace-auto-open.js +18 -4
- package/src/utils/doctor-electron-mcp-check.js +150 -0
- package/src/utils/server-setup.js +74 -7
|
@@ -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": [
|
|
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": {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
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
|
-
{
|
|
20
|
-
|
|
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": [
|
|
73
|
+
"required": [
|
|
74
|
+
"id",
|
|
75
|
+
"tier",
|
|
76
|
+
"decision",
|
|
77
|
+
"applied"
|
|
78
|
+
],
|
|
28
79
|
"properties": {
|
|
29
|
-
"id": {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
|
|
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": {
|
|
44
|
-
|
|
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": [
|
|
154
|
+
"required": [
|
|
155
|
+
"action"
|
|
156
|
+
],
|
|
47
157
|
"additionalProperties": false
|
|
48
158
|
}
|
|
49
159
|
}
|
|
50
160
|
}
|
|
51
161
|
},
|
|
52
|
-
"streetCred": {
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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' });
|
package/src/council/run.js
CHANGED
|
@@ -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);
|
package/src/council/verdict.js
CHANGED
|
@@ -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 = {
|
|
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
|
package/src/mcp-council-run.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
249
|
+
electronState: es.state,
|
|
250
|
+
electronDir: es.electronDir,
|
|
249
251
|
platform: process.platform,
|
|
250
252
|
env: process.env,
|
|
251
253
|
autoOpenConfig: getWorkspaceAutoOpen(),
|
package/src/opencode-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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).
|
package/src/sidecar/unzip.js
CHANGED
|
@@ -173,7 +173,22 @@ async function robustExtract(zip, opts = {}) {
|
|
|
173
173
|
deps = {},
|
|
174
174
|
} = opts;
|
|
175
175
|
const fs = deps.fs || fsDefault;
|
|
176
|
-
|
|
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
|
-
* -
|
|
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 -
|
|
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
|
-
|
|
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' };
|