amicus 4.3.0 → 4.4.1

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 (94) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +64 -0
  3. package/README.md +6 -3
  4. package/docs/DISTRIBUTION.md +234 -0
  5. package/docs/ROADMAP.md +200 -0
  6. package/docs/SHIMS.md +62 -0
  7. package/docs/architecture.md +104 -0
  8. package/docs/configuration.md +371 -0
  9. package/docs/council.md +911 -0
  10. package/docs/doc-system.md +92 -0
  11. package/docs/electron-testing.md +471 -0
  12. package/docs/jsdoc-setup.md +75 -0
  13. package/docs/opencode-integration.md +114 -0
  14. package/docs/publishing.md +60 -0
  15. package/docs/schemas.md +55 -0
  16. package/docs/testing.md +589 -0
  17. package/docs/troubleshooting.md +298 -0
  18. package/docs/usage.md +699 -0
  19. package/electron/fold.js +1 -1
  20. package/electron/ipc-workspace.js +283 -0
  21. package/electron/main.js +31 -1
  22. package/electron/preload-workspace.js +40 -0
  23. package/electron/setup-ui-aliases.js +6 -6
  24. package/electron/workspace-shell.js +85 -0
  25. package/electron/workspace-ui/index.html +111 -0
  26. package/electron/workspace-ui/live-model.js +112 -0
  27. package/electron/workspace-ui/md-lite.js +163 -0
  28. package/electron/workspace-ui/workspace-app.js +240 -0
  29. package/electron/workspace-ui/workspace-matrix.js +249 -0
  30. package/electron/workspace-ui/workspace-panels.js +237 -0
  31. package/electron/workspace-ui/workspace-render.js +277 -0
  32. package/electron/workspace-ui/workspace-verbs.js +293 -0
  33. package/electron/workspace-ui/workspace.css +172 -0
  34. package/package.json +8 -3
  35. package/schemas/council-run-live.schema.json +25 -1
  36. package/schemas/council-run.schema.json +34 -0
  37. package/schemas/progress.schema.json +26 -1
  38. package/schemas/spend.schema.json +52 -4
  39. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  40. package/src/cli-handlers-council-run.js +25 -3
  41. package/src/cli-handlers-spend.js +50 -5
  42. package/src/cli-handlers-watch.js +48 -10
  43. package/src/cli.js +4 -2
  44. package/src/council/briefings-debate.js +27 -7
  45. package/src/council/briefings-stage2.js +155 -25
  46. package/src/council/briefings.js +59 -3
  47. package/src/council/findings.js +236 -9
  48. package/src/council/parse-stage2.js +10 -2
  49. package/src/council/report.js +19 -8
  50. package/src/council/run-assemble.js +42 -1
  51. package/src/council/run-budget.js +277 -0
  52. package/src/council/run-chair.js +4 -1
  53. package/src/council/run-debate.js +4 -2
  54. package/src/council/run-finalize.js +102 -0
  55. package/src/council/run-launch.js +73 -7
  56. package/src/council/run-server.js +248 -0
  57. package/src/council/run-stage2.js +118 -0
  58. package/src/council/run-stages.js +148 -113
  59. package/src/council/run-state.js +23 -1
  60. package/src/council/run.js +52 -53
  61. package/src/council/tally.js +10 -0
  62. package/src/headless.js +519 -17
  63. package/src/mcp-council-awareness.js +53 -3
  64. package/src/observe/council-legs.js +240 -0
  65. package/src/observe/live-doc.js +39 -4
  66. package/src/observe/watch-render.js +23 -1
  67. package/src/opencode-client.js +15 -3
  68. package/src/sidecar/child-sessions.js +197 -0
  69. package/src/sidecar/conversation-mirror.js +111 -37
  70. package/src/sidecar/fanout-budget.js +71 -0
  71. package/src/sidecar/fanout-leg-fallback.js +69 -21
  72. package/src/sidecar/fanout-leg.js +29 -1
  73. package/src/sidecar/fanout-signals.js +61 -0
  74. package/src/sidecar/fanout-wave-io.js +75 -0
  75. package/src/sidecar/fanout.js +65 -81
  76. package/src/sidecar/progress-fields.js +26 -4
  77. package/src/sidecar/progress.js +8 -1
  78. package/src/sidecar/session-utils.js +23 -14
  79. package/src/sidecar/tool-part.js +196 -0
  80. package/src/sidecar/workspace-window.js +62 -0
  81. package/src/spend-query.js +33 -6
  82. package/src/utils/env-num.js +42 -0
  83. package/src/utils/lifecycle.js +37 -1
  84. package/src/utils/path-fence.js +120 -0
  85. package/src/utils/pricing.js +114 -9
  86. package/src/utils/server-setup.js +79 -1
  87. package/src/utils/spend-ledger.js +24 -3
  88. package/src/workspace/artifact-guard.js +208 -0
  89. package/src/workspace/blind-mode.js +32 -0
  90. package/src/workspace/fold-format.js +124 -0
  91. package/src/workspace/live-normalize.js +169 -0
  92. package/src/workspace/matrix-model.js +94 -0
  93. package/src/workspace/run-detail.js +229 -0
  94. package/src/workspace/run-scan.js +148 -0
@@ -15,8 +15,14 @@
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
17
  const runState = require('./council/run-state');
18
+ // The pointer-containment fence. Lives in a dependency-free leaf module
19
+ // (src/utils/path-fence.js) precisely so any surface can require it —
20
+ // requiring it here adds no cycle and keeps ONE implementation of the check
21
+ // shared with the v4.4 workspace reads.
22
+ const { containsOnDisk } = require('./utils/path-fence');
18
23
  const { RUNNING_VERSION } = require('./utils/version-info');
19
24
  const { enrichLegUsage, markLive, rollupWaveUsage } = require('./observe/live-doc');
25
+ const { buildLegRows } = require('./observe/council-legs');
20
26
 
21
27
  /**
22
28
  * Every wave a stage launched: the primary `waveId` plus the recorded
@@ -94,6 +100,30 @@ function legUsage(project, legId) {
94
100
  return enrichLegUsage({ model }, progressUsage);
95
101
  }
96
102
 
103
+ /**
104
+ * readPointer PLUS the containment check the pointer file itself cannot
105
+ * provide. runState.readPointer validates `council-<id>.json`'s {runId, runDir}
106
+ * JSON only for truthiness (run-state.js:133-139), so a tampered or stale
107
+ * pointer can point runDir anywhere on disk — and the two callers below do not
108
+ * merely READ from it, they runState.checkpoint() INTO it (crash detection and
109
+ * abort), which makes an unfenced pointer a write primitive at an
110
+ * attacker-chosen path. A real runDir is always nested inside the project:
111
+ * src/mcp-council-run.js:109 rejects an outDir outside it at creation time, so
112
+ * nothing legitimate is refused here.
113
+ *
114
+ * Fails to null — the SAME "not a council run" signal an absent/corrupt pointer
115
+ * already produces, so amicus_status / amicus_abort keep their existing
116
+ * "Session <id> not found in project <cwd>" error contract (mcp-server.js:586,
117
+ * :1005) and `amicus abort` keeps falling through to its own not-found path.
118
+ * No new error shape, and — because the fence runs before readRun — no read or
119
+ * write ever reaches the escaping directory.
120
+ * @returns {{runId: string, runDir: string}|null}
121
+ */
122
+ function readFencedPointer(project, taskId) {
123
+ const ptr = runState.readPointer(project, taskId);
124
+ return ptr && containsOnDisk(project, ptr.runDir) ? ptr : null;
125
+ }
126
+
97
127
  function elapsedOf(run) {
98
128
  const end = run.completedAt || new Date().toISOString();
99
129
  const ms = Math.max(0, new Date(end).getTime() - new Date(run.createdAt || end).getTime());
@@ -102,7 +132,7 @@ function elapsedOf(run) {
102
132
 
103
133
  /** Status payload for a council runId, or null when the id is not a council run. */
104
134
  function buildCouncilStatusPayload(project, taskId) {
105
- const ptr = runState.readPointer(project, taskId);
135
+ const ptr = readFencedPointer(project, taskId);
106
136
  if (!ptr) { return null; }
107
137
  const run = runState.readRun(ptr.runDir);
108
138
  if (!run) { return null; }
@@ -132,14 +162,19 @@ function buildCouncilStatusPayload(project, taskId) {
132
162
  // least one sub-wave record exists on disk.
133
163
  // Cost-by-seat rides the same loop, read-time from progress.json only (A8) —
134
164
  // usageLegs stays empty (no `usage` on the payload) until a leg has actually
135
- // flushed usage; a leg with none yet contributes nothing (N3).
165
+ // flushed usage; a leg with none yet contributes nothing (N3). allLegIds
166
+ // collects every leg id seen regardless of usage — the row builder below
167
+ // needs just-started legs too (DE-ROT F01: the naive `payload.legs =
168
+ // usageLegs` would silently drop them).
136
169
  const usageLegs = [];
170
+ const allLegIds = [];
137
171
  for (const waveId of active && active.project ? subWaveIds(active) : []) {
138
172
  const c = countWaveLegs(active.project, waveId);
139
173
  if (!c) { continue; }
140
174
  legsTotal = (legsTotal || 0) + c.total;
141
175
  legsComplete = (legsComplete || 0) + c.complete;
142
176
  for (const legId of waveLegIds(active.project, waveId)) {
177
+ allLegIds.push(legId);
143
178
  const enriched = legUsage(active.project, legId);
144
179
  if (enriched.usage) { usageLegs.push(enriched); }
145
180
  }
@@ -152,6 +187,15 @@ function buildCouncilStatusPayload(project, taskId) {
152
187
  version: RUNNING_VERSION,
153
188
  };
154
189
  if (usageLegs.length) { payload.usage = rollupWaveUsage(usageLegs); }
190
+ if (allLegIds.length) {
191
+ // F34/F36: bench/critic/lenses are the alias-valued fields legRole needs
192
+ // (roleFor's rule); stageName lets it treat the chair stage as its own
193
+ // case rather than matching on alias (see council-legs.js's legRole doc).
194
+ const runCtx = { bench: run.bench, critic: run.critic, lenses: run.lenses, stageName: active.name };
195
+ const built = buildLegRows(active.project, allLegIds, runCtx);
196
+ payload.legs = built.rows;
197
+ if (built.stalled) { payload.stalled = true; payload.stalledForSeconds = built.stalledForSeconds; }
198
+ }
155
199
  if (run.error) { payload.reason = `${run.error.code}: ${run.error.message}`; }
156
200
  return markLive(payload);
157
201
  }
@@ -161,6 +205,12 @@ function listCouncilRuns(project) {
161
205
  const { sanitizePreview } = require('./sidecar/progress-fields');
162
206
  const out = [];
163
207
  for (const ptr of runState.listPointers(project)) {
208
+ // Same fence as readFencedPointer, applied per enumerated pointer
209
+ // (listPointers parses the files itself and is no stricter about runDir).
210
+ // Skipping is the right failure mode here: an escaping pointer degrades
211
+ // exactly like the unreadable-run.json case below, so one tampered pointer
212
+ // can never blank the rest of the list.
213
+ if (!containsOnDisk(project, ptr.runDir)) { continue; }
164
214
  const run = runState.readRun(ptr.runDir);
165
215
  if (!run) { continue; }
166
216
  let briefing = '';
@@ -204,7 +254,7 @@ function cascadeWave(project, waveId) {
204
254
  * @returns {null|{notFound?: true}|{alreadyTerminal: true, status}|{aborted: true, cascaded: number}}
205
255
  */
206
256
  function abortCouncilRun(project, taskId) {
207
- const ptr = runState.readPointer(project, taskId);
257
+ const ptr = readFencedPointer(project, taskId);
208
258
  if (!ptr) { return null; }
209
259
  const run = runState.readRun(ptr.runDir);
210
260
  if (!run) { return null; }
@@ -0,0 +1,240 @@
1
+ // src/observe/council-legs.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module observe/council-legs
6
+ * Per-leg row builder for the composed council live doc
7
+ * (buildCouncilStatusPayload in src/mcp-council-awareness.js). Split out of
8
+ * that file to stay under the 300-line gate (DE-ROT Task 0.5, closes F01:
9
+ * `usageLegs` was computed then discarded — no `legs[]` ever reached the
10
+ * payload, so the live Seats panel had no data source).
11
+ *
12
+ * One row per leg id, built UNCONDITIONALLY: unlike the usage rollup (gated
13
+ * on `enriched.usage` in buildCouncilStatusPayload), a live seats panel needs
14
+ * just-started legs — the ones with no usage yet — just as much as priced
15
+ * ones. Field names mirror the wave branch (src/mcp-server.js:592-608) so
16
+ * live-normalize.js (Task 14) has one vocabulary to map, not two.
17
+ *
18
+ * `modelInput` + `role` (F36/F34 correction): a live leg's `model` is the
19
+ * RESOLVED executable id (metadata.model), never the council ALIAS that
20
+ * run.json's bench/chair/critic/lenses and roleFor's rule are keyed on — so
21
+ * deriving role from `model` is a silent no-op (Role column permanently
22
+ * em-dash) and blind mode's labelOf(alias) lookup never matches (real model
23
+ * id leaks). The alias IS on disk per-leg, though: every council leg goes
24
+ * through src/sidecar/fanout-leg.js's runSingleAttempt, which calls
25
+ * `writeLegPatch(legDir, { parentWave, modelInput })` synchronously,
26
+ * immediately after leg creation (fanout-leg.js:101) — well before any
27
+ * status poll could reasonably observe it missing. So this module reads
28
+ * `modelInput` straight off the leg's own metadata.json, no run.json join
29
+ * needed for the alias itself.
30
+ */
31
+
32
+ const fs = require('fs');
33
+ const path = require('path');
34
+ const { readProgress, isStalled } = require('../sidecar/progress');
35
+ const { enrichLegUsage, TERMINAL } = require('./live-doc');
36
+ const { roleFor } = require('../council/run-stages');
37
+ const { logger } = require('../utils/logger');
38
+
39
+ /**
40
+ * A leg's council role. The chair stage is the one case alias identity
41
+ * cannot resolve: run-chair.js's fallback chain (ch1/ch2 = run.chair's own
42
+ * alias, ch3 = a DIFFERENT ledger-promoted alias, ch4 = whichever succeeded)
43
+ * means a chair leg's modelInput does not reliably equal run.json's `chair`
44
+ * field while the chain is still in flight (that field is only checkpointed
45
+ * once the WHOLE chain resolves, src/council/run-chair.js:122) — so alias
46
+ * matching would miss ch3/ch4 mid-run. The stage that owns the leg is the
47
+ * authoritative signal instead (plan's F34 correction: "derive role from the
48
+ * stage that owns the leg"). Every other stage (stage1/stage2/debate-*)
49
+ * reuses roleFor (src/council/run-stages.js) keyed on modelInput — a
50
+ * model's seat/critic/lens identity is stable whether it's reviewing
51
+ * (stage1) or judging (stage2), and a repair/debate leg relaunches the SAME
52
+ * alias as its origin leg, so the identity carries through unchanged.
53
+ * @returns {string|null} null when modelInput is unknown (truthful — never a guess)
54
+ */
55
+ function legRole({ bench, critic, lenses, stageName, modelInput }) {
56
+ if (!modelInput) { return null; }
57
+ if (stageName === 'chair') { return 'chair'; }
58
+ // ⚠️ v4.4.1 LC-4: roleFor's LENS branch does `o.models.indexOf(alias)`, so a
59
+ // run.json carrying truthy `lenses` with a missing or non-array `bench` throws
60
+ // a TypeError. This function runs on every status poll, so one malformed
61
+ // run.json took out three surfaces at once: `amicus status`, the amicus_status
62
+ // MCP tool, and `amicus watch`. It was unreachable only because
63
+ // src/council/run.js:72 happens to write `bench` and `lenses` together — an
64
+ // argument that rests entirely on one writer never changing.
65
+ //
66
+ // A role we cannot compute is `null` — the module's existing, documented
67
+ // degradation (an em-dash in the Role column), never a guess and never a
68
+ // throw. The non-lens branch never touches `models`, so it stays exact.
69
+ //
70
+ // ⚠️ v4.4.1 A2: the guard above was ASYMMETRIC — it validated `bench` and took `lenses` on
71
+ // trust, but roleFor's lens branch indexes BOTH (`o.lenses[o.models.indexOf(alias)]`). Neither
72
+ // remaining shape throws, because slug() coerces with String(), so both produced a confident
73
+ // LIE instead of a crash: `lenses: 'security'` with `bench: ['gpt']` indexes the STRING and
74
+ // yields `lens:s`, and a `lenses` array shorter than `bench` yields `lens:undefined`. The
75
+ // length pairing is a real, enforced invariant, not an assumption — cli-handlers-council-run.js:170
76
+ // refuses `--lenses` unless it has exactly one lens per seat — so a run.json that violates it is
77
+ // malformed, and the honest answer for a malformed pairing is the same `null` (an em-dash in the
78
+ // Role column) that LC-4 established, never a guess.
79
+ if (lenses && (!Array.isArray(bench) || !Array.isArray(lenses) || lenses.length !== bench.length)) {
80
+ logger.debug('leg role unresolved: run.json lenses/bench are not a matched pair', {
81
+ modelInput, stageName,
82
+ benchType: bench === null ? 'null' : typeof bench,
83
+ lensesType: lenses === null ? 'null' : typeof lenses,
84
+ benchLength: Array.isArray(bench) ? bench.length : null,
85
+ lensesLength: Array.isArray(lenses) ? lenses.length : null,
86
+ });
87
+ return null;
88
+ }
89
+ return roleFor({ models: bench, critic, lenses }, modelInput);
90
+ }
91
+
92
+ /**
93
+ * One composed row for a leg, plus the ms-since-activity behind its `stalled`
94
+ * flag (or null when not stalled/not yet measurable) so the caller can roll
95
+ * several legs' staleness into one run-level summary without re-parsing
96
+ * `lastActivityAt` back into a timestamp.
97
+ * @param {string} project
98
+ * @param {string} legId
99
+ * @param {{bench: string[], critic: string|null, lenses: string[]|null, stageName: string}} runCtx
100
+ * @returns {{row: object, stalledMs: number|null}}
101
+ */
102
+ function buildLegRow(project, legId, runCtx) {
103
+ const { getSessionDir } = require('../session-manager');
104
+ const legDir = getSessionDir(project, legId);
105
+ let meta = {};
106
+ // ⚠️ v4.4.1 LC-9: the catch stays ALL-OR-NOTHING on purpose (Appendix A-9) —
107
+ // it mirrors the wave branch, and a half-parsed metadata object is worse than
108
+ // an empty one. What was wrong was the SILENCE: a corrupt metadata.json, an
109
+ // EACCES on the leg dir, and "the file doesn't exist yet" were indistinguishable
110
+ // and left no trace anywhere, so a leg with corrupt metadata rendered as a
111
+ // just-started leg forever. `code` is what separates them — ENOENT is the
112
+ // ordinary just-started case, anything else is a real fault. Logging only; the
113
+ // branching is untouched.
114
+ try { meta = JSON.parse(fs.readFileSync(path.join(legDir, 'metadata.json'), 'utf-8')); }
115
+ catch (metaErr) {
116
+ logger.debug('leg metadata.json unreadable — rendering the leg with base fields only', {
117
+ legId, code: metaErr.code || null, error: metaErr.message,
118
+ });
119
+ }
120
+ // Truthful null, never metadata.model as a fallback: showing the resolved
121
+ // id where the alias was expected is exactly the F36 bug (blind mode would
122
+ // leak the real model id instead of degrading to an em-dash).
123
+ const modelInput = meta.modelInput || null;
124
+ // LC-4: hoisted out of the object literal below so the guard inside legRole is
125
+ // the only thing standing between a malformed run.json and three live surfaces.
126
+ const role = legRole({ ...runCtx, modelInput });
127
+ const row = {
128
+ taskId: legId, model: meta.model || null, status: meta.status || 'unknown',
129
+ modelInput, role,
130
+ };
131
+ let stalledMs = null;
132
+ let p = null;
133
+ try {
134
+ p = readProgress(legDir);
135
+ row.messages = p.messages;
136
+ row.stage = p.stage;
137
+ row.latestPreview = p.latestPreview;
138
+ row.lastActivityAt = p.lastActivityAt;
139
+ row.stalled = row.status === 'running' && isStalled(p.lastActivityMs);
140
+ if (row.stalled) { stalledMs = p.lastActivityMs; }
141
+ } catch (progressErr) {
142
+ // ⚠️ v4.4.1 LC-9: same deal as the metadata catch above — all-or-nothing by
143
+ // design (A-9), silent by accident. readProgress swallows a malformed
144
+ // progress.json itself, so reaching here means the leg DIR could not be
145
+ // stat'd/read at all (EACCES, a vanished session dir) or readProgress threw
146
+ // on a shape it could not handle — neither of which is "hasn't started yet",
147
+ // and both of which previously left the row indistinguishable from one.
148
+ logger.debug('leg progress unreadable — rendering the leg with base fields only', {
149
+ legId, code: progressErr.code || null, error: progressErr.message,
150
+ });
151
+ }
152
+
153
+ // council review C3: this is a SEPARATE try from readProgress's above, on
154
+ // purpose. The old code wrapped both in one try, so a pricing-resolution
155
+ // failure (an unknown model, a corrupt catalog row) landed in the exact same
156
+ // catch as "progress.json doesn't exist yet" — an operator (or the workspace's
157
+ // cost-by-seat panel) could not tell "pricing lookup failed" from "hasn't
158
+ // billed yet"; both rendered as a permanently blank cost cell. `usageError`
159
+ // makes the failure mode truthful and distinguishable, additive to the row
160
+ // (N3's undefined-key discipline still holds: only set usage/usageError when
161
+ // there is something to say).
162
+ //
163
+ // v4.4 B3 (diagnosis §4/§7.3): for a TERMINAL leg, metadata.json's `usage`
164
+ // block wins over the progress.json snapshot. progress.json's usage is stamped
165
+ // ONLY on 'receiving' flushes — which fire on text/tool/reasoning GROWTH, i.e.
166
+ // always strictly before OpenCode's finalization stamp — so on real paid runs
167
+ // 31 of 35 legs ended with an all-zero snapshot while metadata.json held
168
+ // thousands of real tokens and a reported cost. Reading the snapshot for a
169
+ // finished leg made every completed seat look free in the live doc.
170
+ // headless.js now also writes a terminal 'complete' progress record carrying
171
+ // the settled usage, which fixes the DATA; this makes the READER prefer the
172
+ // authoritative source either way, including for every leg already on disk.
173
+ // A still-RUNNING leg keeps reading progress.json — metadata.usage does not
174
+ // exist until the leg finalizes, and read-time resolution is what keeps a live
175
+ // in-flight cost current (live-doc.js's stated design).
176
+ if (TERMINAL.has(row.status) && meta.usage && meta.usage.cost) {
177
+ row.usage = meta.usage;
178
+ } else if (p && p.usage) {
179
+ try {
180
+ const enriched = enrichLegUsage(row, p.usage);
181
+ if (enriched.usage) { row.usage = enriched.usage; }
182
+ } catch (err) {
183
+ row.usageError = err.message;
184
+ }
185
+ }
186
+ return { row, stalledMs };
187
+ }
188
+
189
+ /**
190
+ * Rows for every leg id, plus a run-level stall rollup. A council run fans
191
+ * legs across several parallel sub-waves at once (seat wave, chair chain,
192
+ * lens/critic solos), so — unlike the single-leg wave branch, which only
193
+ * ever flags its own row — a run-level banner needs one summary rather than
194
+ * making the caller inspect every row.
195
+ *
196
+ * The rollup means exactly what the workspace's dead-run banner claims ("no
197
+ * leg activity for Xm — the run may be dead"): the run is stalled only when
198
+ * EVERY still-running leg is stalled. A single stalled leg alongside another
199
+ * leg that is actively producing (message/cost climbing) must NOT set the
200
+ * flag — that was the old (max-based) bug, observed live: `glm` sat stalled
201
+ * for two minutes while `qwen-coder` was visibly working, and the banner
202
+ * fired anyway. A leg that has already finished (any non-'running' status)
203
+ * is terminal — it is excluded from the "every leg" population entirely: it
204
+ * can neither keep the run "healthy" by counting as active, nor drag it
205
+ * "dead" by counting as stalled. Per-leg `row.stalled` is untouched by this —
206
+ * it stays the accurate, per-row signal it already was.
207
+ *
208
+ * When the run genuinely is stalled, `stalledForSeconds` is the SHORTEST
209
+ * idle duration among the stalled running legs, not the longest: that is the
210
+ * honest answer to "how long has the whole run been quiet" — something was
211
+ * still happening as recently as the most-recently-active (but still
212
+ * over-threshold) leg's last activity.
213
+ * @param {string} project
214
+ * @param {string[]} legIds
215
+ * @param {{bench: string[], critic: string|null, lenses: string[]|null, stageName: string}} runCtx
216
+ * run.json's alias-valued fields (bench/critic/lenses) + the active stage's
217
+ * name, threaded through to legRole — this module never reads run.json itself.
218
+ * @returns {{rows: object[], stalled?: true, stalledForSeconds?: number}}
219
+ */
220
+ function buildLegRows(project, legIds, runCtx) {
221
+ const rows = [];
222
+ let runningCount = 0;
223
+ const stalledRunningMs = [];
224
+ for (const legId of legIds) {
225
+ const { row, stalledMs } = buildLegRow(project, legId, runCtx);
226
+ rows.push(row);
227
+ if (row.status === 'running') {
228
+ runningCount += 1;
229
+ if (stalledMs !== null) { stalledRunningMs.push(stalledMs); }
230
+ }
231
+ }
232
+ const out = { rows };
233
+ if (runningCount > 0 && stalledRunningMs.length === runningCount) {
234
+ out.stalled = true;
235
+ out.stalledForSeconds = Math.floor(Math.min(...stalledRunningMs) / 1000);
236
+ }
237
+ return out;
238
+ }
239
+
240
+ module.exports = { buildLegRows };
@@ -15,13 +15,48 @@
15
15
 
16
16
  const { resolveUsage, sumWaveUsage } = require('../utils/pricing');
17
17
 
18
- const TERMINAL = new Set(['complete', 'partial', 'error', 'crashed', 'aborted', 'timeout', 'idle-timeout']);
18
+ // ⚠️ v4.4.1 A1: 'timeout' AND 'timed-out' the codebase genuinely has two spellings for one
19
+ // state, written by two different producers, and this set has to cover both.
20
+ // 'timeout' — src/utils/result-schema.js:23 statusFromResult, i.e. the LEG/wave-document and
21
+ // `--json` run-document vocabulary. Correct, still emitted, stays.
22
+ // 'timed-out' — src/sidecar/session-finalize.js:21 resolveTerminalState, i.e. what actually
23
+ // lands in a session's metadata.json `status` and (since LC-3) progress.json's
24
+ // terminal stage. It was MISSING here, and this set is the one every observability
25
+ // reader consults, so three real consequences followed: `amicus watch <taskId>` on
26
+ // a timed-out single session never exited (watch-render.js:138 polls until
27
+ // TERMINAL.has(doc.status), and amicus_status stamps metadata.status straight onto
28
+ // the doc — mcp-server.js:687); a timed-out leg skipped the "prefer metadata.usage
29
+ // over the stale progress.json snapshot" branch in council-legs.js:162 and reported
30
+ // an under-counted cost; and markLive kept stamping view:'live' on a finished
31
+ // single-session doc.
32
+ // NOTE this is deliberately NOT the same list as src/utils/result-schema.js:13 TERMINAL_STATUSES
33
+ // (the leg set, no 'partial'). Two mirrors of THIS list exist — src/workspace/run-detail.js:26 and
34
+ // electron/workspace-ui/live-model.js:14 — byte-identical, held by drift pins. Edit all three.
35
+ const TERMINAL = new Set(['complete', 'partial', 'error', 'crashed', 'aborted', 'timeout', 'timed-out', 'idle-timeout']);
19
36
 
20
- /** Attach read-time-resolved usage to a leg from its raw progress usage. */
37
+ /**
38
+ * Attach read-time-resolved usage to a leg from its raw progress usage.
39
+ *
40
+ * v4.4.1 CA-1: the terminal progress record carries the leg's enumerated CHILD
41
+ * (subagent) session spend as `usage.subtree` / `usage.subtreeUnknown`
42
+ * (src/headless.js). Those must be forwarded, not dropped: the live workspace
43
+ * and `amicus watch` read progress.json directly, so silently keeping only
44
+ * {tokens, cost} here would make the GUI's cost-by-seat and its wave rollup
45
+ * disagree with run.json by exactly the child-session amount — reintroducing
46
+ * the under-report one surface down from where it was fixed.
47
+ */
21
48
  function enrichLegUsage(leg, progressUsage) {
22
49
  if (!progressUsage || !progressUsage.tokens) { return leg; }
23
- const resolved = resolveUsage({ model: leg.model, usageTotals: progressUsage });
24
- return { ...leg, usage: { tokens: resolved.tokens, cost: resolved.cost } };
50
+ const resolved = resolveUsage({
51
+ model: leg.model,
52
+ usageTotals: progressUsage,
53
+ subtree: progressUsage.subtree,
54
+ subtreeUnknown: progressUsage.subtreeUnknown,
55
+ });
56
+ const usage = { tokens: resolved.tokens, cost: resolved.cost };
57
+ if (resolved.subtree) { usage.subtree = resolved.subtree; }
58
+ if (resolved.subtreeUnknown) { usage.subtreeUnknown = true; }
59
+ return { ...leg, usage };
25
60
  }
26
61
 
27
62
  /** Stamp view:'live' on a non-terminal composed doc; no-op when terminal. */
@@ -29,7 +29,10 @@ const legCost = (leg) => (leg.usage && leg.usage.cost ? formatCost(leg.usage.cos
29
29
  const legTokens = (leg) => (leg.usage && leg.usage.tokens ? `${leg.usage.tokens.input || 0}/${leg.usage.tokens.output || 0}` : DASH);
30
30
  const truncate = (s, n) => { const t = String(s || ''); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
31
31
 
32
- const STAGE_MARK = { complete: '✓', running: '▶', pending: '·' };
32
+ // `partial` (v4.4.1 F6): a stage that FINISHED but lost seats. Without its own
33
+ // mark it fell through to `pending`, so a degraded Stage 1 rendered as if it had
34
+ // not started — the opposite of reporting loudly.
35
+ const STAGE_MARK = { complete: '✓', running: '▶', partial: '⚠', pending: '·' };
33
36
 
34
37
  /** The in-place refresh block for a composed wave/council/solo doc. */
35
38
  function renderTable(doc, width = 100) {
@@ -93,6 +96,25 @@ function emitJsonChange(doc, prevText) {
93
96
  * @returns {Promise<number>} exit code
94
97
  */
95
98
  async function runWatchLoop(target, args, project, deps = {}) {
99
+ // Pointer-containment fence, defence in depth. cli-handlers-watch.js's
100
+ // resolveWatchTarget already refuses a council pointer whose runDir escapes
101
+ // the project, but this loop is exported, takes `target` from its caller, and
102
+ // opens events.jsonl straight out of target.runDir below — so it re-checks
103
+ // rather than trusting the hand-off. Reuses the shared fence
104
+ // (src/utils/path-fence.js) and reports through the SAME failJson
105
+ // BAD_SESSION envelope handleWatch uses for an unresolvable id, so a --json
106
+ // caller still gets exactly one typed error doc.
107
+ if (target.kind === 'council') {
108
+ const { containsOnDisk } = require('../utils/path-fence');
109
+ if (!containsOnDisk(project, target.runDir)) {
110
+ const { failJson, ERROR_CODES } = require('../utils/error-doc');
111
+ return failJson(!!args.json, {
112
+ code: ERROR_CODES.BAD_SESSION,
113
+ message: `watch: run directory for '${target.id}' resolves outside project ${project}`,
114
+ hint: 'Pass --project if the run was launched elsewhere.',
115
+ });
116
+ }
117
+ }
96
118
  const intervalSec = Math.max(0.5, Number(args.interval) || 2);
97
119
  const statusFn = deps.statusFn || ((id, p) => require('../mcp-server').handlers.amicus_status({ taskId: id }, p));
98
120
  const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
@@ -328,15 +328,27 @@ async function createChildSession(client, parentId) {
328
328
  }
329
329
 
330
330
  /**
331
- * Get child sessions for a parent session
331
+ * Get child (subagent) sessions for a parent session.
332
+ *
333
+ * ⚠️ v4.4.1 LC-7: this was the ONE per-session call that did not thread
334
+ * `directoryQuery(directory)`, unlike getMessages / createSession /
335
+ * abortSession. It went unnoticed because nothing called it — on a SHARED
336
+ * server (one server, many projects) an un-scoped call is the exact
337
+ * "session not found" failure mode issue #47 fixed everywhere else, and it
338
+ * would have broken child-session cost attribution on precisely the
339
+ * configuration that makes attribution matter. Fixed with CA-1, its first
340
+ * consumer (src/sidecar/child-sessions.js).
332
341
  *
333
342
  * @param {import('@opencode-ai/sdk').OpencodeClient} client - SDK client
334
343
  * @param {string} parentId - Parent session ID
344
+ * @param {string} [directory] - Optional project directory to scope the call to.
345
+ * Omitting it keeps the call byte-for-byte identical to before.
335
346
  * @returns {Promise<Array>} Array of child sessions
336
347
  */
337
- async function getChildren(client, parentId) {
348
+ async function getChildren(client, parentId, directory) {
338
349
  const result = await client.session.children({
339
- path: { id: parentId }
350
+ path: { id: parentId },
351
+ ...directoryQuery(directory)
340
352
  });
341
353
 
342
354
  return result.data || [];