amicus 4.4.0 → 4.5.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.
Files changed (109) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +162 -0
  3. package/README.md +17 -2
  4. package/bin/amicus.js +10 -0
  5. package/docs/DISTRIBUTION.md +234 -0
  6. package/docs/ROADMAP.md +226 -0
  7. package/docs/SHIMS.md +62 -0
  8. package/docs/architecture.md +104 -0
  9. package/docs/configuration.md +395 -0
  10. package/docs/council.md +970 -0
  11. package/docs/doc-system.md +92 -0
  12. package/docs/electron-testing.md +471 -0
  13. package/docs/jsdoc-setup.md +75 -0
  14. package/docs/opencode-integration.md +114 -0
  15. package/docs/publishing.md +60 -0
  16. package/docs/schemas.md +56 -0
  17. package/docs/testing.md +589 -0
  18. package/docs/troubleshooting.md +298 -0
  19. package/docs/usage.md +849 -0
  20. package/electron/fold.js +1 -1
  21. package/electron/main.js +4 -1
  22. package/electron/setup-ui-aliases.js +6 -6
  23. package/electron/workspace-ui/live-model.js +12 -1
  24. package/electron/workspace-ui/md-lite.js +52 -8
  25. package/electron/workspace-ui/workspace-app.js +39 -17
  26. package/electron/workspace-ui/workspace-matrix.js +46 -9
  27. package/electron/workspace-ui/workspace-panels.js +88 -19
  28. package/electron/workspace-ui/workspace-render.js +17 -1
  29. package/electron/workspace-ui/workspace-verbs.js +48 -2
  30. package/package.json +8 -3
  31. package/schemas/council-run-live.schema.json +1 -1
  32. package/schemas/council-run.schema.json +34 -0
  33. package/schemas/error.schema.json +1 -1
  34. package/schemas/event.schema.json +1 -1
  35. package/schemas/pack.schema.json +30 -0
  36. package/schemas/progress.schema.json +13 -1
  37. package/schemas/run-live.schema.json +1 -1
  38. package/schemas/run.schema.json +2 -1
  39. package/schemas/spend.schema.json +52 -4
  40. package/schemas/wave-live.schema.json +1 -1
  41. package/schemas/wave.schema.json +2 -1
  42. package/skills/second-opinion/SKILL.md +5 -0
  43. package/src/cli-handlers-council-run.js +51 -8
  44. package/src/cli-handlers-pack.js +238 -0
  45. package/src/cli-handlers-run.js +36 -8
  46. package/src/cli-handlers-spend.js +20 -2
  47. package/src/cli-handlers-template.js +53 -0
  48. package/src/cli-handlers-watch.js +11 -0
  49. package/src/cli.js +68 -5
  50. package/src/council/briefings-debate.js +27 -7
  51. package/src/council/briefings-stage2.js +155 -25
  52. package/src/council/briefings.js +24 -1
  53. package/src/council/findings.js +199 -9
  54. package/src/council/parse-stage2.js +10 -2
  55. package/src/council/presets-cli.js +23 -11
  56. package/src/council/report.js +19 -8
  57. package/src/council/run-assemble.js +42 -1
  58. package/src/council/run-budget.js +64 -11
  59. package/src/council/run-chair.js +4 -1
  60. package/src/council/run-debate.js +4 -2
  61. package/src/council/run-finalize.js +102 -0
  62. package/src/council/run-launch.js +29 -1
  63. package/src/council/run-server.js +248 -0
  64. package/src/council/run-stage2.js +118 -0
  65. package/src/council/run-stages.js +134 -110
  66. package/src/council/run-state.js +40 -1
  67. package/src/council/run.js +45 -47
  68. package/src/council/tally.js +10 -0
  69. package/src/headless.js +180 -7
  70. package/src/mcp-council-run.js +108 -4
  71. package/src/mcp-server.js +203 -7
  72. package/src/mcp-tools.js +15 -5
  73. package/src/observe/council-legs.js +60 -3
  74. package/src/observe/live-doc.js +18 -1
  75. package/src/observe/watch-render.js +4 -1
  76. package/src/pack/pack-cli.js +38 -0
  77. package/src/pack/pack-forward.js +96 -0
  78. package/src/pack/pack-resolve.js +297 -0
  79. package/src/pack/pack-store.js +130 -0
  80. package/src/pack/pack-validate.js +113 -0
  81. package/src/sidecar/child-sessions.js +1 -2
  82. package/src/sidecar/fanout-leg-fallback.js +69 -21
  83. package/src/sidecar/fanout-leg.js +6 -0
  84. package/src/sidecar/fanout-signals.js +61 -0
  85. package/src/sidecar/fanout-wave-io.js +75 -0
  86. package/src/sidecar/fanout.js +82 -74
  87. package/src/sidecar/progress-fields.js +26 -4
  88. package/src/sidecar/progress.js +42 -1
  89. package/src/sidecar/session-utils.js +23 -14
  90. package/src/sidecar/start.js +5 -4
  91. package/src/sidecar/workspace-auto-open.js +69 -0
  92. package/src/sidecar/workspace-window.js +46 -1
  93. package/src/spend-query.js +17 -5
  94. package/src/template/apply.js +88 -0
  95. package/src/template/render.js +86 -0
  96. package/src/template/store.js +106 -0
  97. package/src/utils/config.js +65 -25
  98. package/src/utils/error-doc.js +5 -0
  99. package/src/utils/lifecycle.js +37 -1
  100. package/src/utils/path-fence.js +39 -1
  101. package/src/utils/pricing.js +26 -10
  102. package/src/utils/result-schema-rebuild.js +1 -0
  103. package/src/utils/result-schema.js +8 -2
  104. package/src/utils/server-setup.js +79 -1
  105. package/src/utils/spend-ledger.js +24 -3
  106. package/src/workspace/artifact-guard.js +66 -7
  107. package/src/workspace/fold-format.js +33 -4
  108. package/src/workspace/live-normalize.js +28 -15
  109. package/src/workspace/run-detail.js +13 -1
@@ -48,35 +48,83 @@ function recordAttemptSpend({ doc, leg, currentModel, legId, waveId, project, at
48
48
  } catch { /* best-effort */ }
49
49
  }
50
50
 
51
+ /** Add up a list of token blocks, key by key. */
52
+ function foldTokens(blocks) {
53
+ const tokens = {};
54
+ for (const t of blocks) {
55
+ for (const [k, v] of Object.entries(t || {})) { tokens[k] = (tokens[k] || 0) + (v || 0); }
56
+ }
57
+ return tokens;
58
+ }
59
+
51
60
  /**
52
- * Fold token totals + cost amount across a leg's attempts[]. A single-attempt
53
- * leg returns that attempt's usage verbatim (no behavior change for non-fallback
54
- * legs). Multi-attempt sums tokens.* and cost.amount, tagging source 'mixed'
55
- * when attempts' sources differ matching formatCost's `~` behavior so a summed
56
- * cost never reads as authoritative.
61
+ * Add up a list of resolved cost objects, tagging source 'mixed' when they
62
+ * differ matching formatCost's `~` behavior so a summed cost never reads as
63
+ * authoritative. `none` is returned when not one of them carried a number,
64
+ * because a sum of nothing is not $0.
57
65
  */
58
- function sumAttemptUsage(attempts) {
59
- const withUsage = (attempts || []).filter(a => a.usage && a.usage.tokens);
60
- if (withUsage.length === 0) { return null; }
61
- if (withUsage.length === 1) { return withUsage[0].usage; }
62
- const tokens = {};
66
+ function foldCosts(costs, none) {
63
67
  let amount = 0;
64
68
  let anyCost = false;
65
69
  const sources = new Set();
66
- for (const a of withUsage) {
67
- for (const [k, v] of Object.entries(a.usage.tokens || {})) {
68
- tokens[k] = (tokens[k] || 0) + (v || 0);
69
- }
70
- if (a.usage.cost && typeof a.usage.cost.amount === 'number') {
71
- amount += a.usage.cost.amount;
70
+ for (const c of costs) {
71
+ if (c && typeof c.amount === 'number') {
72
+ amount += c.amount;
72
73
  anyCost = true;
73
- if (a.usage.cost.source) { sources.add(a.usage.cost.source); }
74
+ if (c.source) { sources.add(c.source); }
74
75
  }
75
76
  }
76
- const cost = anyCost
77
- ? { amount, currency: 'USD', source: sources.size > 1 ? 'mixed' : (sources.values().next().value || 'reported') }
78
- : null;
79
- return { tokens, cost };
77
+ if (!anyCost) { return none; }
78
+ return { amount, currency: 'USD',
79
+ source: sources.size > 1 ? 'mixed' : (sources.values().next().value || 'reported') };
80
+ }
81
+
82
+ /**
83
+ * Fold a leg's attempts[] into ONE usage block. A single-attempt leg returns that
84
+ * attempt's usage verbatim (no behavior change for non-fallback legs).
85
+ *
86
+ * ⚠️ v4.4.1 A1. This used to `return { tokens, cost }` — silently DISCARDING every
87
+ * other key resolveUsage puts on a usage block. The casualty was `subtreeUnknown`:
88
+ * a leg that fell back to a substitute AND left an unattributable subagent subtree
89
+ * lost the flag here, `sumWaveUsage` never counted it in `subtreeUnknownLegs`, and
90
+ * run.json reported `costExact: true` for a total that was not — precisely the lie
91
+ * `costExact` exists to prevent, and reachable only on the fallback path. So the
92
+ * fold now starts from a merge of every attempt's usage and overwrites only the
93
+ * keys it has a real opinion about; anything added to the block later survives by
94
+ * default instead of being dropped by omission.
95
+ *
96
+ * HOW EACH KIND OF KEY FOLDS, and why:
97
+ * - `tokens` / `cost` — SUMMED. They are per-attempt measurements of one leg's
98
+ * total consumption; every attempt really was billed.
99
+ * - `subtreeUnknown` — OR'd. It is a claim about EXACTNESS, not a quantity: if
100
+ * even one attempt left a subtree it could not account for, the leg's total is
101
+ * a floor, and that stays true no matter how exact the other attempts were.
102
+ * Any other fold (last-wins, or requiring every attempt to agree) would let a
103
+ * later clean attempt erase an earlier attempt's admitted gap.
104
+ * - `subtree` — SUMMED, not last-wins. Two attempts can each have walked and
105
+ * PRICED child sessions, and both spent real money; keeping only the last one's
106
+ * measurement would re-open the same under-report one level down, which
107
+ * sumWaveUsage's CA-1 docblock explicitly refuses to make.
108
+ * - anything else — last attempt wins, which is what the merge already does.
109
+ */
110
+ function sumAttemptUsage(attempts) {
111
+ const withUsage = (attempts || []).filter(a => a.usage && a.usage.tokens);
112
+ if (withUsage.length === 0) { return null; }
113
+ if (withUsage.length === 1) { return withUsage[0].usage; }
114
+ const usages = withUsage.map(a => a.usage);
115
+ const out = Object.assign({}, ...usages);
116
+ out.tokens = foldTokens(usages.map(u => u.tokens));
117
+ out.cost = foldCosts(usages.map(u => u.cost), null);
118
+ const subtrees = usages.map(u => u.subtree).filter(Boolean);
119
+ if (subtrees.length > 0) {
120
+ out.subtree = {
121
+ sessions: subtrees.reduce((n, s) => n + (s.sessions || 0), 0),
122
+ tokens: foldTokens(subtrees.map(s => s.tokens)),
123
+ cost: foldCosts(subtrees.map(s => s.cost), { amount: null, currency: 'USD', source: 'unknown' }),
124
+ };
125
+ }
126
+ if (usages.some(u => u.subtreeUnknown)) { out.subtreeUnknown = true; }
127
+ return out;
80
128
  }
81
129
 
82
130
  /**
@@ -162,6 +162,12 @@ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow
162
162
  // OpenCode session may have kept working (and billing) afterwards. Travels
163
163
  // with the leg so it is readable long after the run's stderr is gone.
164
164
  toolSettleTimedOut: (result && result.toolSettleTimedOut) || undefined,
165
+ // v4.4.1 LC-2: whether the ceiling's abort landed. Deliberately NOT
166
+ // `|| undefined` like the flag above — a `false` here is the whole point
167
+ // ("we tried to stop it and could not; it may still be billing") and must
168
+ // survive onto disk. runHeadless sets it only when the ceiling was hit, so
169
+ // passing it through unchanged keeps a clean leg carrying neither field.
170
+ toolSettleAborted: result ? result.toolSettleAborted : undefined,
165
171
  };
166
172
  let finalMeta = legPatch;
167
173
  if (legDir) {
@@ -0,0 +1,61 @@
1
+ // src/sidecar/fanout-signals.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module fanout-signals
6
+ * A fan-out wave's signal-abort handler, extracted from fanout.js for the
7
+ * 300-line size gate (v4.4.1 fix wave, finding F3 needed room in fanout.js).
8
+ * Pure move plus the force-exit reaper described below — same markers, same
9
+ * ordering, same watchdog window.
10
+ *
11
+ * The contract fanout.js relies on: mark the wave and every leg aborted, then
12
+ * let NORMAL control flow finalize. Legs see their abort marker within one poll
13
+ * (~2s) and settle, so the caller still writes wave.json and emits a parseable
14
+ * aborted document. The force-exit watchdog is only a backstop for a wedged leg.
15
+ */
16
+
17
+ const { logger } = require('../utils/logger');
18
+ const { installSignalAbort, markAborted } = require('../utils/session-abort');
19
+ const { armExitWatchdog, exitReaping } = require('../utils/lifecycle');
20
+
21
+ /** Force-exit backstop window; comfortably outlives close()'s ~2s escalation. */
22
+ const WAVE_FORCE_EXIT_MS = 10000;
23
+
24
+ /**
25
+ * @param {{waveId: string, waveDir: string, legDirs: string[], server: object,
26
+ * externalServer: boolean}} args
27
+ * @returns {{uninstall: Function, signal: () => (string|null)}} `signal()` is a
28
+ * GETTER — the handler mutates it between awaits, so a snapshot would miss it.
29
+ */
30
+ function installWaveAbort({ waveId, waveDir, legDirs, server, externalServer }) {
31
+ let signalled = null;
32
+ const uninstall = installSignalAbort({
33
+ onAbort: (signal) => {
34
+ const code = signal === 'SIGINT' ? 130 : 143;
35
+ if (signalled) { process.exit(code); } // second signal: exit NOW
36
+ signalled = signal;
37
+ logger.warn('Signal received — aborting wave', { waveId, signal });
38
+ markAborted(waveDir, signal);
39
+ for (const dir of legDirs) { markAborted(dir, signal); }
40
+ // close() is async (B06 escalation); this handler stays sync, so
41
+ // fire-and-forget with a rejection guard.
42
+ // ⚠️ close site 1 of 2 — NEVER an injected server: it belongs to the
43
+ // council run, whose own signal handler tears it down in finalize().
44
+ // Closing it here would kill every sibling and later wave in the run.
45
+ if (!externalServer) { try { server.close().catch(() => {}); } catch { /* best-effort */ } }
46
+ // ⚠️ …but a FORCE exit must not orphan it either (F3). We no longer close
47
+ // an injected server above, so if this watchdog fires before the owner's
48
+ // finalize() runs, the OpenCode process outlives the parent — still
49
+ // holding the SQLite lock this whole task exists to stop contending on.
50
+ // exitReaping SIGTERMs its Go pid on the way out. An OWNED server needs
51
+ // no hook: close() above already signalled it.
52
+ armExitWatchdog(code, WAVE_FORCE_EXIT_MS, {
53
+ log: (m, meta) => logger.debug(m, meta),
54
+ ...(externalServer ? { exit: exitReaping(server) } : {}),
55
+ });
56
+ },
57
+ });
58
+ return { uninstall, signal: () => signalled };
59
+ }
60
+
61
+ module.exports = { installWaveAbort, WAVE_FORCE_EXIT_MS };
@@ -0,0 +1,75 @@
1
+ // src/sidecar/fanout-wave-io.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module fanout-wave-io
6
+ * On-disk lifecycle of a WAVE document, split out of fanout.js to keep that
7
+ * file under the 300-line gate (v4.4.1 Task 0.5, which needed room for the
8
+ * external-server seam). Pure move — same writes, same order, same atomicity:
9
+ *
10
+ * writeWaveMetadata metadata.json read-merge-write, abort-wins
11
+ * writeWaveDoc wave.json, atomic (tmp + rename)
12
+ * finishWave the terminal path every completed wave funnels through:
13
+ * persist → checkpoint metadata → emit wave-terminal →
14
+ * fire --on-complete → print
15
+ *
16
+ * `finishWave` had two byte-identical copies in fanout.js (the all-legs-failed-
17
+ * to-route short circuit and the normal aggregation); they are one function here
18
+ * so a future change to the terminal contract cannot land on only one of them.
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const { writeFileAtomic } = require('../utils/atomic-write');
24
+
25
+ /**
26
+ * Write/merge wave metadata (preserves fields an MCP pre-spawn handler wrote).
27
+ * Abort-wins: once existing status is 'aborted', a patch cannot demote it back
28
+ * to a softer status (same precedence rule as writeLegPatch — a signal/abort
29
+ * marker must never lose a write race against an in-flight init/finalize).
30
+ */
31
+ function writeWaveMetadata(waveDir, patch) {
32
+ const metaPath = path.join(waveDir, 'metadata.json');
33
+ let existing = {};
34
+ if (fs.existsSync(metaPath)) {
35
+ try { existing = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); } catch { /* corrupt → rewrite */ }
36
+ }
37
+ const safePatch = { ...patch };
38
+ if (existing.status === 'aborted' && safePatch.status && safePatch.status !== 'aborted') {
39
+ delete safePatch.status;
40
+ }
41
+ const merged = { ...existing, ...safePatch };
42
+ writeFileAtomic(metaPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
43
+ return merged;
44
+ }
45
+
46
+ /**
47
+ * Persist a wave document atomically (tmp + rename).
48
+ * @returns {string} the wave.json path
49
+ */
50
+ function writeWaveDoc(waveDir, wave) {
51
+ const wavePath = path.join(waveDir, 'wave.json');
52
+ writeFileAtomic(wavePath, JSON.stringify(wave, null, 2), { mode: 0o600 });
53
+ return wavePath;
54
+ }
55
+
56
+ /**
57
+ * Terminal path for a wave that produced leg documents: persist wave.json,
58
+ * checkpoint metadata.json, emit wave-terminal, fire --on-complete, print.
59
+ * @param {{wave: object, waveDir: string, waveId: string, project: string,
60
+ * exitCode: number, completedAt: string, follow: object|null, emit: Function,
61
+ * onComplete?: string, onCompleteDeps?: object}} args
62
+ * @returns {Promise<{wave: object, exitCode: number}>}
63
+ */
64
+ async function finishWave({ wave, waveDir, waveId, project, exitCode, completedAt, follow, emit, onComplete, onCompleteDeps }) {
65
+ const { emitWaveTerminal } = require('../observe/events');
66
+ const wavePath = writeWaveDoc(waveDir, wave);
67
+ writeWaveMetadata(waveDir, { status: wave.status, completedAt });
68
+ emitWaveTerminal(waveDir, waveId, { status: wave.status, counts: wave.counts, usage: wave.usage, exitCode }, follow);
69
+ await require('../observe/on-complete').fireWaveOnComplete(onComplete, wave,
70
+ { waveId, waveDir, wavePath, exitCode, project }, onCompleteDeps);
71
+ emit(wave);
72
+ return { wave, exitCode };
73
+ }
74
+
75
+ module.exports = { writeWaveMetadata, writeWaveDoc, finishWave };
@@ -16,7 +16,10 @@ const { logger } = require('../utils/logger');
16
16
  const { runLeg, buildRoutingFailureLeg } = require('./fanout-leg');
17
17
  const { parseModelsList, DEFAULT_MAX_LEGS, validateFanoutModels } = require('./fanout-validate');
18
18
  const { ERROR_CODES } = require('../utils/error-doc');
19
- const { writeFileAtomic } = require('../utils/atomic-write');
19
+ // Wave-document persistence lives in ./fanout-wave-io (size-gate split, v4.4.1
20
+ // Task 0.5). writeWaveMetadata is re-exported below — fanout-retry.js and the
21
+ // fanout tests import it from here.
22
+ const { writeWaveMetadata, writeWaveDoc, finishWave } = require('./fanout-wave-io');
20
23
 
21
24
  /**
22
25
  * Derive leg task IDs: <waveId>-1 .. <waveId>-N (matches TASK_ID_PATTERN).
@@ -28,27 +31,6 @@ function deriveLegIds(waveId, count) {
28
31
  return Array.from({ length: count }, (_, i) => `${waveId}-${i + 1}`);
29
32
  }
30
33
 
31
- /**
32
- * Write/merge wave metadata (preserves fields an MCP pre-spawn handler wrote).
33
- * Abort-wins: once existing status is 'aborted', a patch cannot demote it back
34
- * to a softer status (same precedence rule as writeLegPatch — a signal/abort
35
- * marker must never lose a write race against an in-flight init/finalize).
36
- */
37
- function writeWaveMetadata(waveDir, patch) {
38
- const metaPath = path.join(waveDir, 'metadata.json');
39
- let existing = {};
40
- if (fs.existsSync(metaPath)) {
41
- try { existing = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); } catch { /* corrupt → rewrite */ }
42
- }
43
- const safePatch = { ...patch };
44
- if (existing.status === 'aborted' && safePatch.status && safePatch.status !== 'aborted') {
45
- delete safePatch.status;
46
- }
47
- const merged = { ...existing, ...safePatch };
48
- writeFileAtomic(metaPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
49
- return merged;
50
- }
51
-
52
34
  /**
53
35
  * Run a fan-out wave. Spec §4.3.
54
36
  * @param {object} options - models, prompt, promptMeta, waveId?, project, agent?,
@@ -59,7 +41,17 @@ function writeWaveMetadata(waveDir, patch) {
59
41
  * with routing.prefer, applied per leg), json?, client?, quiet? (suppress
60
42
  * stdout — tests), councilRunId? / councilName? (v4.3 §7.2: stamped onto legs),
61
43
  * fallback? / catalog? (v4.3 Task 18 §6.2: opt-in substitution; off/absent unchanged),
62
- * retryContexts? / retryOfWaveId? (v4.3 Task 19: --retry-failed relaunch seam; absent -> byte-identical)
44
+ * retryContexts? / retryOfWaveId? (v4.3 Task 19: --retry-failed relaunch seam; absent -> byte-identical),
45
+ * pack? (v4.5 Task 13: {name,version,hash,source} record when launched via
46
+ * --pack; absent/null -> omitted from wave metadata.json/wave.json, not stored as null.
47
+ * v4.5 final-review F2: when absent, wave.json still inherits a pack the caller
48
+ * pre-seeded onto this wave dir's metadata.json before calling runFanout — see
49
+ * `metaPack` below. That is how an MCP-spawned child, which never receives
50
+ * --pack itself, still ends up with the pack on its wave.json),
51
+ * server? + serverClient? (v4.4.1 Task 0.5: an ALREADY-STARTED OpenCode server
52
+ * to run this wave's legs on. Both or neither. When supplied this wave never
53
+ * starts a server and never closes one — see the seam comment in step 4.
54
+ * NOT `client`, which is the client TYPE string on this function.)
63
55
  * @returns {Promise<{wave: object, exitCode: number}>} Never rejects for leg errors.
64
56
  */
65
57
  async function runFanout(options) {
@@ -70,9 +62,9 @@ async function runFanout(options) {
70
62
  const { buildContext } = require('./context-builder');
71
63
  const { buildPrompts } = require('../prompt-builder');
72
64
  const { generateFoldNonce } = require('../utils/fold-marker');
73
- const { installSignalAbort, markAborted } = require('../utils/session-abort');
65
+ const { installWaveAbort } = require('./fanout-signals');
74
66
  const { getSessionDir } = require('../session-manager');
75
- const { emitWaveStarted, emitWaveTerminal } = require('../observe/events');
67
+ const { emitWaveStarted } = require('../observe/events'); // wave-terminal is emitted by finishWave
76
68
 
77
69
  const project = options.project || process.cwd();
78
70
  const createdAt = new Date().toISOString();
@@ -88,9 +80,17 @@ async function runFanout(options) {
88
80
  console.log(formatWaveHuman(doc));
89
81
  }
90
82
  };
91
- const errorWave = (waveId, message) => {
92
- const doc = buildWaveResult({ waveId: waveId || null, legs: [], promptMeta: options.promptMeta || null, createdAt, completedAt: new Date().toISOString(), status: 'error' });
83
+ // v4.4.1 Task 0.5: a wave that dies BEFORE its legs still owes the run a
84
+ // wave.json. Backlog C1 covered the pre-`try` throw; a server that never
85
+ // started was its uncovered sibling — run v441plan01's four dead seats left a
86
+ // `reason` in metadata.json, no wave.json, and stage1 recorded 'complete'.
87
+ // waveDir is optional (only the post-creation caller has one).
88
+ const errorWave = (waveId, message, waveDir) => {
89
+ const doc = buildWaveResult({ waveId: waveId || null, legs: [], promptMeta: options.promptMeta || null, pack: options.pack, createdAt, completedAt: new Date().toISOString(), status: 'error' });
93
90
  doc.error = message;
91
+ doc.reason = message; // classifier alias, same as fanout-leg.js's run docs
92
+ // best-effort: an unwritable wave dir must not mask the real error
93
+ if (waveDir) { try { writeWaveDoc(waveDir, doc); } catch { /* ignore */ } }
94
94
  emit(doc);
95
95
  return { wave: doc, exitCode: 1 };
96
96
  };
@@ -143,13 +143,24 @@ async function runFanout(options) {
143
143
  const waveDir = getSessionDir(project, waveId);
144
144
  fs.mkdirSync(waveDir, { recursive: true, mode: 0o700 });
145
145
  fs.writeFileSync(path.join(waveDir, 'briefing.md'), options.prompt, { mode: 0o600 });
146
- writeWaveMetadata(waveDir, {
146
+ const waveMeta = writeWaveMetadata(waveDir, {
147
147
  taskId: waveId, type: 'wave', status: 'running', mode: 'headless',
148
148
  models: legs.map(l => (l.ok ? l.model : l.modelInput)), legs: legIds,
149
149
  briefing: String(options.prompt).slice(0, 200),
150
150
  promptMeta: options.promptMeta || null,
151
+ ...(options.pack ? { pack: options.pack } : {}), // v4.5 Task 13: absent-not-null.
151
152
  pid: process.pid, project, createdAt,
152
153
  });
154
+ // v4.5 final-review F2: an MCP-spawned child never gets --pack (single-
155
+ // resolution rule), but mcp-server.js pre-seeds THIS wave dir's
156
+ // metadata.json with the pack it already resolved in-process before
157
+ // spawning the child. writeWaveMetadata read-merges (fanout-wave-io.js),
158
+ // so its RETURN VALUE already carries that pre-seeded pack when
159
+ // options.pack is absent here — inherit from it below rather than
160
+ // re-reading the file (mirrors the inherit idiom in
161
+ // result-schema-rebuild.js:93, which reads meta.pack off a metadata.json
162
+ // it loaded for an unrelated reason).
163
+ const metaPack = waveMeta.pack;
153
164
  emitWaveStarted(waveDir, waveId, legs.map(l => (l.ok ? l.model : l.modelInput)), legIds, follow);
154
165
 
155
166
  // 2b. All legs failed to route (#61 perf): no leg will ever touch the
@@ -160,16 +171,11 @@ async function runFanout(options) {
160
171
  const legDocs = legs.map((leg, i) => buildRoutingFailureLeg({ leg, legId: legIds[i], waveId, quiet: options.quiet }));
161
172
  const completedAt = new Date().toISOString();
162
173
  const wave = buildWaveResult({
163
- waveId, legs: legDocs, promptMeta: options.promptMeta || null, createdAt, completedAt, notices,
174
+ waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, createdAt, completedAt, notices,
164
175
  });
165
- const wavePath = path.join(waveDir, 'wave.json');
166
- writeFileAtomic(wavePath, JSON.stringify(wave, null, 2), { mode: 0o600 });
167
- writeWaveMetadata(waveDir, { status: wave.status, completedAt });
168
- const routingExitCode = waveExitCode(wave.status);
169
- emitWaveTerminal(waveDir, waveId, { status: wave.status, counts: wave.counts, usage: wave.usage, exitCode: routingExitCode }, follow);
170
- await require('../observe/on-complete').fireWaveOnComplete(options.onComplete, wave, { waveId, waveDir, wavePath, exitCode: routingExitCode, project }, options.onCompleteDeps);
171
- emit(wave);
172
- return { wave, exitCode: routingExitCode };
176
+ return finishWave({ wave, waveDir, waveId, project, completedAt, follow, emit,
177
+ exitCode: waveExitCode(wave.status),
178
+ onComplete: options.onComplete, onCompleteDeps: options.onCompleteDeps });
173
179
  }
174
180
 
175
181
  // 3. Context + prompts built ONCE (model-independent)
@@ -198,37 +204,41 @@ async function runFanout(options) {
198
204
  mcp: options.mcp, mcpConfig: options.mcpConfig, clientType: options.client,
199
205
  noMcp: options.noMcp, excludeMcp: options.excludeMcp,
200
206
  });
207
+ // ⚠️ v4.4.1 Task 0.5 — the external-server seam runHeadless has carried since
208
+ // v4.0 (src/headless.js:245): a caller that already owns a server passes it in
209
+ // and we must NOT close it. Added here because a council run launches its
210
+ // Stage-1 seat wave and its critic solo under ONE Promise.all (run-stages.js:83)
211
+ // and two concurrent startOpenCodeServer calls race on OpenCode's SQLite —
212
+ // run v441plan01 lost four of five seats in 736ms to `database is locked`.
213
+ // NAME DIVERGENCE, deliberate: runHeadless spells the pair client+server, but
214
+ // runFanout's `options.client` is ALREADY the client TYPE string (buildMcpConfig
215
+ // above, buildContext, buildPrompts, cli-handlers-run.js `client: args.client`),
216
+ // so the SDK client is `options.serverClient`. Both or neither: a half-injection
217
+ // falls back to owning a server rather than running clientless.
218
+ const externalServer = !!(options.server && options.serverClient);
201
219
  let client, server;
202
- try {
203
- ({ client, server } = await startOpenCodeServer(mcpServers, { models: validated.serverModels || okLegs.map(l => l.model) }));
204
- } catch (err) {
205
- writeWaveMetadata(waveDir, { status: 'error', reason: err.message, completedAt: new Date().toISOString() });
206
- return errorWave(waveId, `Failed to start server: ${err.message}`);
220
+ if (externalServer) {
221
+ ({ serverClient: client, server } = options);
222
+ logger.debug('Using external server (shared server mode)', { waveId, url: server.url });
223
+ } else {
224
+ try {
225
+ ({ client, server } = await startOpenCodeServer(mcpServers, { models: validated.serverModels || okLegs.map(l => l.model) }));
226
+ } catch (err) {
227
+ writeWaveMetadata(waveDir, { status: 'error', reason: err.message, completedAt: new Date().toISOString() });
228
+ return errorWave(waveId, `Failed to start server: ${err.message}`, waveDir);
229
+ }
207
230
  }
208
- if (server.goPid) { writeWaveMetadata(waveDir, { goPid: server.goPid }); }
231
+ // Only an OWNED server's pid belongs in this wave's metadata: mcp-server.js's
232
+ // wave abort SIGTERMs `metadata.goPid` as "the orchestrator + its OWNED
233
+ // OpenCode server", which on an injected server would kill every sibling wave.
234
+ if (!externalServer && server.goPid) { writeWaveMetadata(waveDir, { goPid: server.goPid }); }
209
235
 
210
- // 5. Signal abort: mark wave + all legs aborted, close the server, then let
211
- // NORMAL control flow finalize legs see their abort marker within one poll
212
- // (~2s) and settle, so step 7 still writes wave.json and emits a parseable
213
- // aborted document. An unref'd force-exit watchdog backstops a wedged leg.
236
+ // 5. Signal abort (./fanout-signals owns the handler size gate): mark wave +
237
+ // all legs aborted, close an OWNED server, then let NORMAL control flow
238
+ // finalize, so step 7 still writes wave.json and emits a parseable aborted
239
+ // document. An unref'd force-exit watchdog backstops a wedged leg.
214
240
  const legDirs = legIds.map(id => getSessionDir(project, id));
215
- let signalled = null;
216
- const uninstallSignals = installSignalAbort({
217
- onAbort: (signal) => {
218
- const code = signal === 'SIGINT' ? 130 : 143;
219
- if (signalled) { process.exit(code); } // second signal: exit NOW
220
- signalled = signal;
221
- logger.warn('Signal received — aborting wave', { waveId, signal });
222
- markAborted(waveDir, signal);
223
- for (const dir of legDirs) { markAborted(dir, signal); }
224
- // close() is async (B06 escalation); this handler stays sync, so
225
- // fire-and-forget with a rejection guard. The 10s exit watchdog below
226
- // comfortably outlives the ~2s escalation grace inside close().
227
- try { server.close().catch(() => {}); } catch { /* best-effort */ }
228
- const { armExitWatchdog } = require('../utils/lifecycle');
229
- armExitWatchdog(code, 10000, { log: (m, meta) => logger.debug(m, meta) });
230
- },
231
- });
241
+ const waveAbort = installWaveAbort({ waveId, waveDir, legDirs, server, externalServer });
232
242
 
233
243
  // 6. Launch all ROUTABLE legs concurrently (runLeg never rejects). A leg that
234
244
  // failed to route (leg.ok === false) resolves to an error run doc in its own
@@ -262,26 +272,24 @@ async function runFanout(options) {
262
272
  }));
263
273
  } finally {
264
274
  heartbeat.stop();
265
- uninstallSignals();
266
- try { await server.close(); } catch { /* already closed on signal */ }
275
+ waveAbort.uninstall();
276
+ // ⚠️ close site 2 of 2 (`grep -n "server.close()" src/sidecar/fanout.js`).
277
+ // An injected server outlives this wave by design — the owner closes it once.
278
+ if (!externalServer) { try { await server.close(); } catch { /* already closed on signal */ } }
267
279
  }
268
280
 
269
281
  // 7. Aggregate, persist (atomic: tmp + rename), finalize, emit
270
282
  const completedAt = new Date().toISOString();
283
+ const signalled = waveAbort.signal();
271
284
  const wave = buildWaveResult({
272
- waveId, legs: legDocs, promptMeta: options.promptMeta || null, createdAt, completedAt,
285
+ waveId, legs: legDocs, promptMeta: options.promptMeta || null, pack: options.pack || metaPack, createdAt, completedAt,
273
286
  status: signalled ? 'aborted' : null, notices,
274
287
  });
275
- const wavePath = path.join(waveDir, 'wave.json');
276
- writeFileAtomic(wavePath, JSON.stringify(wave, null, 2), { mode: 0o600 });
277
- writeWaveMetadata(waveDir, { status: wave.status, completedAt });
278
288
  const exitCode = signalled
279
289
  ? (signalled === 'SIGINT' ? 130 : 143)
280
290
  : waveExitCode(wave.status);
281
- emitWaveTerminal(waveDir, waveId, { status: wave.status, counts: wave.counts, usage: wave.usage, exitCode }, follow);
282
- await require('../observe/on-complete').fireWaveOnComplete(options.onComplete, wave, { waveId, waveDir, wavePath, exitCode, project }, options.onCompleteDeps);
283
- emit(wave);
284
- return { wave, exitCode };
291
+ return finishWave({ wave, waveDir, waveId, project, exitCode, completedAt, follow, emit,
292
+ onComplete: options.onComplete, onCompleteDeps: options.onCompleteDeps });
285
293
  }
286
294
 
287
295
  module.exports = {
@@ -10,6 +10,23 @@
10
10
  /** Coarse stages surfaced to agents. */
11
11
  const COARSE_STAGES = ['starting', 'generating', 'folding', 'terminal'];
12
12
 
13
+ /**
14
+ * The stages src/headless.js can write on its ONE terminal progress record.
15
+ *
16
+ * ⚠️ v4.4.1 LC-3: this list must stay byte-identical to resolveTerminalState's
17
+ * status vocabulary (src/sidecar/session-finalize.js), because headless.js
18
+ * derives the terminal stage straight from that function. A drift pin in
19
+ * tests/sidecar/progress-fields.test.js asserts it.
20
+ *
21
+ * Before LC-3 the terminal record hardcoded 'complete' on every path, so this
22
+ * set had exactly one member and deriveStage could match the literal. Widening
23
+ * the writer without widening this set would have been the WORSE bug: an
24
+ * aborted/errored/timed-out leg would fall through to 'starting' and a finished
25
+ * leg would read as barely begun for the whole window before metadata.json
26
+ * lands.
27
+ */
28
+ const TERMINAL_PROGRESS_STAGES = new Set(['complete', 'error', 'timed-out', 'aborted']);
29
+
13
30
  /**
14
31
  * Collapse whitespace and defang fence/tag characters so the preview can be
15
32
  * embedded in a one-line JSON status without opening a code fence or tag
@@ -42,8 +59,10 @@ function latestAssistantPreview(entries) {
42
59
  * Map (metadata.status, progress.stage) to the coarse agent-facing stage.
43
60
  * - terminal metadata status -> 'terminal'
44
61
  * - progress 'receiving' -> 'generating'
45
- * - progress 'complete' while metadata still says running -> 'folding'
46
- * (mirror stopped; summary/conflict finalize in flight)
62
+ * - a TERMINAL progress stage while metadata still says running -> 'folding'
63
+ * (mirror stopped; summary/conflict finalize in flight). v4.4.1 LC-3: that is
64
+ * any of TERMINAL_PROGRESS_STAGES, not just 'complete' — an aborted or
65
+ * errored leg is every bit as "done streaming, finalizing" as a clean one.
47
66
  * - anything else -> 'starting'
48
67
  * @param {string|undefined} metadataStatus @param {string|undefined} progressStage
49
68
  * @returns {string}
@@ -53,8 +72,11 @@ function deriveStage(metadataStatus, progressStage) {
53
72
  return 'terminal';
54
73
  }
55
74
  if (progressStage === 'receiving') { return 'generating'; }
56
- if (progressStage === 'complete') { return 'folding'; }
75
+ if (TERMINAL_PROGRESS_STAGES.has(progressStage)) { return 'folding'; }
57
76
  return 'starting';
58
77
  }
59
78
 
60
- module.exports = { sanitizePreview, latestAssistantPreview, deriveStage, COARSE_STAGES };
79
+ module.exports = {
80
+ sanitizePreview, latestAssistantPreview, deriveStage, COARSE_STAGES,
81
+ TERMINAL_PROGRESS_STAGES,
82
+ };
@@ -19,7 +19,14 @@ const STAGE_LABELS = {
19
19
  session_created: 'Session created',
20
20
  prompt_sent: 'Briefing delivered, waiting for response...',
21
21
  receiving: 'Generating response...',
22
- complete: 'Complete'
22
+ complete: 'Complete',
23
+ // v4.4.1 LC-3: the terminal record is no longer always 'complete' — headless.js
24
+ // derives it from resolveTerminalState, so these three are now reachable. Without
25
+ // a label, readProgress's `latest` (which falls back to stageLabel when the leg
26
+ // has no assistant messages yet) would read the raw id: "aborted", not "Aborted".
27
+ aborted: 'Aborted',
28
+ error: 'Failed',
29
+ 'timed-out': 'Timed out'
23
30
  };
24
31
 
25
32
  /**
@@ -123,6 +130,39 @@ function writeProgress(sessionDir, stage, extra = {}) {
123
130
  writeFileAtomic(progressPath, JSON.stringify(data), { mode: 0o600 });
124
131
  }
125
132
 
133
+ /**
134
+ * FR-1 (v4.5): stamp a TERMINAL stage into progress.json on a failure path,
135
+ * preserving previously recorded usage. Extracted from headless.js's A3 outer
136
+ * catch so the early-return paths and the exception path can never drift.
137
+ *
138
+ * writeProgress REBUILDS progress.json (no merge) — a bare terminal write would
139
+ * delete whatever real spend the last flush recorded, trading a stale-stage bug
140
+ * for a cost under-report on exactly the legs that failed. So the prior usage is
141
+ * read back and re-attached; `extra` fields beyond usage are deliberately
142
+ * dropped (readProgress derives message counts from conversation.jsonl).
143
+ *
144
+ * Runs on error paths: must never throw and never mask the original error.
145
+ *
146
+ * @param {string} sessionDir
147
+ * @param {string} errorMessage - the failure being recorded (stage derives from it)
148
+ * @returns {boolean} true if the terminal record was written
149
+ */
150
+ function writeTerminalProgressSafe(sessionDir, errorMessage) {
151
+ try {
152
+ let priorUsage = null;
153
+ try {
154
+ const prior = JSON.parse(fs.readFileSync(path.join(sessionDir, 'progress.json'), 'utf-8'));
155
+ if (prior && prior.usage) { priorUsage = prior.usage; }
156
+ } catch { /* no readable prior record: write the terminal stage without usage */ }
157
+ const { resolveTerminalState } = require('./session-finalize');
158
+ const stage = resolveTerminalState({ error: errorMessage }).status;
159
+ writeProgress(sessionDir, stage, priorUsage ? { usage: priorUsage } : {});
160
+ return true;
161
+ } catch {
162
+ return false;
163
+ }
164
+ }
165
+
126
166
  /**
127
167
  * Read progress from a session's conversation.jsonl and progress.json files.
128
168
  *
@@ -235,6 +275,7 @@ function readProgress(sessionDir) {
235
275
  module.exports = {
236
276
  readProgress,
237
277
  writeProgress,
278
+ writeTerminalProgressSafe,
238
279
  extractLatest,
239
280
  computeLastActivity,
240
281
  STAGE_LABELS,