amicus 4.9.6 → 4.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +246 -0
  3. package/README.md +1 -1
  4. package/docs/ROADMAP.md +3 -3
  5. package/docs/architecture-map.md +24 -4
  6. package/docs/configuration.md +43 -15
  7. package/docs/council.md +140 -3
  8. package/docs/electron-testing.md +133 -0
  9. package/docs/troubleshooting.md +14 -7
  10. package/docs/usage.md +8 -4
  11. package/package.json +1 -1
  12. package/schemas/council-verdict.schema.json +3 -1
  13. package/skills/second-opinion/SEAT-BRIEFS.md +6 -0
  14. package/src/cli-council-run-tools.js +168 -0
  15. package/src/cli-handlers-council-run.js +6 -6
  16. package/src/cli.js +23 -1
  17. package/src/council/briefings-chair.js +1 -1
  18. package/src/council/briefings-task.js +11 -5
  19. package/src/council/briefings.js +25 -7
  20. package/src/council/report-lost-rows.js +89 -0
  21. package/src/council/report-md.js +3 -1
  22. package/src/council/report.js +3 -2
  23. package/src/council/run-degrade.js +22 -1
  24. package/src/council/run-finish.js +23 -1
  25. package/src/council/run-launch.js +33 -4
  26. package/src/council/run-retry-launch.js +9 -4
  27. package/src/council/run-retry.js +3 -0
  28. package/src/council/run-seat-tools-verify.js +296 -0
  29. package/src/council/run-seat-tools.js +274 -0
  30. package/src/council/run-server.js +41 -6
  31. package/src/council/run-stage1-launch.js +8 -3
  32. package/src/council/run.js +21 -21
  33. package/src/council/seat-tools.js +299 -0
  34. package/src/council/verdict-seats-reviewed.js +76 -6
  35. package/src/headless.js +136 -6
  36. package/src/mcp-council-pack-map.js +24 -0
  37. package/src/mcp-council-run.js +17 -15
  38. package/src/mcp-server.js +2 -2
  39. package/src/mcp-tools.js +15 -4
  40. package/src/opencode-client.js +26 -0
  41. package/src/pack/pack-validate.js +3 -1
  42. package/src/prompt-builder.js +2 -2
  43. package/src/sidecar/electron-exe-rel.js +131 -0
  44. package/src/sidecar/electron-install.js +7 -12
  45. package/src/sidecar/electron-layout.js +31 -31
  46. package/src/sidecar/electron-native-plan.js +23 -5
  47. package/src/sidecar/electron-native-rescue.js +55 -16
  48. package/src/sidecar/electron-rescue-notice.js +18 -1
  49. package/src/sidecar/fanout.js +7 -1
  50. package/src/sidecar/heartbeat.js +46 -0
  51. package/src/sidecar/session-utils.js +7 -34
  52. package/src/sidecar/zip-from-buffer.js +16 -5
  53. package/src/sidecar/zip-local-name-scan.js +238 -0
  54. package/src/sidecar/zip-name-scan.js +5 -0
  55. package/src/utils/agent-mapping.js +1 -1
  56. package/src/utils/degrade.js +8 -0
@@ -18,8 +18,11 @@
18
18
  * caller already hashed and writes `dist/` by extract-into-incoming + promote
19
19
  * (see `promoteDist`).
20
20
  *
21
- * TRUE LEAF: `path` only, with `fs` and the extractor injected by the caller —
22
- * so electron-provision.js requires it too without any risk of a cycle.
21
+ * NEAR-LEAF: `path` and `crypto`, plus the true leaf `./electron-exe-rel` (the
22
+ * one `path.txt` rule, shared with `resolveElectronBinary` since v4.9.7 A1),
23
+ * with `fs` and the extractor injected by the caller — so electron-provision.js
24
+ * requires it too without any risk of a cycle. `platformExe` is re-exported
25
+ * from here because it was defined here through v4.9.6.
23
26
  *
24
27
  * @module sidecar/electron-layout
25
28
  */
@@ -28,24 +31,7 @@
28
31
 
29
32
  const crypto = require('crypto');
30
33
  const path = require('path');
31
-
32
- /** Platform exe basename, matching electron's getPlatformPath(). */
33
- function platformExe(platform) {
34
- switch (platform) {
35
- case 'mas':
36
- case 'darwin':
37
- return path.join('Electron.app', 'Contents', 'MacOS', 'Electron');
38
- case 'win32':
39
- return 'electron.exe';
40
- default:
41
- return 'electron';
42
- }
43
- }
44
-
45
- /** Write path.txt: the basename `electron/index.js` joins onto `dist/`. */
46
- function writePathTxt({ electronDir, platform, fs }) {
47
- fs.writeFileSync(path.join(electronDir, 'path.txt'), platformExe(platform));
48
- }
34
+ const { platformExe, writePathTxt, distHeldExe } = require('./electron-exe-rel');
49
35
 
50
36
  /**
51
37
  * The two litter prefixes this module creates, and how long one may survive.
@@ -154,19 +140,26 @@ function sweepPromoteLitter({
154
140
  * and the caller then downloaded 138 MB and repeated the same promote.
155
141
  *
156
142
  * So the in-place removal now happens ONLY when there is nothing to lose: a
157
- * `dist/` that holds no `platformExe` is not an install, and destroying it costs
158
- * the user nothing they had. When the old tree DOES hold an executable, the
159
- * promote REFUSES and that tree is untouched — the repair fails, which is
160
- * strictly better than a working GUI becoming no GUI.
143
+ * `dist/` that holds NEITHER the exe `path.txt` names NOR `platformExe` is not
144
+ * an install, and destroying it costs the user nothing they had. When the old
145
+ * tree DOES hold an executable, the promote REFUSES and that tree is untouched —
146
+ * the repair fails, which is strictly better than a working GUI becoming no GUI.
161
147
  *
162
148
  * THE GUARANTEE, stated so it is checkable:
163
- * **A promote never removes a `dist/` that held an executable unless the new
164
- * tree is already in its place.**
149
+ * **A promote never removes a `dist/` that HELD an executable under the name
150
+ * `path.txt` gives it, or `platformExe` when `path.txt` is absent, unreadable
151
+ * or blank — unless the new tree is already in its place.**
152
+ * "HELD" means a FILE INSIDE `dist/`; `electron-exe-rel.js :: distHeldExe` owns
153
+ * that rule and carries what each of its bounds was measured to cost (A1).
165
154
  * The one exit that can still leave a user without a usable `dist/` is both
166
155
  * renames failing after step 1 SUCCEEDED. The old tree is then whole and
167
156
  * undeleted at `.amicus-retired-<hex>`, and the thrown message names it so the
168
157
  * user can rename it back.
169
158
  *
159
+ * WHICH VALUE THE GUARD READS (A1): `raw`, captured BEFORE step 0 — not
160
+ * `replaced`, and never a re-read. `electron-exe-rel.js` carries why, the
161
+ * `ELECTRON_OVERRIDE_DIST_PATH` ruling, and the measurements behind both.
162
+ *
170
163
  * `path.txt` IS WRITTEN FIRST (B2). Writing it LAST made "dist but no path.txt"
171
164
  * unobservable only while that write SUCCEEDED, and it ran after the old tree was
172
165
  * retired AND DELETED, so one ENOSPC/EPERM/AV-locked 12-byte write left a `dist/`
@@ -189,8 +182,10 @@ function sweepPromoteLitter({
189
182
  function promoteDist({ electronDir, incomingDist, platform, fs }) {
190
183
  const distDir = path.join(electronDir, 'dist');
191
184
  const pathFile = path.join(electronDir, 'path.txt');
192
- let replaced = null; // step 0's overwritten DIFFERENT value
193
- try { replaced = fs.readFileSync(pathFile, 'utf8'); } catch { /* absent or unreadable */ }
185
+ let raw = null; // path.txt BEFORE step 0 overwrites it
186
+ let unreadable = false; // a read that failed for a reason other than ENOENT
187
+ try { raw = fs.readFileSync(pathFile, 'utf8'); } catch (e) { unreadable = !e || e.code !== 'ENOENT'; }
188
+ let replaced = raw; // step 0's overwritten DIFFERENT value
194
189
  try {
195
190
  if (replaced === platformExe(platform)) { replaced = null; } else {
196
191
  try { writePathTxt({ electronDir, platform, fs }); } catch (e) {
@@ -205,10 +200,15 @@ function promoteDist({ electronDir, incomingDist, platform, fs }) {
205
200
  retiredExists = true;
206
201
  } catch (e) {
207
202
  // The old tree cannot be moved. Removing it in place is irreversible, so
208
- // it is allowed only when the tree is not an install anyway.
209
- if (fs.existsSync(path.join(distDir, platformExe(platform)))) {
203
+ // it is allowed only when the tree is an install under NEITHER name (A1).
204
+ if (unreadable) {
205
+ throw new Error(`${(e && e.message) || e} — path.txt could not be read, so which exe `
206
+ + 'dist/ holds is unknown and it was left exactly as it was');
207
+ }
208
+ const held = distHeldExe({ distDir, raw, platform, fs });
209
+ if (held) {
210
210
  throw new Error(`${(e && e.message) || e} — the existing dist/ holds a usable `
211
- + `${platformExe(platform)} and was left exactly as it was`);
211
+ + `${held} and was left exactly as it was`);
212
212
  }
213
213
  fs.rmSync(distDir, { recursive: true, force: true });
214
214
  }
@@ -80,6 +80,17 @@ function cleanDir(fs, dir) {
80
80
  /**
81
81
  * Walk the platform's native plan until one strategy leaves files in `dir`.
82
82
  *
83
+ * `cwd` IS THE INCOMING TREE, and it is a containment measure with an honest
84
+ * label: MEASURED NEUTRAL, not measured needed. Across 18 paired runs no shipped
85
+ * strategy wrote anything cwd-relative, so this changes no observed behaviour.
86
+ * It costs one line and adds no decision surface, and it converts a hypothetical
87
+ * cwd-relative write from "the user's own repo, under npx, forever" into "the
88
+ * incoming tree, which `extractBytesToDist`'s `finally` deletes unconditionally
89
+ * and `sweepPromoteLitter` takes if a kill skipped that". `unzip.js ::
90
+ * robustExtract`'s native loop deliberately does NOT get the same treatment: its
91
+ * `dir` is not inside a tree amicus deletes unconditionally, so binding a cwd
92
+ * there would point a child's working directory at the user's install.
93
+ *
83
94
  * The verdicts are unzip.js's, because they were right there: a spawn error or
84
95
  * an external signal-kill (`status: null` — SIGKILL, an OOM) is a FAILURE even
85
96
  * if files landed, a non-zero exit is a failure, and a clean exit that produced
@@ -87,12 +98,14 @@ function cleanDir(fs, dir) {
87
98
  * strategy starts from an empty directory.
88
99
  * @returns {string|null} the strategy name that worked, or null
89
100
  */
90
- function runNativePlan({ zip, dir, platform, fs, spawn, maxMs, log }) {
101
+ function runNativePlan({ zip, dir, cwd, platform, fs, spawn, maxMs, log }) {
91
102
  const failures = [];
92
103
  for (const strat of nativeUnzipPlan(zip, dir, platform)) {
93
104
  let res;
94
105
  try {
95
- res = spawn(strat.cmd, strat.args, { stdio: 'ignore', windowsHide: true, timeout: maxMs });
106
+ res = spawn(strat.cmd, strat.args, {
107
+ stdio: 'ignore', windowsHide: true, timeout: maxMs, cwd,
108
+ });
96
109
  } catch (e) {
97
110
  failures.push(`${strat.name}: spawn ${(e && e.code) || (e && e.message) || 'threw'}`);
98
111
  continue;
@@ -118,6 +131,11 @@ function runNativePlan({ zip, dir, platform, fs, spawn, maxMs, log }) {
118
131
  * will find it — so a rescue lands in `dist/` by the SAME single rename, with the
119
132
  * same litter sweep, and this module never touches the promote at all.
120
133
  *
134
+ * `namesComplete`/`namesChecked` are REQUIRED and deliberately have no defaults:
135
+ * they drive a disclosure, and a caller that forgot to thread them must not get
136
+ * the reassuring branch by omission. `announceNativeRescue` treats `undefined` as
137
+ * "not complete" for the same reason.
138
+ *
121
139
  * `flag: 'wx'` is a real control and a small one: `O_EXCL` refuses to write
122
140
  * through a name that already exists, INCLUDING a symlink someone pre-planted at
123
141
  * it. It does nothing about a substitution AFTER the write — that window is the
@@ -126,14 +144,14 @@ function runNativePlan({ zip, dir, platform, fs, spawn, maxMs, log }) {
126
144
  * `extractBytesToDist` removes the whole incoming tree regardless.
127
145
  * @returns {string|null} the strategy name that recovered the archive, or null
128
146
  */
129
- function nativeRescue({ bytes, dir, reason, platform, fs, spawn, maxMs, log }) {
147
+ function nativeRescue({ bytes, dir, reason, namesComplete, namesChecked, platform, fs, spawn, maxMs, log }) {
130
148
  const incoming = path.dirname(dir);
131
149
  if (!path.basename(incoming).startsWith(INCOMING_PREFIX)) {
132
150
  log(`[amicus] the native-extractor rescue was NOT attempted: ${collapseExcerpt(dir, PATH_EXCERPT_CHARS)} is not inside an amicus incoming directory.`);
133
151
  return null;
134
152
  }
135
153
  const zip = path.join(incoming, RESCUE_ZIP);
136
- announceNativeRescue({ zip, reason, log });
154
+ announceNativeRescue({ zip, reason, namesComplete, namesChecked, log });
137
155
  try {
138
156
  fs.writeFileSync(zip, bytes, { flag: 'wx', mode: 0o600 });
139
157
  } catch (e) {
@@ -144,7 +162,7 @@ function nativeRescue({ bytes, dir, reason, platform, fs, spawn, maxMs, log }) {
144
162
  // The failed extractor's partial tree is evidence of nothing and would be
145
163
  // promoted as if it were a rescue. It goes before the child runs.
146
164
  cleanDir(fs, dir);
147
- const strategy = runNativePlan({ zip, dir, platform, fs, spawn, maxMs, log });
165
+ const strategy = runNativePlan({ zip, dir, cwd: incoming, platform, fs, spawn, maxMs, log });
148
166
  if (strategy) {
149
167
  log(`[amicus] recovered via the native extractor (${strategy}). These bytes were NOT re-hashed; the result is marked unverified.`);
150
168
  }
@@ -118,6 +118,7 @@ const { nativeRescue, RESCUE_ZIP, INCOMING_PREFIX } = require('./electron-native
118
118
  const { offerNativeRescue } = require('./electron-rescue-notice');
119
119
  // The read-only name walk the boundary consults before it trusts a verdict.
120
120
  const { scanEntryNames } = require('./zip-name-scan');
121
+ const { scanLocalNames } = require('./zip-local-name-scan');
121
122
  const { collapseExcerpt } = require('../utils/text-sanitize');
122
123
 
123
124
  /** The ONE extractor verdict a rescue may act on. See the docblock's boundary. */
@@ -144,27 +145,46 @@ function isRescuableFailure(err) {
144
145
  * unadvertised, left in place, exactly as if the archive had had nothing wrong
145
146
  * with it but that entry.
146
147
  *
147
- * THE RESIDUAL, STATED RATHER THAN ENGINEERED AWAY. The scan reads the central
148
- * directory; an archive whose central directory is unreadable a truncated zip,
149
- * the commonest thing this rescue exists for declares no names it can see, and
150
- * that archive still reaches the native extractor. Nothing here covers a SYMLINK
151
- * whose target escapes either: that is a payload, not a name. In both cases the
152
- * only remaining check is the extractor's own, which `tar` and `Expand-Archive`
153
- * were MEASURED to have (`ditto` and Info-ZIP `unzip` are unmeasured), and
154
- * `cleanDir` sweeps only inside `dir` anything a native tool wrote outside it
155
- * would survive a failed strategy. `docs/configuration.md` says the same thing to
148
+ * BOTH TABLES, BECAUSE THE STRATEGIES DO NOT AGREE ON WHICH ONE THEY READ (B3).
149
+ * Through v4.9.6 this asked the CENTRAL directory only, so an archive that blinds
150
+ * yauzl there a truncation, or any of four ONE-FIELD forgeries of a COMPLETE
151
+ * end-of-central-directory record declared no names amicus could see and went
152
+ * to the native extractor anyway. MEASURED: seven such archives carrying
153
+ * `../../../PWNED-BY-NATIVE.txt` reached a real spawn, and on two the rescue ran
154
+ * to COMPLETION and promoted. Only the Windows tools' own `..` guards stopped the
155
+ * escape the exact reliance this module says amicus will not make.
156
+ * And the tables can DISAGREE: on an archive declaring one name locally and
157
+ * another centrally, `tar.exe` wrote the LOCAL name while `Expand-Archive` wrote
158
+ * the CENTRAL one. So a refusal in EITHER table refuses the archive.
159
+ *
160
+ * THE RESIDUALS THAT REMAIN. Neither walk sees a SYMLINK whose target escapes:
161
+ * that is a payload, not a name. And an archive that defeats BOTH walks still
162
+ * reaches the extractor — rarer than before, but not impossible — so the notice
163
+ * printed before the spawn now says WHICH names were checked, rather than letting
164
+ * the user assume they all were. `docs/configuration.md` says the same thing to
156
165
  * the user who has to decide whether to set the flag.
166
+ *
167
+ * WHEN A NAME CHECK CANNOT SEE IT, the only check left is the extractor's own —
168
+ * and that claim is now RE-MEASURED on every CI run rather than asserted once
169
+ * (`tests/sidecar/native-extractor-containment.test.js`, 12 escape shapes per
170
+ * strategy). `tar.exe`, `Expand-Archive` and Info-ZIP `unzip` all contain their
171
+ * own escapes; GNU `tar` cannot read a zip at all; `ditto` is the one strategy
172
+ * still unmeasured, and that suite measures it the first time it runs on a Mac.
173
+ * What a failed strategy can still leave behind is ONLY a write to an ABSOLUTE
174
+ * path outside the incoming tree: everything else the rescue writes lives under
175
+ * that tree, which `extractBytesToDist`'s `finally` deletes unconditionally (B2).
176
+ * @param {{central:object, local:object}} seen the two walks' results
157
177
  * @returns {Error|null} a terminal UNZIP_UNSAFE_ARCHIVE, or null
158
178
  */
159
- async function hostileName(bytes, log) {
160
- const seen = await scanEntryNames(bytes);
161
- if (!seen.refusal) { return null; }
179
+ function hostileName(seen, log) {
180
+ const refusal = seen.central.refusal || seen.local.refusal;
181
+ if (!refusal) { return null; }
162
182
  log('[amicus] REFUSING to rescue this archive: amicus could not read it, and while asking what');
163
183
  log('[amicus] it contains it found an entry that tries to write OUTSIDE the destination:');
164
- log(`[amicus] ${collapseExcerpt(seen.refusal)}`);
184
+ log(`[amicus] ${collapseExcerpt(refusal)}`);
165
185
  log('[amicus] A native extractor may have no such check, so it is not offered this archive.');
166
186
  return Object.assign(
167
- new Error(`refusing to extract this archive: ${collapseExcerpt(seen.refusal)}`),
187
+ new Error(`refusing to extract this archive: ${collapseExcerpt(refusal)}`),
168
188
  { code: 'UNZIP_UNSAFE_ARCHIVE' },
169
189
  );
170
190
  }
@@ -200,7 +220,8 @@ function withNativeRescue({
200
220
  // ...and the one class that IS rescuable is asked what names it declares
201
221
  // first, because the exclusion above keys on the refusal yauzl FORMED and
202
222
  // an earlier bad entry stops it forming one. See `hostileName`.
203
- const hostile = await hostileName(bytes, log);
223
+ const seen = { central: await scanEntryNames(bytes), local: scanLocalNames(bytes) };
224
+ const hostile = hostileName(seen, log);
204
225
  if (hostile) { throw hostile; }
205
226
  if (!policy.allowUnverified) {
206
227
  // `offered` IS THE OFFER'S RECEIPT, and the cache route is required to
@@ -214,7 +235,25 @@ function withNativeRescue({
214
235
  throw err;
215
236
  }
216
237
  const strategy = nativeRescue({
217
- bytes, dir: o.dir, reason: (err && err.message) || '', platform, fs, spawn, maxMs, log,
238
+ bytes,
239
+ dir: o.dir,
240
+ reason: (err && err.message) || '',
241
+ // WHAT THE NOTICE MAY CLAIM. Three states, not two: a real artifact
242
+ // truncated by a few KB has BOTH walks incomplete while the local walk
243
+ // read and cleared every name it found, so `central.read || local.complete`
244
+ // would print "nothing checked its entries" over 73 checked entries.
245
+ // BOTH, NOT EITHER. The two tables carry DIFFERENT names and the two
246
+ // strategies read different ones, so a disjunction cannot mean "every
247
+ // name was checked". MEASURED: a local walk stopped at entry 1 with a
248
+ // readable, benign central directory reported TRUE and printed nothing,
249
+ // while `tar.exe` reached a `../../../` entry only the local table had.
250
+ namesComplete: seen.central.read && seen.local.complete,
251
+ namesChecked: seen.local.names,
252
+ platform,
253
+ fs,
254
+ spawn,
255
+ maxMs,
256
+ log,
218
257
  });
219
258
  // A rescue that failed leaves the ORIGINAL classified error in flight, so
220
259
  // a genuinely bad archive is still evicted exactly as it was before.
@@ -62,9 +62,26 @@ function offerNativeRescue({ reason, log = () => {} }) {
62
62
  * instead of reassuring anyone about it — the rescue is not safe, and the words a
63
63
  * user reads while it happens have to say so.
64
64
  */
65
- function announceNativeRescue({ zip, reason, log = () => {} }) {
65
+ function announceNativeRescue({
66
+ zip, reason, namesComplete, namesChecked = 0, log = () => {},
67
+ }) {
66
68
  log('[amicus] AMICUS_ALLOW_UNVERIFIED_ELECTRON=1 — running the NATIVE-EXTRACTOR RESCUE.');
67
69
  log(`[amicus] amicus could not read the archive itself: ${collapseExcerpt(reason)}`);
70
+ // THREE STATES, NOT TWO, and the middle one is the common one: a real artifact
71
+ // truncated by a few KB leaves both walks incomplete while the local walk still
72
+ // read and cleared every name it reached. Saying "nothing checked its entries"
73
+ // there would be a FALSE disclosure on the shape this rescue exists for.
74
+ // `namesComplete` is undefined-means-no on purpose (see `nativeRescue`).
75
+ if (!namesComplete && namesChecked > 0) {
76
+ log(`[amicus] IT CHECKED ${namesChecked} ENTRY NAMES AND COULD NOT CONFIRM IT SAW THEM ALL:`);
77
+ log('[amicus] the archive stopped amicus part-way through its own tables, so an entry that');
78
+ log('[amicus] writes OUTSIDE dist/ could sit past the point it reached.');
79
+ } else if (!namesComplete) {
80
+ log('[amicus] AND IT COULD NOT READ THE ENTRY NAMES EITHER: neither this archive\'s central');
81
+ log('[amicus] directory nor its local file headers could be walked, so NOTHING checked its');
82
+ log('[amicus] entries for paths that write OUTSIDE dist/. The extractor below is the only');
83
+ log('[amicus] check left.');
84
+ }
68
85
  log('[amicus] THE WINDOW THIS OPENS, stated plainly. amicus has written the bytes it hashed to');
69
86
  log(`[amicus] ${collapseExcerpt(zip, PATH_EXCERPT_CHARS)}`);
70
87
  log('[amicus] and is about to hand that PATH to a native extractor it does not control. Between');
@@ -46,6 +46,7 @@ const { deriveLegIds } = require('./leg-ids');
46
46
  * to run this wave's legs on. Both or neither. When supplied this wave never
47
47
  * starts a server and never closes one — see the seam comment in step 4.
48
48
  * NOT `client`, which is the client TYPE string on this function.)
49
+ * serverAgents? (spec 2026-09-11 §4: agents to register if this wave starts its own server)
49
50
  * @returns {Promise<{wave: object, exitCode: number}>} Never rejects for leg errors.
50
51
  */
51
52
  async function runFanout(options) {
@@ -218,7 +219,12 @@ async function runFanout(options) {
218
219
  logger.debug('Using external server (shared server mode)', { waveId, url: server.url });
219
220
  } else {
220
221
  try {
221
- ({ client, server } = await startOpenCodeServer(mcpServers, { models: validated.serverModels || okLegs.map(l => l.model) }));
222
+ ({ client, server } = await startOpenCodeServer(mcpServers, {
223
+ models: validated.serverModels || okLegs.map(l => l.model),
224
+ // Spec 2026-09-11 §4: a wave that starts its own server still needs the
225
+ // council agents registered on it. Spread-guarded: byte-identical otherwise.
226
+ ...(options.serverAgents ? { agents: options.serverAgents } : {}),
227
+ }));
222
228
  } catch (err) {
223
229
  writeWaveMetadata(waveDir, { status: 'error', reason: err.message, completedAt: new Date().toISOString() });
224
230
  return errorWave(waveId, `Failed to start server: ${err.message}`, waveDir);
@@ -0,0 +1,46 @@
1
+ // src/sidecar/heartbeat.js
2
+ 'use strict';
3
+ // HEARTBEAT_INTERVAL + createHeartbeat — moved verbatim from session-utils.js
4
+ // (size-gate split, spec 2026-09-11 §4 PR 2: that file was already at the
5
+ // 300-line ceiling before this task's additions). Zero behavior change; both
6
+ // re-exported from session-utils.js's existing module.exports, so no caller
7
+ // (start.js, resume.js, continue.js, fanout.js, index.js, and their tests)
8
+ // needs to change — see tests/sidecar/session-utils.test.js and
9
+ // tests/sidecar/start.test.js, which exercise these through that re-export.
10
+
11
+ /** Standard heartbeat interval in milliseconds */
12
+ const HEARTBEAT_INTERVAL = 15000;
13
+
14
+ /**
15
+ * Create a heartbeat that writes status to stderr periodically.
16
+ * When sessionDir is provided, includes message count and latest activity.
17
+ *
18
+ * @param {number} [interval=HEARTBEAT_INTERVAL] - Interval in milliseconds
19
+ * @param {string} [sessionDir] - Session directory to read progress from
20
+ * @returns {{ stop: () => void }}
21
+ */
22
+ function createHeartbeat(interval = HEARTBEAT_INTERVAL, sessionDir) {
23
+ const startTime = Date.now();
24
+ const intervalId = setInterval(() => {
25
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
26
+ const mins = Math.floor(elapsed / 60);
27
+ const secs = elapsed % 60;
28
+ const ts = mins > 0 ? `${mins}m${secs}s` : `${secs}s`;
29
+
30
+ if (sessionDir) {
31
+ const { readProgress } = require('./progress');
32
+ const progress = readProgress(sessionDir);
33
+ process.stderr.write(`[amicus] ${ts} | ${progress.messages} messages | ${progress.latest}\n`);
34
+ } else {
35
+ process.stderr.write(`[amicus] still running... ${ts} elapsed\n`);
36
+ }
37
+ }, interval);
38
+
39
+ return {
40
+ stop() {
41
+ clearInterval(intervalId);
42
+ }
43
+ };
44
+ }
45
+
46
+ module.exports = { HEARTBEAT_INTERVAL, createHeartbeat };
@@ -20,8 +20,10 @@ const {
20
20
  resolveExistingSessionDir
21
21
  } = require('../session-manager');
22
22
 
23
- /** Standard heartbeat interval in milliseconds */
24
- const HEARTBEAT_INTERVAL = 15000;
23
+ // HEARTBEAT_INTERVAL + createHeartbeat live in ./heartbeat (size-gate split:
24
+ // this file was already at the 300-line ceiling — spec 2026-09-11 §4 PR 2).
25
+ // Re-exported below so no caller changes.
26
+ const { HEARTBEAT_INTERVAL, createHeartbeat } = require('./heartbeat');
25
27
 
26
28
  /** Session path utilities - eliminates magic strings across modules */
27
29
  const SessionPaths = {
@@ -122,38 +124,6 @@ function outputSummary(summary) {
122
124
  console.log(fenceSidecarOutput(summary));
123
125
  }
124
126
 
125
- /**
126
- * Create a heartbeat that writes status to stderr periodically.
127
- * When sessionDir is provided, includes message count and latest activity.
128
- *
129
- * @param {number} [interval=HEARTBEAT_INTERVAL] - Interval in milliseconds
130
- * @param {string} [sessionDir] - Session directory to read progress from
131
- * @returns {{ stop: () => void }}
132
- */
133
- function createHeartbeat(interval = HEARTBEAT_INTERVAL, sessionDir) {
134
- const startTime = Date.now();
135
- const intervalId = setInterval(() => {
136
- const elapsed = Math.round((Date.now() - startTime) / 1000);
137
- const mins = Math.floor(elapsed / 60);
138
- const secs = elapsed % 60;
139
- const ts = mins > 0 ? `${mins}m${secs}s` : `${secs}s`;
140
-
141
- if (sessionDir) {
142
- const { readProgress } = require('./progress');
143
- const progress = readProgress(sessionDir);
144
- process.stderr.write(`[amicus] ${ts} | ${progress.messages} messages | ${progress.latest}\n`);
145
- } else {
146
- process.stderr.write(`[amicus] still running... ${ts} elapsed\n`);
147
- }
148
- }, interval);
149
-
150
- return {
151
- stop() {
152
- clearInterval(intervalId);
153
- }
154
- };
155
- }
156
-
157
127
  /**
158
128
  * Execute sidecar in either headless or interactive mode
159
129
  * Consolidates the if/else pattern duplicated across start, resume, continue
@@ -228,6 +198,7 @@ async function executeMode(options) {
228
198
  * @param {string} [options.client] - Client type (e.g. 'cowork', 'code-local')
229
199
  * @param {string} [options.systemPrompt] - System prompt to set on agent config (hidden from UI)
230
200
  * @param {string} [options.agentName] - Agent to set systemPrompt on (default: 'chat')
201
+ * @param {Object<string, object>} [options.agents] - Extra agents to register (council seat agents)
231
202
  * @param {string[]} [options.models] - Resolved executable id(s) actually launched on this
232
203
  * server (#61 Task 4.6/7.3 sole-input invariant) — a multi-model shared server (fanout)
233
204
  * has no single default `config.model`, so this registers ALL of them in provider.models
@@ -257,6 +228,8 @@ async function startOpenCodeServer(mcpConfig, options = {}) {
257
228
  if (options.models) { serverOptions.models = options.models; }
258
229
  if (options.systemPrompt) { serverOptions.systemPrompt = options.systemPrompt; }
259
230
  if (options.agentName) { serverOptions.agentName = options.agentName; }
231
+ // Spec 2026-09-11 §4: the council's two agents ride into buildServerOptions.
232
+ if (options.agents) { serverOptions.agents = options.agents; }
260
233
  // Explicit per-call override only. Unset is the normal case and is correct:
261
234
  // buildServerOptions resolves AMICUS_SERVER_START_TIMEOUT_MS / the platform
262
235
  // default downstream, so forwarding `undefined` here would change nothing.
@@ -40,11 +40,22 @@
40
40
  * extraction root is REFUSED here and is not by extract-zip, and it is resolved
41
41
  * against the REALPATH of the directory the link lands in because the lexical
42
42
  * `path.dirname` was measured to be defeated outright by a chain of
43
- * directory-symlink entries earlier in the same archive. That is a behaviour
44
- * change on a shape amicus cannot test on this machine (the darwin `.app` bundle
45
- * is the only electron artifact with real symlinks), so it is refused in the
46
- * same `Out of bound path` wording, exercised against synthetic archives, and
47
- * named in the report as unverified on macOS.
43
+ * directory-symlink entries earlier in the same archive. It is refused in the
44
+ * same `Out of bound path` wording and exercised against synthetic archives
45
+ * here; the darwin `.app` bundle is the only electron artifact with real
46
+ * symlinks, and since v4.9.7 `.github/workflows/darwin-bundle.yml` runs this
47
+ * path over the REAL artifact on a real Mac. The v4.9.6 worry that the check
48
+ * might REJECT a working layout is refuted by measurement: the real
49
+ * `electron-v43.1.1-darwin-arm64.zip` declares 585 records and 14 symlinks,
50
+ * every target relative, none carrying a `..` component, none absolute, and 0
51
+ * of the 585 entry names traversing a symlinked component. The linux artifacts
52
+ * hold ZERO symlink entries, so `writeSymlink` is unreachable there at all.
53
+ *
54
+ * `root = fs.realpathSync(dir)` below is load-bearing for that answer and no
55
+ * Windows probe would ever show it: on macOS the extraction root usually sits
56
+ * under `/var`, which is itself a symlink to `/private/var`, so comparing a
57
+ * resolved target against an UNRESOLVED root would read every link in a real
58
+ * `.app` as an escape.
48
59
  *
49
60
  * ── ERROR CODES ARE A CAUSAL CLAIM ───────────────────────────────────────
50
61
  * `UNZIP_BUFFER_FAILED` = the ARCHIVE is bad. `UNZIP_DEST_FAILED` = the