amicus 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +59 -0
- package/electron/load-failsafe.js +11 -4
- package/electron/main.js +22 -13
- package/electron/opencode-theme.js +130 -0
- package/electron/preload.js +1 -1
- package/electron/session-route.js +28 -0
- package/package.json +1 -1
- package/scripts/postinstall.js +59 -35
- package/src/cli-handlers-doctor.js +90 -14
- package/src/cli-handlers-run.js +4 -0
- package/src/cli.js +53 -0
- package/src/headless.js +21 -7
- package/src/mcp-server.js +167 -24
- package/src/mcp-tools.js +17 -1
- package/src/opencode-client.js +44 -10
- package/src/session-manager.js +5 -0
- package/src/sidecar/electron-cache.js +42 -0
- package/src/sidecar/electron-ensure.js +92 -0
- package/src/sidecar/electron-install.js +244 -0
- package/src/sidecar/fanout.js +3 -0
- package/src/sidecar/interactive.js +43 -17
- package/src/sidecar/setup-window.js +12 -7
- package/src/sidecar/setup.js +2 -0
- package/src/utils/project-path.js +61 -0
- package/src/utils/project-root-sanity.js +66 -0
- package/src/utils/remediation-hints.js +51 -0
- package/src/utils/result-schema.js +16 -3
- package/src/utils/session-index.js +98 -0
- package/src/utils/session-path.js +68 -0
- package/src/utils/validators.js +3 -27
- package/src/utils/version-info.js +49 -0
package/src/mcp-tools.js
CHANGED
|
@@ -115,7 +115,7 @@ function getTools() {
|
|
|
115
115
|
},
|
|
116
116
|
{
|
|
117
117
|
name: 'amicus_status',
|
|
118
|
-
annotations: { readOnlyHint:
|
|
118
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
119
119
|
description:
|
|
120
120
|
'Check the status of a running Amicus session. Returns status ' +
|
|
121
121
|
'(running/complete), elapsed time, and progress info. Primarily ' +
|
|
@@ -279,6 +279,14 @@ function getTools() {
|
|
|
279
279
|
includeContext: z.boolean().optional().default(true).describe(
|
|
280
280
|
'Include parent conversation context (built once, shared by all legs). Set false for self-contained briefings.'
|
|
281
281
|
),
|
|
282
|
+
coworkProcess: z.string().optional().describe(
|
|
283
|
+
'Cowork VM process name (e.g., "modest-laughing-goodall"). ' +
|
|
284
|
+
'Extract from CWD: /sessions/<name>/. Required for parent context loading from Cowork.'
|
|
285
|
+
),
|
|
286
|
+
parentSession: z.string().optional().describe(
|
|
287
|
+
'Claude Code session UUID for exact context matching. ' +
|
|
288
|
+
'Prevents ambiguity when multiple sessions are active in the same project.'
|
|
289
|
+
),
|
|
282
290
|
project: z.string().optional().describe(
|
|
283
291
|
'Optional project directory path. Auto-detected from working directory if omitted.'
|
|
284
292
|
),
|
|
@@ -360,13 +368,21 @@ function getTools() {
|
|
|
360
368
|
*/
|
|
361
369
|
function getGuideText() {
|
|
362
370
|
const { getEffectiveAliases } = require('./utils/config');
|
|
371
|
+
const { RUNNING_VERSION, versionWarning } = require('./utils/version-info');
|
|
363
372
|
const aliases = getEffectiveAliases();
|
|
364
373
|
const aliasRows = Object.entries(aliases)
|
|
365
374
|
.map(([name, model]) => `| ${name} | ${model} |`)
|
|
366
375
|
.join('\n');
|
|
376
|
+
// #33: surface the running version (and a call-time staleness warning) so a
|
|
377
|
+
// post-upgrade agent session can tell it's running old code.
|
|
378
|
+
const warn = versionWarning();
|
|
379
|
+
const versionLine = `**Running amicus version:** ${RUNNING_VERSION}`
|
|
380
|
+
+ (warn ? `\n\n> ⚠️ ${warn}` : '');
|
|
367
381
|
|
|
368
382
|
return `# Amicus Usage Guide
|
|
369
383
|
|
|
384
|
+
${versionLine}
|
|
385
|
+
|
|
370
386
|
## What Is Amicus?
|
|
371
387
|
Amicus spawns parallel conversations with different LLMs and folds results back into your context.
|
|
372
388
|
|
package/src/opencode-client.js
CHANGED
|
@@ -107,15 +107,34 @@ async function createClient(baseUrl) {
|
|
|
107
107
|
return createOpencodeClient(config);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Build an optional `query: { directory }` fragment to spread into an SDK call.
|
|
112
|
+
*
|
|
113
|
+
* Returns an EMPTY object when no directory is supplied so spreading it is a
|
|
114
|
+
* true no-op — the emitted request is byte-for-byte identical to a call that
|
|
115
|
+
* never knew about `directory`. Only when a directory IS passed does a
|
|
116
|
+
* `query: { directory }` key appear on the wire (the SDK accepts
|
|
117
|
+
* `query?: { directory?: string }` on every session endpoint).
|
|
118
|
+
*
|
|
119
|
+
* @param {string} [directory] - Optional project directory to scope the call to.
|
|
120
|
+
* @returns {{query?: {directory: string}}} Fragment to spread into SDK args.
|
|
121
|
+
*/
|
|
122
|
+
function directoryQuery(directory) {
|
|
123
|
+
return directory === undefined ? {} : { query: { directory } };
|
|
124
|
+
}
|
|
125
|
+
|
|
110
126
|
/**
|
|
111
127
|
* Create a new session
|
|
112
128
|
*
|
|
113
129
|
* @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
|
|
130
|
+
* @param {string} [directory] - Optional project directory to scope the session
|
|
131
|
+
* to (threaded to the SDK as query.directory). Omitting it keeps the call
|
|
132
|
+
* byte-for-byte identical to before.
|
|
114
133
|
* @returns {Promise<string>} Session ID
|
|
115
134
|
* @throws {Error} If session creation fails
|
|
116
135
|
*/
|
|
117
|
-
async function createSession(client) {
|
|
118
|
-
const result = await client.session.create({});
|
|
136
|
+
async function createSession(client, directory) {
|
|
137
|
+
const result = await client.session.create({ ...directoryQuery(directory) });
|
|
119
138
|
|
|
120
139
|
if (result.error) {
|
|
121
140
|
throw new Error(result.error.message || 'Failed to create session');
|
|
@@ -145,10 +164,13 @@ async function createSession(client) {
|
|
|
145
164
|
* @param {object} [options.reasoning] - Reasoning/thinking configuration
|
|
146
165
|
* @param {string} [options.reasoning.effort] - Effort level: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'none'
|
|
147
166
|
* @param {object} [options.watchdog] - IdleWatchdog instance to signal busy/idle around the API call
|
|
167
|
+
* @param {string} [options.directory] - Optional project directory to scope the
|
|
168
|
+
* call to (threaded to the SDK as query.directory). Omitting it keeps the
|
|
169
|
+
* call byte-for-byte identical to before.
|
|
148
170
|
* @returns {Promise<object>} API response
|
|
149
171
|
*/
|
|
150
172
|
async function sendPrompt(client, sessionId, options) {
|
|
151
|
-
const { model, system, parts, agent, tools, reasoning, watchdog } = options;
|
|
173
|
+
const { model, system, parts, agent, tools, reasoning, watchdog, directory } = options;
|
|
152
174
|
|
|
153
175
|
// Parse model string to SDK format
|
|
154
176
|
const modelSpec = parseModelString(model);
|
|
@@ -185,7 +207,8 @@ async function sendPrompt(client, sessionId, options) {
|
|
|
185
207
|
try {
|
|
186
208
|
result = await client.session.promptAsync({
|
|
187
209
|
path: { id: sessionId },
|
|
188
|
-
body
|
|
210
|
+
body,
|
|
211
|
+
...directoryQuery(directory)
|
|
189
212
|
});
|
|
190
213
|
} finally {
|
|
191
214
|
if (watchdog) {
|
|
@@ -227,11 +250,15 @@ async function sendPrompt(client, sessionId, options) {
|
|
|
227
250
|
*
|
|
228
251
|
* @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
|
|
229
252
|
* @param {string} sessionId - Session ID
|
|
253
|
+
* @param {string} [directory] - Optional project directory to scope the call to
|
|
254
|
+
* (threaded to the SDK as query.directory). Omitting it keeps the call
|
|
255
|
+
* byte-for-byte identical to before.
|
|
230
256
|
* @returns {Promise<Array>} Array of messages
|
|
231
257
|
*/
|
|
232
|
-
async function getMessages(client, sessionId) {
|
|
258
|
+
async function getMessages(client, sessionId, directory) {
|
|
233
259
|
const result = await client.session.messages({
|
|
234
|
-
path: { id: sessionId }
|
|
260
|
+
path: { id: sessionId },
|
|
261
|
+
...directoryQuery(directory)
|
|
235
262
|
});
|
|
236
263
|
|
|
237
264
|
return result.data || [];
|
|
@@ -314,10 +341,13 @@ async function listSessions(client) {
|
|
|
314
341
|
*
|
|
315
342
|
* @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
|
|
316
343
|
* @param {string} sessionId - Session ID to abort
|
|
344
|
+
* @param {string} [directory] - Optional project directory to scope the call to
|
|
345
|
+
* (threaded to the SDK as query.directory). Omitting it keeps the call
|
|
346
|
+
* byte-for-byte identical to before.
|
|
317
347
|
* @returns {Promise<void>}
|
|
318
348
|
*/
|
|
319
|
-
async function abortSession(client, sessionId) {
|
|
320
|
-
await client.session.abort({ path: { id: sessionId } });
|
|
349
|
+
async function abortSession(client, sessionId, directory) {
|
|
350
|
+
await client.session.abort({ path: { id: sessionId }, ...directoryQuery(directory) });
|
|
321
351
|
}
|
|
322
352
|
|
|
323
353
|
/**
|
|
@@ -325,11 +355,15 @@ async function abortSession(client, sessionId) {
|
|
|
325
355
|
*
|
|
326
356
|
* @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
|
|
327
357
|
* @param {string} sessionId - Session ID
|
|
358
|
+
* @param {string} [directory] - Optional project directory to scope the call to
|
|
359
|
+
* (threaded to the SDK as query.directory). Omitting it keeps the call
|
|
360
|
+
* byte-for-byte identical to before.
|
|
328
361
|
* @returns {Promise<Object>} Session status
|
|
329
362
|
*/
|
|
330
|
-
async function getSessionStatus(client, sessionId) {
|
|
363
|
+
async function getSessionStatus(client, sessionId, directory) {
|
|
331
364
|
const result = await client.session.status({
|
|
332
|
-
path: { id: sessionId }
|
|
365
|
+
path: { id: sessionId },
|
|
366
|
+
...directoryQuery(directory)
|
|
333
367
|
});
|
|
334
368
|
|
|
335
369
|
return result.data || {};
|
package/src/session-manager.js
CHANGED
|
@@ -113,6 +113,11 @@ function createSession(projectDir, taskId, metadata) {
|
|
|
113
113
|
|
|
114
114
|
// Create empty conversation.jsonl
|
|
115
115
|
fs.writeFileSync(path.join(sessionDir, 'conversation.jsonl'), '', { mode: 0o600 });
|
|
116
|
+
|
|
117
|
+
// #40: record the taskId -> project mapping in the global index so a later
|
|
118
|
+
// lookup that defaults to a DIFFERENT project can still find this session.
|
|
119
|
+
// Best-effort: recordSession never throws.
|
|
120
|
+
require('./utils/session-index').recordSession(taskId, metadata.project || projectDir);
|
|
116
121
|
}
|
|
117
122
|
|
|
118
123
|
/**
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Electron download-cache root resolution (#53 helper).
|
|
3
|
+
*
|
|
4
|
+
* Split out of electron-install.js to keep that module under the 300-line
|
|
5
|
+
* size gate. Mirrors @electron/get's cache-root precedence:
|
|
6
|
+
* - electron_config_cache (npm config / .npmrc)
|
|
7
|
+
* - ELECTRON_CACHE (env override)
|
|
8
|
+
* - the platform default from env-paths('electron').cache
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
|
|
16
|
+
/** Platform default cache dir, mirroring env-paths('electron',{suffix:''}).cache. */
|
|
17
|
+
function defaultCacheRoot(env = process.env) {
|
|
18
|
+
const home = env.HOME || os.homedir();
|
|
19
|
+
if (process.platform === 'win32') {
|
|
20
|
+
const localAppData = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
21
|
+
return path.join(localAppData, 'electron', 'Cache');
|
|
22
|
+
}
|
|
23
|
+
if (process.platform === 'darwin') {
|
|
24
|
+
return path.join(home, 'Library', 'Caches', 'electron');
|
|
25
|
+
}
|
|
26
|
+
const xdg = env.XDG_CACHE_HOME || path.join(home, '.cache');
|
|
27
|
+
return path.join(xdg, 'electron');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Ordered, de-duplicated list of cache roots to probe for a cached zip.
|
|
32
|
+
* @returns {string[]}
|
|
33
|
+
*/
|
|
34
|
+
function resolveCacheRoots(env = process.env) {
|
|
35
|
+
const roots = [];
|
|
36
|
+
if (env.electron_config_cache) { roots.push(env.electron_config_cache); }
|
|
37
|
+
if (env.ELECTRON_CACHE) { roots.push(env.ELECTRON_CACHE); }
|
|
38
|
+
roots.push(defaultCacheRoot(env));
|
|
39
|
+
return [...new Set(roots.filter(Boolean))];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { resolveCacheRoots, defaultCacheRoot };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ensureElectron() — lazy first-GUI provisioning (#55).
|
|
3
|
+
*
|
|
4
|
+
* This is the ONLY entry point allowed to PROVISION (download/extract) electron,
|
|
5
|
+
* and only on FIRST GUI use. getElectronPath()/checkElectronAvailable() stay
|
|
6
|
+
* PURE PROBES: the doctor check (cli-handlers-doctor.js) and MCP amicus_setup
|
|
7
|
+
* (mcp-server.js) depend on that purity — making a probe provision would silently
|
|
8
|
+
* fetch ~170MB. Kept in its own module so src/sidecar/electron-install.js stays
|
|
9
|
+
* under the 300-line size gate.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const {
|
|
15
|
+
isElectronUsable: defaultIsUsable,
|
|
16
|
+
resolveElectronBinary: defaultResolve,
|
|
17
|
+
repairElectron: defaultRepair,
|
|
18
|
+
} = require('./electron-install');
|
|
19
|
+
const HINTS = require('../utils/remediation-hints');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Module-level single-flight guard. Holds the in-flight (or last SUCCESSFUL)
|
|
23
|
+
* provision promise so repeated GUI launches in one process never re-download.
|
|
24
|
+
* A FAILED provision is cleared so a later launch may retry.
|
|
25
|
+
*/
|
|
26
|
+
let _ensurePromise = null;
|
|
27
|
+
|
|
28
|
+
/** Test-only: clear the once-guard so each test starts single-flight-clean. */
|
|
29
|
+
function _resetEnsureElectron() {
|
|
30
|
+
_ensurePromise = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Lazily PROVISION electron on FIRST GUI use.
|
|
35
|
+
*
|
|
36
|
+
* Flow: if isElectronUsable() return the resolved path (NO repair). Otherwise
|
|
37
|
+
* call repairElectron({cacheOnly:false}) — network ALLOWED here, this is an
|
|
38
|
+
* explicit first-use provision — with progress messaging, then re-check.
|
|
39
|
+
*
|
|
40
|
+
* Single-flight: the in-flight / last-successful promise is memoized so
|
|
41
|
+
* concurrent and repeated launches share ONE provision. A failed attempt is
|
|
42
|
+
* NOT cached (the guard is cleared) so a later launch can retry.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} opts
|
|
45
|
+
* @param {object} [opts.deps] injected
|
|
46
|
+
* { isElectronUsable, resolveElectronBinary, repairElectron, logProgress }.
|
|
47
|
+
* @param {object} [opts.repairOptions] forwarded to repairElectron (electronDir, etc.).
|
|
48
|
+
* @returns {Promise<{ok:boolean, path?:string, reason?:string}>}
|
|
49
|
+
*/
|
|
50
|
+
function ensureElectron({ deps = {}, repairOptions = {} } = {}) {
|
|
51
|
+
const usable = deps.isElectronUsable || defaultIsUsable;
|
|
52
|
+
const resolve = deps.resolveElectronBinary || defaultResolve;
|
|
53
|
+
const repair = deps.repairElectron || defaultRepair;
|
|
54
|
+
const logProgress = deps.logProgress
|
|
55
|
+
|| ((msg) => { try { process.stderr.write(`${msg}\n`); } catch { /* ignore */ } });
|
|
56
|
+
|
|
57
|
+
// Fast path: already provisioned. Cheap stat — safe to run every launch.
|
|
58
|
+
if (usable()) {
|
|
59
|
+
return Promise.resolve({ ok: true, path: resolve() });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Single-flight: reuse an in-flight (or already-succeeded) provision.
|
|
63
|
+
if (_ensurePromise) { return _ensurePromise; }
|
|
64
|
+
|
|
65
|
+
_ensurePromise = (async () => {
|
|
66
|
+
logProgress('[amicus] Provisioning the Electron GUI binary (first GUI use, ~170MB). This runs once...');
|
|
67
|
+
let result;
|
|
68
|
+
try {
|
|
69
|
+
result = await repair({ cacheOnly: false, ...repairOptions });
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return { ok: false, reason: `Electron provisioning failed: ${err && err.message}` };
|
|
72
|
+
}
|
|
73
|
+
if (usable()) {
|
|
74
|
+
logProgress('[amicus] Electron GUI ready.');
|
|
75
|
+
return { ok: true, path: resolve() };
|
|
76
|
+
}
|
|
77
|
+
const reason = (result && result.reason)
|
|
78
|
+
|| `Electron could not be provisioned; the GUI is unavailable. ${HINTS.doctorFix} (or use --no-ui).`;
|
|
79
|
+
return { ok: false, reason };
|
|
80
|
+
})().then((r) => {
|
|
81
|
+
// Only memoize SUCCESS; a failure clears the guard so a later launch retries.
|
|
82
|
+
if (!r.ok) { _ensurePromise = null; }
|
|
83
|
+
return r;
|
|
84
|
+
}, (err) => {
|
|
85
|
+
_ensurePromise = null;
|
|
86
|
+
throw err;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return _ensurePromise;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { ensureElectron, _resetEnsureElectron };
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Electron self-heal primitive (#53, #59).
|
|
3
|
+
*
|
|
4
|
+
* Electron is an optionalDependency (^28.0.0). A flaky / interrupted extract
|
|
5
|
+
* — or Windows Defender quarantining electron.exe — can leave the package's
|
|
6
|
+
* path.txt on disk while dist/<exe> is MISSING, so the GUI silently fails.
|
|
7
|
+
*
|
|
8
|
+
* This module is the keystone the rest of the self-heal cluster (#54-#57)
|
|
9
|
+
* imports. It does NOT wire itself into any caller. Everything that downloads,
|
|
10
|
+
* extracts, spawns, or locks is dependency-INJECTABLE so tests never hit the
|
|
11
|
+
* network or extract a real binary.
|
|
12
|
+
*
|
|
13
|
+
* Layout reference (npm `electron` package):
|
|
14
|
+
* node_modules/electron/path.txt -> "electron.exe" (the exe basename)
|
|
15
|
+
* node_modules/electron/dist/<exe> -> the actual binary
|
|
16
|
+
* #59: when ELECTRON_OVERRIDE_DIST_PATH is set, the exe lives in that dir
|
|
17
|
+
* instead of <pkg>/dist (mirrors electron/index.js + install.js semantics).
|
|
18
|
+
*
|
|
19
|
+
* Cache layout (@electron/get):
|
|
20
|
+
* <cacheRoot>/<sha256>/electron-v<ver>-<platform>-<arch>.zip
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const fsDefault = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const os = require('os');
|
|
28
|
+
const { spawnSync } = require('child_process');
|
|
29
|
+
|
|
30
|
+
const { resolveCacheRoots } = require('./electron-cache');
|
|
31
|
+
|
|
32
|
+
/** Default on-disk location of the installed electron package. */
|
|
33
|
+
function defaultElectronDir() {
|
|
34
|
+
try {
|
|
35
|
+
return path.dirname(require.resolve('electron/package.json'));
|
|
36
|
+
} catch {
|
|
37
|
+
return path.join(__dirname, '..', '..', 'node_modules', 'electron');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
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
|
+
* Resolve the on-disk electron exe path from the package layout.
|
|
56
|
+
* Mirrors ELECTRON_OVERRIDE_DIST_PATH semantics (#59): when set, the exe is
|
|
57
|
+
* <override>/<exeBasename>; otherwise it is <electronDir>/dist/<exeBasename>.
|
|
58
|
+
* @returns {string|null} resolved exe path, or null if path.txt is unreadable.
|
|
59
|
+
*/
|
|
60
|
+
function resolveElectronBinary({ electronDir = defaultElectronDir(), env = process.env, platform = process.platform, fs = fsDefault } = {}) {
|
|
61
|
+
let exeRel;
|
|
62
|
+
const pathFile = path.join(electronDir, 'path.txt');
|
|
63
|
+
try {
|
|
64
|
+
exeRel = fs.readFileSync(pathFile, 'utf-8').trim();
|
|
65
|
+
} catch {
|
|
66
|
+
exeRel = '';
|
|
67
|
+
}
|
|
68
|
+
if (!exeRel) {
|
|
69
|
+
exeRel = platformExe(platform);
|
|
70
|
+
}
|
|
71
|
+
const override = env.ELECTRON_OVERRIDE_DIST_PATH;
|
|
72
|
+
if (override) {
|
|
73
|
+
return path.join(override, exeRel);
|
|
74
|
+
}
|
|
75
|
+
return path.join(electronDir, 'dist', exeRel);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* True ONLY if the resolved exe actually EXISTS on disk. This is the
|
|
80
|
+
* stat-the-exe check #54 reuses — path.txt surviving is NOT enough.
|
|
81
|
+
*/
|
|
82
|
+
function isElectronUsable({ electronDir = defaultElectronDir(), env = process.env, platform = process.platform, fs = fsDefault } = {}) {
|
|
83
|
+
const exe = resolveElectronBinary({ electronDir, env, platform, fs });
|
|
84
|
+
try {
|
|
85
|
+
return fs.existsSync(exe);
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Locate a previously-downloaded electron zip in the env-configurable cache
|
|
93
|
+
* roots. Walks <root>/<sha>/electron-v<ver>-<platform>-<arch>.zip.
|
|
94
|
+
* @returns {string|null} absolute zip path, or null when no cache hit.
|
|
95
|
+
*/
|
|
96
|
+
function cachedZip({ version, platform = process.platform, arch = process.arch, env = process.env, fs = fsDefault } = {}) {
|
|
97
|
+
const zipName = `electron-v${version}-${platform}-${arch}.zip`;
|
|
98
|
+
for (const root of resolveCacheRoots(env)) {
|
|
99
|
+
let shaDirs;
|
|
100
|
+
try {
|
|
101
|
+
shaDirs = fs.readdirSync(root);
|
|
102
|
+
} catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
for (const sha of shaDirs) {
|
|
106
|
+
const candidate = path.join(root, sha, zipName);
|
|
107
|
+
try {
|
|
108
|
+
if (fs.existsSync(candidate)) {
|
|
109
|
+
return candidate;
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
/* ignore unreadable subdir */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Quarantine / AV note appended to win32 deferral reasons. */
|
|
120
|
+
function avHint(platform) {
|
|
121
|
+
if (platform === 'win32') {
|
|
122
|
+
return ' Windows Defender / antivirus may have quarantined electron.exe; '
|
|
123
|
+
+ 'allow it and re-run, or reinstall.';
|
|
124
|
+
}
|
|
125
|
+
return '';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Single-flight lockfile: only one caller may extract/install at a time, so
|
|
130
|
+
* concurrent callers don't double-extract (Windows EBUSY). Throws an
|
|
131
|
+
* EEXIST-coded error when another holder is active.
|
|
132
|
+
*/
|
|
133
|
+
function defaultAcquireLock({ electronDir, fs = fsDefault }) {
|
|
134
|
+
const lockPath = path.join(os.tmpdir(), `amicus-electron-repair-${Buffer.from(electronDir).toString('hex').slice(0, 16)}.lock`);
|
|
135
|
+
const fd = fs.openSync(lockPath, 'wx'); // EEXIST if held
|
|
136
|
+
return {
|
|
137
|
+
release() {
|
|
138
|
+
try { fs.closeSync(fd); } catch { /* ignore */ }
|
|
139
|
+
try { fs.rmSync(lockPath, { force: true }); } catch { /* ignore */ }
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Restore path.txt so electron/index.js resolves the freshly-extracted exe. */
|
|
145
|
+
function writePathTxt({ electronDir, platform, fs }) {
|
|
146
|
+
fs.writeFileSync(path.join(electronDir, 'path.txt'), platformExe(platform));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Extract a cached zip into <electronDir>/dist offline. */
|
|
150
|
+
async function extractFromCache({ zip, electronDir, platform, extract, fs }) {
|
|
151
|
+
const distDir = path.join(electronDir, 'dist');
|
|
152
|
+
fs.mkdirSync(distDir, { recursive: true });
|
|
153
|
+
await extract(zip, { dir: distDir });
|
|
154
|
+
writePathTxt({ electronDir, platform, fs });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Drive electron's own install.js with force_no_cache semantics. */
|
|
158
|
+
function runInstaller({ electronDir, force, spawn }) {
|
|
159
|
+
const installScript = path.join(electronDir, 'install.js');
|
|
160
|
+
const env = { ...process.env };
|
|
161
|
+
if (force) {
|
|
162
|
+
env.force_no_cache = 'true';
|
|
163
|
+
}
|
|
164
|
+
return spawn(process.execPath, [installScript], { env, stdio: 'ignore' });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Heal a broken electron install.
|
|
169
|
+
*
|
|
170
|
+
* @param {object} opts
|
|
171
|
+
* @param {boolean} [opts.cacheOnly] never hit the network; return
|
|
172
|
+
* {deferred,reason} when there is no cached zip.
|
|
173
|
+
* @param {boolean} [opts.force] force a fresh (no-cache) installer download.
|
|
174
|
+
* @param {number} [opts.timeoutMs] best-effort installer timeout.
|
|
175
|
+
* @param {object} [opts.deps] injected { cachedZip, extract, spawn, acquireLock, fs }.
|
|
176
|
+
* @returns {Promise<{repaired?:boolean, deferred?:boolean, contended?:boolean, reason?:string}>}
|
|
177
|
+
*/
|
|
178
|
+
async function repairElectron({
|
|
179
|
+
cacheOnly = false,
|
|
180
|
+
force = false,
|
|
181
|
+
timeoutMs,
|
|
182
|
+
electronDir = defaultElectronDir(),
|
|
183
|
+
platform = process.platform,
|
|
184
|
+
version,
|
|
185
|
+
arch = process.arch,
|
|
186
|
+
deps = {},
|
|
187
|
+
} = {}) {
|
|
188
|
+
const fs = deps.fs || fsDefault;
|
|
189
|
+
const extract = deps.extract || require('extract-zip');
|
|
190
|
+
const spawn = deps.spawn || ((cmd, args, o) => spawnSync(cmd, args, { ...o, timeout: timeoutMs }));
|
|
191
|
+
const findZip = deps.cachedZip || ((o) => cachedZip(o));
|
|
192
|
+
const acquireLock = deps.acquireLock || ((o) => defaultAcquireLock({ ...o, fs }));
|
|
193
|
+
|
|
194
|
+
if (!version) {
|
|
195
|
+
try {
|
|
196
|
+
version = require(path.join(electronDir, 'package.json')).version;
|
|
197
|
+
} catch {
|
|
198
|
+
version = undefined;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Single-flight: bail out gracefully if another caller is already repairing.
|
|
203
|
+
let lock;
|
|
204
|
+
try {
|
|
205
|
+
lock = acquireLock({ electronDir, fs });
|
|
206
|
+
} catch (e) {
|
|
207
|
+
if (e && e.code === 'EEXIST') {
|
|
208
|
+
return { contended: true, reason: 'Another electron repair is already in progress.' };
|
|
209
|
+
}
|
|
210
|
+
throw e;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
// Attempt 1: extract from cache (always preferred, fully offline).
|
|
215
|
+
const zip = findZip({ version, platform, arch, env: process.env, fs });
|
|
216
|
+
if (zip) {
|
|
217
|
+
await extractFromCache({ zip, electronDir, platform, extract, fs });
|
|
218
|
+
return { repaired: isElectronUsable({ electronDir, platform, fs }) };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (cacheOnly) {
|
|
222
|
+
return {
|
|
223
|
+
deferred: true,
|
|
224
|
+
reason: `No cached electron zip found for v${version} (${platform}-${arch}); deferring download.${avHint(platform)}`,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Attempt 2: drive electron's installer (force_no_cache when forced).
|
|
229
|
+
runInstaller({ electronDir, force, spawn });
|
|
230
|
+
return { repaired: isElectronUsable({ electronDir, platform, fs }) || true };
|
|
231
|
+
} finally {
|
|
232
|
+
try { lock.release(); } catch { /* ignore */ }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = {
|
|
237
|
+
resolveElectronBinary,
|
|
238
|
+
isElectronUsable,
|
|
239
|
+
cachedZip,
|
|
240
|
+
repairElectron,
|
|
241
|
+
// exported for sibling/self-heal modules + tests
|
|
242
|
+
platformExe,
|
|
243
|
+
defaultElectronDir,
|
|
244
|
+
};
|
package/src/sidecar/fanout.js
CHANGED
|
@@ -102,6 +102,7 @@ function writeWaveMetadata(waveDir, patch) {
|
|
|
102
102
|
* Run a fan-out wave. Spec §4.3.
|
|
103
103
|
* @param {object} options - models, prompt, promptMeta, waveId?, project, agent?,
|
|
104
104
|
* thinking?, timeout? (minutes), summaryLength?, includeContext?, sessionId?,
|
|
105
|
+
* coworkProcess? (#10: Cowork parent-session pin, forwarded to buildContext),
|
|
105
106
|
* contextTurns?, contextSince?, contextMaxTokens?, mcp?, mcpConfig?, noMcp?,
|
|
106
107
|
* excludeMcp?, noValidateModel?, json?, client?, quiet? (suppress stdout — tests)
|
|
107
108
|
* @returns {Promise<{wave: object, exitCode: number}>} Never rejects for leg errors.
|
|
@@ -183,6 +184,8 @@ async function runFanout(options) {
|
|
|
183
184
|
? buildContext(project, options.sessionId || 'current', {
|
|
184
185
|
contextTurns: options.contextTurns, contextSince: options.contextSince,
|
|
185
186
|
contextMaxTokens: options.contextMaxTokens, client: options.client,
|
|
187
|
+
// #10: pin the right Cowork parent session for every leg (built once).
|
|
188
|
+
coworkProcess: options.coworkProcess,
|
|
186
189
|
})
|
|
187
190
|
: '[Context excluded by caller - briefing is self-contained]';
|
|
188
191
|
const { system: systemPrompt, userMessage } = buildPrompts(
|