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/src/mcp-tools.js CHANGED
@@ -115,7 +115,7 @@ function getTools() {
115
115
  },
116
116
  {
117
117
  name: 'amicus_status',
118
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
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
 
@@ -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 || {};
@@ -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
+ };
@@ -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(