amicus 4.2.1 → 4.4.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 (82) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +46 -1
  3. package/README.md +8 -4
  4. package/bin/amicus.js +5 -0
  5. package/electron/ipc-workspace.js +283 -0
  6. package/electron/main.js +27 -0
  7. package/electron/preload-workspace.js +40 -0
  8. package/electron/workspace-shell.js +85 -0
  9. package/electron/workspace-ui/index.html +111 -0
  10. package/electron/workspace-ui/live-model.js +101 -0
  11. package/electron/workspace-ui/md-lite.js +119 -0
  12. package/electron/workspace-ui/workspace-app.js +240 -0
  13. package/electron/workspace-ui/workspace-matrix.js +212 -0
  14. package/electron/workspace-ui/workspace-panels.js +226 -0
  15. package/electron/workspace-ui/workspace-render.js +271 -0
  16. package/electron/workspace-ui/workspace-verbs.js +247 -0
  17. package/electron/workspace-ui/workspace.css +172 -0
  18. package/package.json +1 -1
  19. package/schemas/council-run-live.schema.json +57 -0
  20. package/schemas/council-run.schema.json +14 -0
  21. package/schemas/event.schema.json +15 -0
  22. package/schemas/progress.schema.json +37 -0
  23. package/schemas/run-live.schema.json +15 -0
  24. package/schemas/spend.schema.json +26 -1
  25. package/schemas/wave-live.schema.json +15 -0
  26. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  27. package/src/cli-handlers-council-run.js +86 -8
  28. package/src/cli-handlers-run.js +26 -0
  29. package/src/cli-handlers-spend.js +94 -32
  30. package/src/cli-handlers-watch.js +116 -0
  31. package/src/cli.js +58 -1
  32. package/src/council/briefings.js +35 -2
  33. package/src/council/run-budget.js +224 -0
  34. package/src/council/run-chair.js +10 -2
  35. package/src/council/run-debate.js +5 -1
  36. package/src/council/run-launch.js +58 -7
  37. package/src/council/run-stages.js +30 -3
  38. package/src/council/run.js +44 -15
  39. package/src/headless.js +356 -15
  40. package/src/mcp-council-awareness.js +98 -3
  41. package/src/mcp-council-run.js +28 -4
  42. package/src/mcp-notify.js +54 -0
  43. package/src/mcp-server.js +51 -1
  44. package/src/mcp-spend.js +125 -0
  45. package/src/mcp-tools.js +39 -0
  46. package/src/mcp-wait.js +28 -2
  47. package/src/observe/council-legs.js +183 -0
  48. package/src/observe/events.js +156 -0
  49. package/src/observe/follow.js +26 -0
  50. package/src/observe/live-doc.js +56 -0
  51. package/src/observe/on-complete.js +117 -0
  52. package/src/observe/watch-render.js +168 -0
  53. package/src/opencode-client.js +15 -3
  54. package/src/sidecar/child-sessions.js +198 -0
  55. package/src/sidecar/continue.js +32 -0
  56. package/src/sidecar/conversation-mirror.js +111 -37
  57. package/src/sidecar/fallback-chains.js +65 -0
  58. package/src/sidecar/fanout-budget.js +71 -0
  59. package/src/sidecar/fanout-leg-fallback.js +189 -0
  60. package/src/sidecar/fanout-leg.js +81 -27
  61. package/src/sidecar/fanout-retry.js +208 -0
  62. package/src/sidecar/fanout-validate.js +42 -4
  63. package/src/sidecar/fanout.js +54 -41
  64. package/src/sidecar/progress.js +5 -0
  65. package/src/sidecar/resume.js +12 -0
  66. package/src/sidecar/start.js +13 -1
  67. package/src/sidecar/tool-part.js +196 -0
  68. package/src/sidecar/workspace-window.js +62 -0
  69. package/src/spend-query.js +119 -0
  70. package/src/utils/env-num.js +42 -0
  71. package/src/utils/error-classify.js +31 -0
  72. package/src/utils/model-tiers.js +1 -1
  73. package/src/utils/path-fence.js +82 -0
  74. package/src/utils/pricing.js +98 -9
  75. package/src/utils/spend-ledger.js +24 -1
  76. package/src/workspace/artifact-guard.js +187 -0
  77. package/src/workspace/blind-mode.js +32 -0
  78. package/src/workspace/fold-format.js +95 -0
  79. package/src/workspace/live-normalize.js +156 -0
  80. package/src/workspace/matrix-model.js +94 -0
  81. package/src/workspace/run-detail.js +223 -0
  82. package/src/workspace/run-scan.js +148 -0
package/src/headless.js CHANGED
@@ -13,8 +13,14 @@ const { ensurePortAvailable } = require('./utils/server-setup');
13
13
  const { mapAgentToOpenCode } = require('./utils/agent-mapping');
14
14
  const { writeProgress } = require('./sidecar/progress');
15
15
  const { writeFileAtomic } = require('./utils/atomic-write');
16
- const { createMirrorState, mirrorMessages, logMessage, getPendingToolCalls } = require('./sidecar/conversation-mirror');
16
+ const { createMirrorState, mirrorMessages, logMessage, getPendingToolCalls,
17
+ getLiveToolCalls, mirrorUsageOnly, allAssistantUsagePresent } = require('./sidecar/conversation-mirror');
17
18
  const { buildFoldMarker, trailingFoldMarkerRegex, generateFoldNonce } = require('./utils/fold-marker');
19
+ // v4.4 (cost-council finding 3): `Number(process.env.X) || DEFAULT` cannot express
20
+ // an explicit `0`, and `0` is the DOCUMENTED disable switch for every knob below
21
+ // that uses this helper. See src/utils/env-num.js for why the older `||` knobs are
22
+ // deliberately left alone.
23
+ const { envNumber } = require('./utils/env-num');
18
24
 
19
25
  /**
20
26
  * Fold marker that the agent outputs when done.
@@ -73,6 +79,51 @@ const STABLE_IDLE_POLLS = Number(process.env.AMICUS_STABLE_IDLE_POLLS) || 30;
73
79
  const POLL_CALL_TIMEOUT_MS = Number(process.env.AMICUS_POLL_CALL_TIMEOUT_MS) || 30000; // per getMessages call (used by a later task)
74
80
  const MAX_CONSECUTIVE_POLL_FAILURES = Number(process.env.AMICUS_MAX_CONSECUTIVE_POLL_FAILURES) || 15; // ≈30s at 2s polls
75
81
  const TOOL_CALL_STALL_MS = Number(process.env.AMICUS_TOOL_CALL_STALL_MS) || 180000; // B53: wedged tool call w/ no progress
82
+ /**
83
+ * v4.4 B1 — bounded post-loop usage reconciliation. The fold-marker (:~540) and
84
+ * SDK-idle (:~568) fast paths break WITHOUT requiring `info.time.completed`, but
85
+ * OpenCode stamps `info.tokens`/`info.cost` at message finalization — so those
86
+ * exits can win the race against the provider's usage payload and report a leg
87
+ * as free. Measured on real paid legs: $0.00759441096 lost by 155 ms and
88
+ * $0.00690565716 by 29 ms. 3 × 400 ms bounds the worst case at ~1.2 s of extra
89
+ * wall time on a leg that already finished, and the loop breaks early the moment
90
+ * every assistant message carries usage (the common case: one extra read).
91
+ * Set AMICUS_USAGE_SETTLE_POLLS to 0 to disable the re-poll entirely.
92
+ */
93
+ const USAGE_SETTLE_POLLS = envNumber('AMICUS_USAGE_SETTLE_POLLS', 3);
94
+ const USAGE_SETTLE_INTERVAL_MS = envNumber('AMICUS_USAGE_SETTLE_INTERVAL_MS', 400);
95
+ /** Deliberately much tighter than POLL_CALL_TIMEOUT_MS: the leg is already
96
+ * finished, so a hung settle read must not add 30 s × 3 to a run's wall time.
97
+ * 0 means "no extra timer" (withTimeout passes the promise through untouched). */
98
+ const USAGE_SETTLE_CALL_TIMEOUT_MS = envNumber('AMICUS_USAGE_SETTLE_CALL_TIMEOUT_MS', 5000);
99
+ /**
100
+ * v4.4 B4 part 1 — how long a completion signal may be DEFERRED while a tool
101
+ * call has not yet reached a terminal `state.status`.
102
+ *
103
+ * THE DEFECT THIS BOUNDS. `council-wsgate02/wsgate02-s1-3` was declared
104
+ * `complete` by the STABLE_IDLE_POLLS gate at 04:36:09.700 on **166 characters**
105
+ * of reasoning preamble, while its `task` tool call ran until 04:38:19.061 —
106
+ * 129 s later — and its session went on to bill $0.14279 of parent spend plus a
107
+ * $0.47105 child session. 166 characters were adjudicated as a peer review.
108
+ *
109
+ * WHY IT MUST BE BOUNDED. 9 of the 1,307 tool parts persisted in this machine's
110
+ * OpenCode database are stuck non-terminal forever: `time_updated` within
111
+ * milliseconds of `time_created`, all from killed sessions that never wrote a
112
+ * terminal status. A stale `running` can therefore outlive everything, so an
113
+ * unbounded wait is not an option.
114
+ *
115
+ * WHY 5 MINUTES. The measured duration of the real subagent call that exposed
116
+ * this is **190.6 s** (`task`, 04:35:08.427 → 04:38:19.061) — already longer
117
+ * than B53's 180 s TOOL_CALL_STALL_MS, so anything at that scale would kill a
118
+ * healthy `task` leg 10 s short of its answer. 300 s clears the measured case
119
+ * with margin and still lands far inside the 15-minute default `--timeout`.
120
+ * Set to 0 to disable the deferral entirely (pre-v4.4 behaviour).
121
+ *
122
+ * ON EXCEEDING IT the leg COMPLETES anyway — never fails — carrying
123
+ * `toolSettleTimedOut` on the result, the terminal progress record and the
124
+ * error log channel. Owner's standing ruling: fail LOUD, not fail CLOSED.
125
+ */
126
+ const TOOL_SETTLE_GRACE_MS = envNumber('AMICUS_TOOL_SETTLE_GRACE_MS', 300000);
76
127
 
77
128
  /**
78
129
  * Race a promise against a timeout. Returns the promise's result, or rejects with
@@ -397,6 +448,15 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
397
448
  const pollCallTimeoutMs = options.pollCallTimeoutMs || POLL_CALL_TIMEOUT_MS;
398
449
  const maxConsecutivePollFailures = options.maxConsecutivePollFailures || MAX_CONSECUTIVE_POLL_FAILURES;
399
450
  const toolCallStallMs = options.toolCallStallMs || TOOL_CALL_STALL_MS;
451
+ // `=== undefined` rather than `||`: 0 is a meaningful value (disable the
452
+ // v4.4 B1 settle re-poll entirely) and must survive injection.
453
+ const usageSettlePolls = options.usageSettlePolls === undefined
454
+ ? USAGE_SETTLE_POLLS : options.usageSettlePolls;
455
+ const usageSettleIntervalMs = options.usageSettleIntervalMs === undefined
456
+ ? USAGE_SETTLE_INTERVAL_MS : options.usageSettleIntervalMs;
457
+ // `=== undefined` rather than `||`: 0 is meaningful (disable the deferral).
458
+ const toolSettleGraceMs = options.toolSettleGraceMs === undefined
459
+ ? TOOL_SETTLE_GRACE_MS : options.toolSettleGraceMs;
400
460
  let consecutivePollFailures = 0;
401
461
  let pollFailureBail = false;
402
462
  let lastAssistantMsgId = null;
@@ -408,6 +468,61 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
408
468
  let lastReasoningLength = 0; // B53: track reasoning-output growth to detect thinking
409
469
  let lastProgressAt = Date.now(); // B53: last poll where `progressed` was true
410
470
  let toolStalled = false; // B53: distinct from completed/timedOut/aborted — see resolveTerminalState
471
+ let lastSettledToolCount = 0; // B4: tool calls observed reaching a terminal status
472
+
473
+ // ---- v4.4 B4 part 1: the tool-settle deferral -----------------------------
474
+ // Recomputed once per poll (see the loop body) so every completion gate in a
475
+ // single poll reads ONE consistent answer.
476
+ let liveTools = []; // POSITIVELY 'pending'/'running' — gates completion
477
+ let pendingTools = []; // not-yet-terminal incl. unknown shape — feeds B53
478
+ let toolSettleDeferredSince = null; // ms timestamp of the first deferral, or null
479
+ let toolSettleTimedOut = false; // the grace ceiling was exceeded
480
+ let unsettledAtCeiling = []; // what was still live when it was exceeded
481
+
482
+ /**
483
+ * Should this poll's completion signal be DEFERRED because a tool call has
484
+ * not reached a terminal `state.status`?
485
+ *
486
+ * Keyed on the REAL SDK shape (src/sidecar/tool-part.js): terminal is
487
+ * `state.status === 'completed' | 'error'`. It is deliberately NOT keyed on a
488
+ * `tool_result` part — OpenCode emits no such part type (36 `tool_use` records
489
+ * and 0 `tool_result` records across the 35 recorded legs), so the diagnosis's
490
+ * proposed `pendingToolCalls` gate would have hung every tool-using leg.
491
+ *
492
+ * It reads `getLiveToolCalls`, NOT `getPendingToolCalls`: a leg is only ever
493
+ * held open on POSITIVE evidence that OpenCode is still working ('pending' /
494
+ * 'running'). A tool part carrying no `state` at all is unknown, not live, and
495
+ * must not defer anything — deferring on an absence of evidence is exactly how
496
+ * this gate would hang. B53 still owns that no-evidence case.
497
+ *
498
+ * @param {string} exitPath which completion gate is asking (for the logs)
499
+ * @returns {boolean} true = keep polling; false = complete now
500
+ */
501
+ const deferForUnsettledTools = (exitPath) => {
502
+ if (toolSettleTimedOut) { return false; } // ceiling blown — never defer again
503
+ if (!(toolSettleGraceMs > 0)) { return false; } // 0 = disabled (escape hatch)
504
+ if (liveTools.length === 0) { return false; }
505
+ if (toolSettleDeferredSince === null) {
506
+ toolSettleDeferredSince = Date.now();
507
+ logger.info('Deferring leg completion — tool call(s) still executing', {
508
+ taskId, exitPath, live: liveTools.length,
509
+ tools: liveTools.map(t => `${t.name}:${t.status}`).join(','), toolSettleGraceMs,
510
+ });
511
+ return true;
512
+ }
513
+ if ((Date.now() - toolSettleDeferredSince) <= toolSettleGraceMs) { return true; }
514
+ // The ceiling. Complete the leg (keep whatever output it produced) and make
515
+ // the uncertainty impossible to miss — never fail it closed.
516
+ toolSettleTimedOut = true;
517
+ unsettledAtCeiling = liveTools.slice();
518
+ logger.error('Tool call(s) did not settle within the grace window — completing leg '
519
+ + 'anyway; its OpenCode session may STILL be working and BILLING', {
520
+ taskId, sessionId, exitPath, toolSettleGraceMs,
521
+ unsettled: unsettledAtCeiling.length,
522
+ tools: unsettledAtCeiling.map(t => `${t.name}@${t.firstSeenAt}`).join(','),
523
+ });
524
+ return false;
525
+ };
411
526
 
412
527
  while (!completed && (Date.now() - startTime) < timeoutMs) {
413
528
  watchdog.touch();
@@ -448,9 +563,25 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
448
563
 
449
564
  const mr = mirrorMessages(messages, mirror);
450
565
  mr.appendLines.forEach(line => logMessage(conversationPath, line));
451
- mr.progressUpdates.forEach(p => writeProgress(sessionDir, p.stage, p.extra));
566
+ // Surface A (spec §4.1): stamp raw usage on each 'receiving' flush from the
567
+ // PERSISTENT mirror.usageByMsg Map (accumulated across polls) — NOT
568
+ // mr.usageByMsg, which doesn't exist on mirrorMessages()'s per-poll delta.
569
+ // Cost resolution happens at read time (Task 9); the writer stays cheap.
570
+ const { sumPerMessageUsage } = require('./utils/pricing');
571
+ mr.progressUpdates.forEach(p => writeProgress(
572
+ sessionDir, p.stage,
573
+ p.stage === 'receiving' ? { ...p.extra, usage: sumPerMessageUsage(mirror.usageByMsg) } : p.extra,
574
+ ));
452
575
  const currentAssistantMsgId = mr.currentAssistantMsgId;
453
576
  const assistantFinished = mr.assistantFinished;
577
+ // v4.4 B4 part 1: evaluate tool liveness ONCE per poll, before any
578
+ // completion gate reads it. Clearing the deferral here (rather than
579
+ // inside deferForUnsettledTools) matters: the gates only run when a
580
+ // completion signal fires, so a leg that resumes working after a
581
+ // deferral would otherwise keep B53 suppressed on a stale timestamp.
582
+ pendingTools = getPendingToolCalls(mirror);
583
+ liveTools = getLiveToolCalls(mirror);
584
+ if (liveTools.length === 0) { toolSettleDeferredSince = null; }
454
585
  if (mr.sessionError) {
455
586
  sessionError = mr.sessionError;
456
587
  logger.error('Session error detected in assistant message', { sessionId, message: mr.sessionError });
@@ -469,7 +600,11 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
469
600
  // marker on its own line mid-output (echoing a prior sidecar, these
470
601
  // instructions, or scraped content) — only the exact nonced marker,
471
602
  // with nothing but blank lines after it, is a completion signal.
472
- if (findTrailingFoldMarker(mirror.output, foldNonce) !== -1) {
603
+ // v4.4 B4: a fold marker WITHOUT info.time.completed means OpenCode has
604
+ // not finalized the message, so a tool call may still be live and billing
605
+ // (this is the same window B1's usage race lives in). Defer, bounded.
606
+ if (findTrailingFoldMarker(mirror.output, foldNonce) !== -1
607
+ && !deferForUnsettledTools('fold-marker')) {
473
608
  completed = true;
474
609
  break;
475
610
  }
@@ -506,7 +641,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
506
641
  'getSessionStatus'
507
642
  );
508
643
  const s = (statusData && statusData.type) ? statusData : (statusData && statusData[sessionId]);
509
- if (s && s.type === 'idle') {
644
+ if (s && s.type === 'idle' && !deferForUnsettledTools('sdk-idle')) {
510
645
  logger.debug('Session reported idle by SDK — completing', { sessionId });
511
646
  completed = true;
512
647
  break;
@@ -535,9 +670,14 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
535
670
  // the stall clock resets instead of falsely firing "Tool call stalled".
536
671
  const reasoningActivity = mirror.reasoningOutput.length > lastReasoningLength;
537
672
  lastReasoningLength = mirror.reasoningOutput.length;
673
+ // v4.4 B4: a tool call REACHING a terminal status is real activity. Before
674
+ // the shape fix this could never be observed (pending never cleared), so a
675
+ // multi-tool leg's stall clock only reset on text growth.
676
+ const settleActivity = mirror.settledToolCallIds.size > lastSettledToolCount;
677
+ lastSettledToolCount = mirror.settledToolCallIds.size;
538
678
 
539
679
  const progressed = outputGrew || toolActivity || resultActivity || messageActivity
540
- || newAssistant || reasoningActivity;
680
+ || newAssistant || reasoningActivity || settleActivity;
541
681
  if (progressed) { lastProgressAt = Date.now(); }
542
682
 
543
683
  // B53: a wedged tool call (tool_use emitted, result never arrives) otherwise
@@ -547,9 +687,19 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
547
687
  // (text/tool/result/message/new-assistant) has been observed for the stall
548
688
  // window — this cannot false-positive during active streaming (progress
549
689
  // resets the clock every poll) and cannot fire without a wedged tool.
550
- const pendingToolCalls = getPendingToolCalls(mirror);
551
- if (pendingToolCalls.length > 0 && (Date.now() - lastProgressAt) > toolCallStallMs) {
552
- const stalled = pendingToolCalls[0];
690
+ //
691
+ // v4.4 B4: SKIPPED while a tool-settle deferral is active. B53 was written
692
+ // against `pendingToolCalls`, which could never clear (no tool_result part
693
+ // exists), so its 180 s window was never calibrated against real tool
694
+ // durations — the measured `task` call that exposed this defect ran 190.6 s,
695
+ // so B53 would kill a healthy subagent leg 10 s short of its answer, and
696
+ // kill it CLOSED. Once a completion signal has fired, the bounded settle
697
+ // grace owns that decision and ends in a LOUD completion instead. B53's
698
+ // actual target — a wedge with NO output, where the idle gate never engages
699
+ // and therefore no deferral is ever active — is untouched.
700
+ if (pendingTools.length > 0 && toolSettleDeferredSince === null
701
+ && (Date.now() - lastProgressAt) > toolCallStallMs) {
702
+ const stalled = pendingTools[0];
553
703
  const pendingSeconds = Math.round((Date.now() - Date.parse(stalled.firstSeenAt)) / 1000);
554
704
  sessionError = `Tool call stalled: ${stalled.name} pending ${pendingSeconds}s with no result or output`;
555
705
  logger.error('Tool call stalled — no progress within threshold', {
@@ -572,7 +722,20 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
572
722
  if (currentAssistantMsgId !== null && mirror.output.length > 0) {
573
723
  stablePolls++;
574
724
  const threshold = assistantFinished ? stableFinishedPolls : stableIdlePolls;
575
- if (stablePolls >= threshold) {
725
+ // v4.4 B4 part 1 — THE MEASURED DEFECT SITE. This is the gate that
726
+ // declared `wsgate02-s1-3` complete on 166 characters of preamble 129 s
727
+ // before its `task` tool finished. Deferred (bounded) when a tool call
728
+ // is still live.
729
+ //
730
+ // The `assistantFinished` branch is deliberately NOT deferred:
731
+ // OpenCode finalizes an assistant message only AFTER its tool calls
732
+ // end, so `time.completed` structurally implies settled. VERIFIED on
733
+ // the defect leg itself — task end 04:38:19.061, message
734
+ // time.completed 04:38:19.301 — and on both recorded multi-tool legs,
735
+ // whose last tool ended 62.3 s and 14.8 s before the leg completed.
736
+ // Gating it would add pure hang risk for no truth gained.
737
+ if (stablePolls >= threshold
738
+ && !(!assistantFinished && deferForUnsettledTools('stable-idle'))) {
576
739
  logger.debug('Session appears complete (idle)', { stablePolls, assistantFinished });
577
740
  completed = true;
578
741
  break;
@@ -636,16 +799,132 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
636
799
 
637
800
  watchdog.cancel();
638
801
  if (uninstallSignals) { uninstallSignals(); }
639
- if (!externalServer) { await server.close(); }
640
802
 
641
- // Log summary of tool calls for debugging
803
+ // ---- v4.4 B1: bounded post-loop usage reconciliation ----------------------
804
+ // MUST run before server.close() — the client needs a live server — and MUST
805
+ // NOT re-mirror text: mirrorMessages() would append the already-captured
806
+ // assistant output to conversation.jsonl a second time, so this uses the
807
+ // usage-only pass (src/sidecar/conversation-mirror.js mirrorUsageOnly).
808
+ //
809
+ // Strictly best-effort: every failure mode leaves the leg's completion
810
+ // verdict, summary and error exactly as the loop decided them. B2 is the
811
+ // safety net underneath — when the re-poll still sees nothing, the leg
812
+ // resolves to `unknown`, never a fabricated $0.
813
+ //
814
+ // Skipped when there is nothing to settle: an aborted leg (the caller pulled
815
+ // the plug), a leg that bailed on consecutive poll failures (the server is
816
+ // gone — three more reads would only burn the settle timeout), and a leg that
817
+ // errored with no output at all (no assistant message was ever billed).
818
+ // Deliberately NOT restricted to `completed`: a timed-out or tool-stalled leg
819
+ // spent real money too, and its usage is just as worth capturing.
820
+ const canSettleUsage = !aborted && !pollFailureBail && !(sessionError && !mirror.output);
821
+ if (canSettleUsage && usageSettlePolls > 0) {
822
+ for (let i = 0; i < usageSettlePolls; i++) {
823
+ // v4.4 (cost-council finding 2): the boundary covers the WHOLE loop body,
824
+ // not just the network read. It previously wrapped only `withTimeout(...)`,
825
+ // leaving `mirrorUsageOnly` and `allAssistantUsagePresent` — which inspect
826
+ // an untrusted snapshot shape — outside it. A throw there escaped
827
+ // runHeadless entirely and DISCARDED a leg whose answer was already
828
+ // captured and already paid for: the most expensive possible outcome for
829
+ // a path whose entire job is a nice-to-have usage top-up. "Best-effort"
830
+ // has to mean the effort, not just its first statement.
831
+ let done = false;
832
+ try {
833
+ const settled = await withTimeout(
834
+ getMessages(client, sessionId, ...dirArgs),
835
+ Math.min(pollCallTimeoutMs, USAGE_SETTLE_CALL_TIMEOUT_MS),
836
+ 'getMessages(usage-settle)',
837
+ );
838
+ mirrorUsageOnly(settled, mirror);
839
+ done = allAssistantUsagePresent(settled);
840
+ if (!done && i < usageSettlePolls - 1) {
841
+ await new Promise(resolve => setTimeout(resolve, usageSettleIntervalMs));
842
+ }
843
+ } catch (settleErr) {
844
+ // Same disposition as the pre-existing network-failure branch: stop
845
+ // settling, keep every dollar already mirrored, and leave the loop's
846
+ // completion verdict, summary and error untouched. B2 remains the net
847
+ // underneath — no observation resolves to `unknown`, never a fake $0.
848
+ logger.debug('usage-settle re-poll failed (best-effort, leg unaffected)', {
849
+ taskId, attempt: i + 1, error: settleErr.message,
850
+ });
851
+ break;
852
+ }
853
+ if (done) { break; }
854
+ }
855
+ }
856
+
857
+ // Log summary of tool calls for debugging.
858
+ // v4.4 B4: this used to filter on `t.name === 'Task'` and was DEAD twice over —
859
+ // the mirror read `part.name` (the real shape has `part.tool`) so every name
860
+ // was undefined, and OpenCode's tool is named `task` in lowercase anyway.
861
+ const { isSubagentToolCall } = require('./sidecar/tool-part');
862
+ const subagentToolCalls = mirror.toolCalls.filter(isSubagentToolCall);
863
+
864
+ // ---- v4.4.1 CA-1: enumerate CHILD (subagent) session spend ---------------
865
+ // MUST run before server.close() — the walk needs a live server. A `task`
866
+ // call spawns a child OpenCode session that OpenCode bills separately and
867
+ // does NOT roll into this session's cost; amicus never looked, so $0.492506
868
+ // across the four recorded paid runs was invisible to every total the
869
+ // product prints. Safe to do at finalization only because dcb0792 stopped a
870
+ // `task` part going terminal while its child session is still live — before
871
+ // that, walking here would have captured a partial child cost and traded a
872
+ // silent zero for a silent floor.
873
+ //
874
+ // Run for EVERY leg, not only ones whose tool calls looked like `task`: the
875
+ // name-string proxy (src/sidecar/tool-part.js) was verified 1:1 on a
876
+ // 37-session corpus and nowhere else, so a child created by some other
877
+ // mechanism would be a silent under-count wearing a costExact badge — the
878
+ // exact defect the flag exists to kill. Skipped only when there is nothing
879
+ // to ask (same predicate as the usage settle: the server is gone, or the
880
+ // caller pulled the plug), in which case the leg falls back to the proxy and
881
+ // honestly reports its subtree as unknown.
882
+ let subtree = null;
883
+ if (canSettleUsage) {
884
+ try {
885
+ const { collectSubtreeUsage, subtreeIsUnknown } = require('./sidecar/child-sessions');
886
+ const walked = await collectSubtreeUsage(client, sessionId, {
887
+ directory,
888
+ callTimeoutMs: Math.min(pollCallTimeoutMs, USAGE_SETTLE_CALL_TIMEOUT_MS),
889
+ logger,
890
+ });
891
+ subtree = {
892
+ sessions: walked.sessions.length,
893
+ tokens: walked.tokens,
894
+ costReported: walked.costReported,
895
+ // The honesty verdict is decided HERE, where both observations live —
896
+ // the walk's own completeness and the `task`-call evidence. See
897
+ // subtreeIsUnknown for why a failed walk with no evidence of a
898
+ // subagent must NOT flag (an older server would otherwise mark every
899
+ // leg of every run inexact forever).
900
+ unknown: subtreeIsUnknown({
901
+ walkComplete: walked.complete,
902
+ sessionsFound: walked.sessions.length,
903
+ subagentCalls: subagentToolCalls.length,
904
+ }),
905
+ };
906
+ if (walked.sessions.length > 0) {
907
+ logger.info('Child session spend attributed to this leg', {
908
+ taskId, sessions: walked.sessions.map((s) => s.id),
909
+ costReported: walked.costReported, subtreeUnknown: subtree.unknown,
910
+ });
911
+ }
912
+ } catch (subtreeErr) {
913
+ // Cannot happen by construction (the collector swallows its own
914
+ // failures), but a throw here must never cost a leg its answer.
915
+ subtree = null;
916
+ logger.debug('subtree enumeration failed (best-effort)', { taskId, error: subtreeErr.message });
917
+ }
918
+ }
919
+
920
+ if (!externalServer) { await server.close(); }
642
921
  if (mirror.toolCalls.length > 0) {
643
922
  logger.info('Tool calls summary', {
644
923
  totalToolCalls: mirror.toolCalls.length,
645
- taskToolCalls: mirror.toolCalls.filter(t => t.name === 'Task').length,
646
- subagentTypes: mirror.toolCalls
647
- .filter(t => t.name === 'Task' && t.input?.subagent_type)
648
- .map(t => ({ type: t.input.subagent_type, model: t.input.model || 'inherited' }))
924
+ taskToolCalls: subagentToolCalls.length,
925
+ subagentTypes: subagentToolCalls
926
+ .filter(t => t.input && (t.input.subagent_type || t.input.description))
927
+ .map(t => ({ type: t.input.subagent_type || t.input.description, model: (t.input && t.input.model) || 'inherited' }))
649
928
  });
650
929
  }
651
930
 
@@ -658,6 +937,58 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
658
937
  const { sumPerMessageUsage } = require('./utils/pricing');
659
938
  const usage = sumPerMessageUsage(mirror.usageByMsg);
660
939
 
940
+ // ---- v4.4 B3: one TERMINAL progress record carrying the settled usage ----
941
+ // progress.json's `usage` block was previously stamped only on 'receiving'
942
+ // flushes, which fire on text/tool/reasoning GROWTH — always strictly before
943
+ // OpenCode's finalization stamp — and writeProgress rebuilds the file from
944
+ // scratch, so it could not preserve an earlier snapshot either. Net effect on
945
+ // real runs: 31 of 35 legs ended with an all-zero usage snapshot while their
946
+ // metadata.json held thousands of real tokens. That snapshot is what the LIVE
947
+ // workspace GUI reads (src/observe/live-doc.js enrichLegUsage → resolveUsage),
948
+ // so every completed leg rendered as free.
949
+ //
950
+ // `messagesReceived` is deliberately omitted: readProgress() derives `messages`
951
+ // from conversation.jsonl's assistant entries and only falls back to this field
952
+ // when there are none, so re-stating it here would add nothing and could only
953
+ // disagree with the file it is meant to summarize.
954
+ //
955
+ // v4.4 B4: when the settle grace was exceeded the leg completed with tool
956
+ // calls still live, so its reported cost is a FLOOR and its session may still
957
+ // be billing. That must travel with the leg, not just sit in a log line — the
958
+ // live GUI reads this file (src/observe/live-doc.js). `unsettledToolCalls` is
959
+ // a COUNT here (progress.json is a compact snapshot); the full list is on the
960
+ // returned result for the caller's metadata.
961
+ const settleFlags = toolSettleTimedOut
962
+ ? { toolSettleTimedOut: true, unsettledToolCalls: unsettledAtCeiling.length }
963
+ : {};
964
+ // v4.4 B4 (Task 2) + v4.4.1 CA-1: a leg that made a SUBAGENT call has spend
965
+ // in a CHILD OpenCode session that is billed separately and is NOT rolled
966
+ // into the parent session's cost. `subtree` carries what the walk MEASURED;
967
+ // `subtreeUnknown` is what it could not. The proxy count survives as the
968
+ // fallback for the case where the walk could not run at all (see the
969
+ // enumeration block above) — src/sidecar/tool-part.js isSubagentToolCall
970
+ // has the 1:1 evidence for it.
971
+ const subtreeFlags = subagentToolCalls.length > 0
972
+ ? { subagentToolCalls: subagentToolCalls.length }
973
+ : {};
974
+ const subtreeResult = subtree ? { subtree } : {};
975
+ // The live workspace reads progress.json directly (src/observe/live-doc.js
976
+ // enrichLegUsage), so the attribution has to travel on BOTH channels or the
977
+ // GUI's cost-by-seat silently disagrees with run.json.
978
+ const subtreeProgress = subtree
979
+ ? { ...(subtree.sessions > 0
980
+ ? { subtree: { sessions: subtree.sessions, tokens: subtree.tokens, costReported: subtree.costReported } }
981
+ : {}),
982
+ ...(subtree.unknown ? { subtreeUnknown: true } : {}) }
983
+ : (subagentToolCalls.length > 0 ? { subtreeUnknown: true } : {});
984
+ try { writeProgress(sessionDir, 'complete', { usage: { ...usage, ...subtreeProgress }, ...settleFlags }); }
985
+ catch (progressErr) {
986
+ logger.debug('terminal progress write failed (best-effort)', { taskId, error: progressErr.message });
987
+ }
988
+ const settleResult = toolSettleTimedOut
989
+ ? { toolSettleTimedOut: true, unsettledToolCalls: unsettledAtCeiling }
990
+ : {};
991
+
661
992
  if (sessionError && (!mirror.output || pollFailureBail || toolStalled)) {
662
993
  return {
663
994
  summary: mirror.output ? extractSummary(mirror.output, foldNonce) : '',
@@ -667,6 +998,9 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
667
998
  taskId,
668
999
  toolCalls: mirror.toolCalls,
669
1000
  usage,
1001
+ ...settleResult,
1002
+ ...subtreeFlags,
1003
+ ...subtreeResult,
670
1004
  error: sessionError
671
1005
  };
672
1006
  }
@@ -679,6 +1013,9 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
679
1013
  taskId,
680
1014
  toolCalls: mirror.toolCalls, // Include tool calls in result for verification
681
1015
  usage,
1016
+ ...settleResult,
1017
+ ...subtreeFlags,
1018
+ ...subtreeResult,
682
1019
  exitCode: 0
683
1020
  };
684
1021
 
@@ -800,4 +1137,8 @@ module.exports = {
800
1137
  POLL_CALL_TIMEOUT_MS,
801
1138
  MAX_CONSECUTIVE_POLL_FAILURES,
802
1139
  TOOL_CALL_STALL_MS,
1140
+ USAGE_SETTLE_POLLS,
1141
+ USAGE_SETTLE_INTERVAL_MS,
1142
+ USAGE_SETTLE_CALL_TIMEOUT_MS,
1143
+ TOOL_SETTLE_GRACE_MS,
803
1144
  };
@@ -15,7 +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');
24
+ const { enrichLegUsage, markLive, rollupWaveUsage } = require('./observe/live-doc');
25
+ const { buildLegRows } = require('./observe/council-legs');
19
26
 
20
27
  /**
21
28
  * Every wave a stage launched: the primary `waveId` plus the recorded
@@ -58,6 +65,65 @@ function countWaveLegs(project, waveId) {
58
65
  return { total: legs.length, complete };
59
66
  }
60
67
 
68
+ /**
69
+ * Leg ids recorded on a sub-wave's metadata.json, or [] when the wave record
70
+ * is absent/malformed. A small sibling to countWaveLegs — kept separate so
71
+ * that helper's {total, complete} contract (other callers depend on it) isn't
72
+ * overloaded into returning ids too.
73
+ * @returns {string[]}
74
+ */
75
+ function waveLegIds(project, waveId) {
76
+ const { getSessionDir } = require('./session-manager');
77
+ let legs;
78
+ try {
79
+ legs = JSON.parse(fs.readFileSync(
80
+ path.join(getSessionDir(project, waveId), 'metadata.json'), 'utf-8')).legs;
81
+ } catch { return []; }
82
+ return Array.isArray(legs) ? legs : [];
83
+ }
84
+
85
+ /**
86
+ * Read-time cost-by-seat for one leg (A8: progress.json only, never a ledger).
87
+ * Tolerates a leg with no progress.usage yet — contributes nothing (N3).
88
+ */
89
+ function legUsage(project, legId) {
90
+ const { getSessionDir } = require('./session-manager');
91
+ const { readProgress } = require('./sidecar/progress');
92
+ let model = null;
93
+ try {
94
+ model = JSON.parse(fs.readFileSync(
95
+ path.join(getSessionDir(project, legId), 'metadata.json'), 'utf-8')).model || null;
96
+ } catch { /* leg metadata not written yet */ }
97
+ let progressUsage;
98
+ try { progressUsage = readProgress(getSessionDir(project, legId)).usage; }
99
+ catch { /* no progress.json yet */ }
100
+ return enrichLegUsage({ model }, progressUsage);
101
+ }
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
+
61
127
  function elapsedOf(run) {
62
128
  const end = run.completedAt || new Date().toISOString();
63
129
  const ms = Math.max(0, new Date(end).getTime() - new Date(run.createdAt || end).getTime());
@@ -66,7 +132,7 @@ function elapsedOf(run) {
66
132
 
67
133
  /** Status payload for a council runId, or null when the id is not a council run. */
68
134
  function buildCouncilStatusPayload(project, taskId) {
69
- const ptr = runState.readPointer(project, taskId);
135
+ const ptr = readFencedPointer(project, taskId);
70
136
  if (!ptr) { return null; }
71
137
  const run = runState.readRun(ptr.runDir);
72
138
  if (!run) { return null; }
@@ -94,11 +160,24 @@ function buildCouncilStatusPayload(project, taskId) {
94
160
  // Sum across every sub-wave the active stage launched: a lens stage1 has no
95
161
  // seat wave at all, and a critic solo runs beside one. Stays null until at
96
162
  // least one sub-wave record exists on disk.
163
+ // Cost-by-seat rides the same loop, read-time from progress.json only (A8) —
164
+ // usageLegs stays empty (no `usage` on the payload) until a leg has actually
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).
169
+ const usageLegs = [];
170
+ const allLegIds = [];
97
171
  for (const waveId of active && active.project ? subWaveIds(active) : []) {
98
172
  const c = countWaveLegs(active.project, waveId);
99
173
  if (!c) { continue; }
100
174
  legsTotal = (legsTotal || 0) + c.total;
101
175
  legsComplete = (legsComplete || 0) + c.complete;
176
+ for (const legId of waveLegIds(active.project, waveId)) {
177
+ allLegIds.push(legId);
178
+ const enriched = legUsage(active.project, legId);
179
+ if (enriched.usage) { usageLegs.push(enriched); }
180
+ }
102
181
  }
103
182
  const payload = {
104
183
  taskId: run.runId, type: 'council-run', runId: run.runId, runDir: ptr.runDir,
@@ -107,8 +186,18 @@ function buildCouncilStatusPayload(project, taskId) {
107
186
  exitCode: run.exitCode !== undefined ? run.exitCode : null,
108
187
  version: RUNNING_VERSION,
109
188
  };
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
+ }
110
199
  if (run.error) { payload.reason = `${run.error.code}: ${run.error.message}`; }
111
- return payload;
200
+ return markLive(payload);
112
201
  }
113
202
 
114
203
  /** amicus_list entries for every council pointer in the project. */
@@ -116,6 +205,12 @@ function listCouncilRuns(project) {
116
205
  const { sanitizePreview } = require('./sidecar/progress-fields');
117
206
  const out = [];
118
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; }
119
214
  const run = runState.readRun(ptr.runDir);
120
215
  if (!run) { continue; }
121
216
  let briefing = '';
@@ -159,7 +254,7 @@ function cascadeWave(project, waveId) {
159
254
  * @returns {null|{notFound?: true}|{alreadyTerminal: true, status}|{aborted: true, cascaded: number}}
160
255
  */
161
256
  function abortCouncilRun(project, taskId) {
162
- const ptr = runState.readPointer(project, taskId);
257
+ const ptr = readFencedPointer(project, taskId);
163
258
  if (!ptr) { return null; }
164
259
  const run = runState.readRun(ptr.runDir);
165
260
  if (!run) { return null; }