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
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,65 @@ 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
+ * v4.4.1 LC-2 (owner ruling, 2026-07-26): the leg's completion and its partial
127
+ * output are unchanged, but its OpenCode session is now ABORTED at the ceiling
128
+ * (see the finalization block) so it stops billing for work nobody will read.
129
+ */
130
+ const TOOL_SETTLE_GRACE_MS = envNumber('AMICUS_TOOL_SETTLE_GRACE_MS', 300000);
131
+ /**
132
+ * v4.4.1 LC-2 — how long the ceiling's abort call may take before we stop
133
+ * waiting on it. A hard constant rather than an env knob (the same disposition
134
+ * as src/sidecar/child-sessions.js's bounds): it exists to stop a pathological
135
+ * hang, not to be tuned. The leg is already complete and already paid for when
136
+ * this runs, so an unbounded wait here would hold a finished answer hostage to a
137
+ * best-effort cost optimization — exactly the trade A-8 forbids. Injectable via
138
+ * `options.toolSettleAbortTimeoutMs` so the bound itself is testable.
139
+ */
140
+ const TOOL_SETTLE_ABORT_TIMEOUT_MS = 5000;
76
141
 
77
142
  /**
78
143
  * Race a promise against a timeout. Returns the promise's result, or rejects with
@@ -207,7 +272,18 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
207
272
  if (options.mcp) {
208
273
  serverOptions.mcp = options.mcp;
209
274
  }
210
- const result = await startServer(serverOptions);
275
+ // v4.4.1 fix wave (F5): this is the OTHER server-start site. It calls
276
+ // startServer directly rather than going through startOpenCodeServer, so
277
+ // the lock-class retry added for the concurrent-start race never covered
278
+ // it — a plain `amicus start` that lost the race still died on the first
279
+ // `database is locked`. "Two separate amicus processes contending" is half
280
+ // that retry's stated justification, and this is one of the two processes.
281
+ // Same bounded, narrow policy: 5 attempts (Step 10.5 widened it from 3),
282
+ // lock-class messages only, final failure rethrown unchanged into the
283
+ // degrade path below.
284
+ const { retryOnLockRace } = require('./utils/server-setup');
285
+ const result = await retryOnLockRace(() => startServer(serverOptions),
286
+ { retryDelayMs: options.retryDelayMs });
211
287
  client = result.client;
212
288
  server = result.server;
213
289
  logger.debug('Server started', { url: server.url });
@@ -397,6 +473,17 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
397
473
  const pollCallTimeoutMs = options.pollCallTimeoutMs || POLL_CALL_TIMEOUT_MS;
398
474
  const maxConsecutivePollFailures = options.maxConsecutivePollFailures || MAX_CONSECUTIVE_POLL_FAILURES;
399
475
  const toolCallStallMs = options.toolCallStallMs || TOOL_CALL_STALL_MS;
476
+ // `=== undefined` rather than `||`: 0 is a meaningful value (disable the
477
+ // v4.4 B1 settle re-poll entirely) and must survive injection.
478
+ const usageSettlePolls = options.usageSettlePolls === undefined
479
+ ? USAGE_SETTLE_POLLS : options.usageSettlePolls;
480
+ const usageSettleIntervalMs = options.usageSettleIntervalMs === undefined
481
+ ? USAGE_SETTLE_INTERVAL_MS : options.usageSettleIntervalMs;
482
+ // `=== undefined` rather than `||`: 0 is meaningful (disable the deferral).
483
+ const toolSettleGraceMs = options.toolSettleGraceMs === undefined
484
+ ? TOOL_SETTLE_GRACE_MS : options.toolSettleGraceMs;
485
+ const toolSettleAbortTimeoutMs = options.toolSettleAbortTimeoutMs === undefined
486
+ ? TOOL_SETTLE_ABORT_TIMEOUT_MS : options.toolSettleAbortTimeoutMs;
400
487
  let consecutivePollFailures = 0;
401
488
  let pollFailureBail = false;
402
489
  let lastAssistantMsgId = null;
@@ -408,6 +495,62 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
408
495
  let lastReasoningLength = 0; // B53: track reasoning-output growth to detect thinking
409
496
  let lastProgressAt = Date.now(); // B53: last poll where `progressed` was true
410
497
  let toolStalled = false; // B53: distinct from completed/timedOut/aborted — see resolveTerminalState
498
+ let lastSettledToolCount = 0; // B4: tool calls observed reaching a terminal status
499
+
500
+ // ---- v4.4 B4 part 1: the tool-settle deferral -----------------------------
501
+ // Recomputed once per poll (see the loop body) so every completion gate in a
502
+ // single poll reads ONE consistent answer.
503
+ let liveTools = []; // POSITIVELY 'pending'/'running' — gates completion
504
+ let pendingTools = []; // not-yet-terminal incl. unknown shape — feeds B53
505
+ let toolSettleDeferredSince = null; // ms timestamp of the first deferral, or null
506
+ let toolSettleTimedOut = false; // the grace ceiling was exceeded
507
+ let unsettledAtCeiling = []; // what was still live when it was exceeded
508
+ let toolSettleAborted = false; // LC-2: the ceiling's abort landed (see finalization)
509
+
510
+ /**
511
+ * Should this poll's completion signal be DEFERRED because a tool call has
512
+ * not reached a terminal `state.status`?
513
+ *
514
+ * Keyed on the REAL SDK shape (src/sidecar/tool-part.js): terminal is
515
+ * `state.status === 'completed' | 'error'`. It is deliberately NOT keyed on a
516
+ * `tool_result` part — OpenCode emits no such part type (36 `tool_use` records
517
+ * and 0 `tool_result` records across the 35 recorded legs), so the diagnosis's
518
+ * proposed `pendingToolCalls` gate would have hung every tool-using leg.
519
+ *
520
+ * It reads `getLiveToolCalls`, NOT `getPendingToolCalls`: a leg is only ever
521
+ * held open on POSITIVE evidence that OpenCode is still working ('pending' /
522
+ * 'running'). A tool part carrying no `state` at all is unknown, not live, and
523
+ * must not defer anything — deferring on an absence of evidence is exactly how
524
+ * this gate would hang. B53 still owns that no-evidence case.
525
+ *
526
+ * @param {string} exitPath which completion gate is asking (for the logs)
527
+ * @returns {boolean} true = keep polling; false = complete now
528
+ */
529
+ const deferForUnsettledTools = (exitPath) => {
530
+ if (toolSettleTimedOut) { return false; } // ceiling blown — never defer again
531
+ if (!(toolSettleGraceMs > 0)) { return false; } // 0 = disabled (escape hatch)
532
+ if (liveTools.length === 0) { return false; }
533
+ if (toolSettleDeferredSince === null) {
534
+ toolSettleDeferredSince = Date.now();
535
+ logger.info('Deferring leg completion — tool call(s) still executing', {
536
+ taskId, exitPath, live: liveTools.length,
537
+ tools: liveTools.map(t => `${t.name}:${t.status}`).join(','), toolSettleGraceMs,
538
+ });
539
+ return true;
540
+ }
541
+ if ((Date.now() - toolSettleDeferredSince) <= toolSettleGraceMs) { return true; }
542
+ // The ceiling. Complete the leg (keep whatever output it produced) and make
543
+ // the uncertainty impossible to miss — never fail it closed.
544
+ toolSettleTimedOut = true;
545
+ unsettledAtCeiling = liveTools.slice();
546
+ logger.error('Tool call(s) did not settle within the grace window — completing leg '
547
+ + 'anyway; its OpenCode session may STILL be working and BILLING', {
548
+ taskId, sessionId, exitPath, toolSettleGraceMs,
549
+ unsettled: unsettledAtCeiling.length,
550
+ tools: unsettledAtCeiling.map(t => `${t.name}@${t.firstSeenAt}`).join(','),
551
+ });
552
+ return false;
553
+ };
411
554
 
412
555
  while (!completed && (Date.now() - startTime) < timeoutMs) {
413
556
  watchdog.touch();
@@ -459,6 +602,14 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
459
602
  ));
460
603
  const currentAssistantMsgId = mr.currentAssistantMsgId;
461
604
  const assistantFinished = mr.assistantFinished;
605
+ // v4.4 B4 part 1: evaluate tool liveness ONCE per poll, before any
606
+ // completion gate reads it. Clearing the deferral here (rather than
607
+ // inside deferForUnsettledTools) matters: the gates only run when a
608
+ // completion signal fires, so a leg that resumes working after a
609
+ // deferral would otherwise keep B53 suppressed on a stale timestamp.
610
+ pendingTools = getPendingToolCalls(mirror);
611
+ liveTools = getLiveToolCalls(mirror);
612
+ if (liveTools.length === 0) { toolSettleDeferredSince = null; }
462
613
  if (mr.sessionError) {
463
614
  sessionError = mr.sessionError;
464
615
  logger.error('Session error detected in assistant message', { sessionId, message: mr.sessionError });
@@ -477,7 +628,11 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
477
628
  // marker on its own line mid-output (echoing a prior sidecar, these
478
629
  // instructions, or scraped content) — only the exact nonced marker,
479
630
  // with nothing but blank lines after it, is a completion signal.
480
- if (findTrailingFoldMarker(mirror.output, foldNonce) !== -1) {
631
+ // v4.4 B4: a fold marker WITHOUT info.time.completed means OpenCode has
632
+ // not finalized the message, so a tool call may still be live and billing
633
+ // (this is the same window B1's usage race lives in). Defer, bounded.
634
+ if (findTrailingFoldMarker(mirror.output, foldNonce) !== -1
635
+ && !deferForUnsettledTools('fold-marker')) {
481
636
  completed = true;
482
637
  break;
483
638
  }
@@ -514,7 +669,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
514
669
  'getSessionStatus'
515
670
  );
516
671
  const s = (statusData && statusData.type) ? statusData : (statusData && statusData[sessionId]);
517
- if (s && s.type === 'idle') {
672
+ if (s && s.type === 'idle' && !deferForUnsettledTools('sdk-idle')) {
518
673
  logger.debug('Session reported idle by SDK — completing', { sessionId });
519
674
  completed = true;
520
675
  break;
@@ -543,9 +698,14 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
543
698
  // the stall clock resets instead of falsely firing "Tool call stalled".
544
699
  const reasoningActivity = mirror.reasoningOutput.length > lastReasoningLength;
545
700
  lastReasoningLength = mirror.reasoningOutput.length;
701
+ // v4.4 B4: a tool call REACHING a terminal status is real activity. Before
702
+ // the shape fix this could never be observed (pending never cleared), so a
703
+ // multi-tool leg's stall clock only reset on text growth.
704
+ const settleActivity = mirror.settledToolCallIds.size > lastSettledToolCount;
705
+ lastSettledToolCount = mirror.settledToolCallIds.size;
546
706
 
547
707
  const progressed = outputGrew || toolActivity || resultActivity || messageActivity
548
- || newAssistant || reasoningActivity;
708
+ || newAssistant || reasoningActivity || settleActivity;
549
709
  if (progressed) { lastProgressAt = Date.now(); }
550
710
 
551
711
  // B53: a wedged tool call (tool_use emitted, result never arrives) otherwise
@@ -555,9 +715,19 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
555
715
  // (text/tool/result/message/new-assistant) has been observed for the stall
556
716
  // window — this cannot false-positive during active streaming (progress
557
717
  // resets the clock every poll) and cannot fire without a wedged tool.
558
- const pendingToolCalls = getPendingToolCalls(mirror);
559
- if (pendingToolCalls.length > 0 && (Date.now() - lastProgressAt) > toolCallStallMs) {
560
- const stalled = pendingToolCalls[0];
718
+ //
719
+ // v4.4 B4: SKIPPED while a tool-settle deferral is active. B53 was written
720
+ // against `pendingToolCalls`, which could never clear (no tool_result part
721
+ // exists), so its 180 s window was never calibrated against real tool
722
+ // durations — the measured `task` call that exposed this defect ran 190.6 s,
723
+ // so B53 would kill a healthy subagent leg 10 s short of its answer, and
724
+ // kill it CLOSED. Once a completion signal has fired, the bounded settle
725
+ // grace owns that decision and ends in a LOUD completion instead. B53's
726
+ // actual target — a wedge with NO output, where the idle gate never engages
727
+ // and therefore no deferral is ever active — is untouched.
728
+ if (pendingTools.length > 0 && toolSettleDeferredSince === null
729
+ && (Date.now() - lastProgressAt) > toolCallStallMs) {
730
+ const stalled = pendingTools[0];
561
731
  const pendingSeconds = Math.round((Date.now() - Date.parse(stalled.firstSeenAt)) / 1000);
562
732
  sessionError = `Tool call stalled: ${stalled.name} pending ${pendingSeconds}s with no result or output`;
563
733
  logger.error('Tool call stalled — no progress within threshold', {
@@ -580,7 +750,20 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
580
750
  if (currentAssistantMsgId !== null && mirror.output.length > 0) {
581
751
  stablePolls++;
582
752
  const threshold = assistantFinished ? stableFinishedPolls : stableIdlePolls;
583
- if (stablePolls >= threshold) {
753
+ // v4.4 B4 part 1 — THE MEASURED DEFECT SITE. This is the gate that
754
+ // declared `wsgate02-s1-3` complete on 166 characters of preamble 129 s
755
+ // before its `task` tool finished. Deferred (bounded) when a tool call
756
+ // is still live.
757
+ //
758
+ // The `assistantFinished` branch is deliberately NOT deferred:
759
+ // OpenCode finalizes an assistant message only AFTER its tool calls
760
+ // end, so `time.completed` structurally implies settled. VERIFIED on
761
+ // the defect leg itself — task end 04:38:19.061, message
762
+ // time.completed 04:38:19.301 — and on both recorded multi-tool legs,
763
+ // whose last tool ended 62.3 s and 14.8 s before the leg completed.
764
+ // Gating it would add pure hang risk for no truth gained.
765
+ if (stablePolls >= threshold
766
+ && !(!assistantFinished && deferForUnsettledTools('stable-idle'))) {
584
767
  logger.debug('Session appears complete (idle)', { stablePolls, assistantFinished });
585
768
  completed = true;
586
769
  break;
@@ -644,16 +827,180 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
644
827
 
645
828
  watchdog.cancel();
646
829
  if (uninstallSignals) { uninstallSignals(); }
647
- if (!externalServer) { await server.close(); }
648
830
 
649
- // Log summary of tool calls for debugging
831
+ // ---- v4.4 B1: bounded post-loop usage reconciliation ----------------------
832
+ // MUST run before server.close() — the client needs a live server — and MUST
833
+ // NOT re-mirror text: mirrorMessages() would append the already-captured
834
+ // assistant output to conversation.jsonl a second time, so this uses the
835
+ // usage-only pass (src/sidecar/conversation-mirror.js mirrorUsageOnly).
836
+ //
837
+ // Strictly best-effort: every failure mode leaves the leg's completion
838
+ // verdict, summary and error exactly as the loop decided them. B2 is the
839
+ // safety net underneath — when the re-poll still sees nothing, the leg
840
+ // resolves to `unknown`, never a fabricated $0.
841
+ //
842
+ // Skipped when there is nothing to settle: an aborted leg (the caller pulled
843
+ // the plug), a leg that bailed on consecutive poll failures (the server is
844
+ // gone — three more reads would only burn the settle timeout), and a leg that
845
+ // errored with no output at all (no assistant message was ever billed).
846
+ // Deliberately NOT restricted to `completed`: a timed-out or tool-stalled leg
847
+ // spent real money too, and its usage is just as worth capturing.
848
+ const canSettleUsage = !aborted && !pollFailureBail && !(sessionError && !mirror.output);
849
+ if (canSettleUsage && usageSettlePolls > 0) {
850
+ for (let i = 0; i < usageSettlePolls; i++) {
851
+ // v4.4 (cost-council finding 2): the boundary covers the WHOLE loop body,
852
+ // not just the network read. It previously wrapped only `withTimeout(...)`,
853
+ // leaving `mirrorUsageOnly` and `allAssistantUsagePresent` — which inspect
854
+ // an untrusted snapshot shape — outside it. A throw there escaped
855
+ // runHeadless entirely and DISCARDED a leg whose answer was already
856
+ // captured and already paid for: the most expensive possible outcome for
857
+ // a path whose entire job is a nice-to-have usage top-up. "Best-effort"
858
+ // has to mean the effort, not just its first statement.
859
+ let done = false;
860
+ try {
861
+ const settled = await withTimeout(
862
+ getMessages(client, sessionId, ...dirArgs),
863
+ Math.min(pollCallTimeoutMs, USAGE_SETTLE_CALL_TIMEOUT_MS),
864
+ 'getMessages(usage-settle)',
865
+ );
866
+ mirrorUsageOnly(settled, mirror);
867
+ done = allAssistantUsagePresent(settled);
868
+ if (!done && i < usageSettlePolls - 1) {
869
+ await new Promise(resolve => setTimeout(resolve, usageSettleIntervalMs));
870
+ }
871
+ } catch (settleErr) {
872
+ // Same disposition as the pre-existing network-failure branch: stop
873
+ // settling, keep every dollar already mirrored, and leave the loop's
874
+ // completion verdict, summary and error untouched. B2 remains the net
875
+ // underneath — no observation resolves to `unknown`, never a fake $0.
876
+ logger.debug('usage-settle re-poll failed (best-effort, leg unaffected)', {
877
+ taskId, attempt: i + 1, error: settleErr.message,
878
+ });
879
+ break;
880
+ }
881
+ if (done) { break; }
882
+ }
883
+ }
884
+
885
+ // Log summary of tool calls for debugging.
886
+ // v4.4 B4: this used to filter on `t.name === 'Task'` and was DEAD twice over —
887
+ // the mirror read `part.name` (the real shape has `part.tool`) so every name
888
+ // was undefined, and OpenCode's tool is named `task` in lowercase anyway.
889
+ const { isSubagentToolCall } = require('./sidecar/tool-part');
890
+ const subagentToolCalls = mirror.toolCalls.filter(isSubagentToolCall);
891
+
892
+ // ---- v4.4.1 CA-1: enumerate CHILD (subagent) session spend ---------------
893
+ // MUST run before server.close() — the walk needs a live server. A `task`
894
+ // call spawns a child OpenCode session that OpenCode bills separately and
895
+ // does NOT roll into this session's cost; amicus never looked, so $0.492506
896
+ // across the four recorded paid runs was invisible to every total the
897
+ // product prints. Safe to do at finalization only because dcb0792 stopped a
898
+ // `task` part going terminal while its child session is still live — before
899
+ // that, walking here would have captured a partial child cost and traded a
900
+ // silent zero for a silent floor.
901
+ //
902
+ // Run for EVERY leg, not only ones whose tool calls looked like `task`: the
903
+ // name-string proxy (src/sidecar/tool-part.js) was verified 1:1 on a
904
+ // 37-session corpus and nowhere else, so a child created by some other
905
+ // mechanism would be a silent under-count wearing a costExact badge — the
906
+ // exact defect the flag exists to kill. Skipped only when there is nothing
907
+ // to ask (same predicate as the usage settle: the server is gone, or the
908
+ // caller pulled the plug), in which case the leg falls back to the proxy and
909
+ // honestly reports its subtree as unknown.
910
+ let subtree = null;
911
+ if (canSettleUsage) {
912
+ try {
913
+ const { collectSubtreeUsage, subtreeIsUnknown } = require('./sidecar/child-sessions');
914
+ const walked = await collectSubtreeUsage(client, sessionId, {
915
+ directory,
916
+ callTimeoutMs: Math.min(pollCallTimeoutMs, USAGE_SETTLE_CALL_TIMEOUT_MS),
917
+ logger,
918
+ });
919
+ subtree = {
920
+ sessions: walked.sessions.length,
921
+ tokens: walked.tokens,
922
+ costReported: walked.costReported,
923
+ // The honesty verdict is decided HERE, where both observations live —
924
+ // the walk's own completeness and the `task`-call evidence. See
925
+ // subtreeIsUnknown for why a failed walk with no evidence of a
926
+ // subagent must NOT flag (an older server would otherwise mark every
927
+ // leg of every run inexact forever).
928
+ unknown: subtreeIsUnknown({
929
+ walkComplete: walked.complete,
930
+ sessionsFound: walked.sessions.length,
931
+ subagentCalls: subagentToolCalls.length,
932
+ }),
933
+ };
934
+ if (walked.sessions.length > 0) {
935
+ logger.info('Child session spend attributed to this leg', {
936
+ taskId, sessions: walked.sessions.map((s) => s.id),
937
+ costReported: walked.costReported, subtreeUnknown: subtree.unknown,
938
+ });
939
+ }
940
+ } catch (subtreeErr) {
941
+ // Cannot happen by construction (the collector swallows its own
942
+ // failures), but a throw here must never cost a leg its answer.
943
+ subtree = null;
944
+ logger.debug('subtree enumeration failed (best-effort)', { taskId, error: subtreeErr.message });
945
+ }
946
+ }
947
+
948
+ // ---- v4.4.1 LC-2: stop paying for a session nobody will read -------------
949
+ // OWNER RULING (2026-07-26). The leg is complete and runHeadless is returning,
950
+ // so nothing will ever read further session output — the outcome was already
951
+ // discarded by completing. Aborting here does not truncate an answer that
952
+ // would have been used; it stops paying for work nobody will read. The
953
+ // original objection ("stops the bleeding at the cost of truncating a
954
+ // possibly-healthy call") applied to aborting ON the completion route, where
955
+ // the call might still have mattered. Here it cannot.
956
+ //
957
+ // ORDER IS LOAD-BEARING, on BOTH sides:
958
+ // AFTER the child-session walk above — aborting first risks losing the
959
+ // subtree cost data v4.4.0 exists to capture, trading one silent
960
+ // under-report for another.
961
+ // BEFORE server.close() below — the abort is an SDK call and needs a live
962
+ // server. It is not redundant with close(): on a SHARED server (every
963
+ // council run) close() is never called here, and the session would go on
964
+ // billing against a server that outlives this leg.
965
+ //
966
+ // A-8 APPLIES: "never lose the answer" outranks "never report inaccurate
967
+ // usage". This is an optimization layered on an already-successful,
968
+ // already-paid-for leg, so every failure — rejection, hang, or a missing
969
+ // session id — is logged and dropped. Nothing here may alter `completed`,
970
+ // `summary`, `usage` or `error`. `toolSettleAborted: false` is the honest
971
+ // record of "we tried and could not; it may still be billing".
972
+ if (toolSettleTimedOut && sessionId) {
973
+ try {
974
+ const { abortSession } = require('./opencode-client');
975
+ await withTimeout(
976
+ abortSession(client, sessionId, ...dirArgs),
977
+ toolSettleAbortTimeoutMs,
978
+ 'abortSession(tool-settle)',
979
+ );
980
+ toolSettleAborted = true;
981
+ // Task 6 review X2: a LANDED abort is the good outcome of a condition
982
+ // that is already logged at `error` (the ceiling itself). Logging the
983
+ // remedy at `warn` reads as a second problem; `info` reads honestly.
984
+ // The FAILED abort below stays at `warn` — that one really is a problem
985
+ // ("it may still be billing").
986
+ logger.info('Aborted the OpenCode session after the tool-settle ceiling', {
987
+ taskId, sessionId, unsettled: unsettledAtCeiling.length,
988
+ });
989
+ } catch (abortErr) {
990
+ toolSettleAborted = false;
991
+ logger.warn('Could not abort the session after the tool-settle ceiling — it may '
992
+ + 'still be billing', { taskId, sessionId, error: abortErr.message });
993
+ }
994
+ }
995
+
996
+ if (!externalServer) { await server.close(); }
650
997
  if (mirror.toolCalls.length > 0) {
651
998
  logger.info('Tool calls summary', {
652
999
  totalToolCalls: mirror.toolCalls.length,
653
- taskToolCalls: mirror.toolCalls.filter(t => t.name === 'Task').length,
654
- subagentTypes: mirror.toolCalls
655
- .filter(t => t.name === 'Task' && t.input?.subagent_type)
656
- .map(t => ({ type: t.input.subagent_type, model: t.input.model || 'inherited' }))
1000
+ taskToolCalls: subagentToolCalls.length,
1001
+ subagentTypes: subagentToolCalls
1002
+ .filter(t => t.input && (t.input.subagent_type || t.input.description))
1003
+ .map(t => ({ type: t.input.subagent_type || t.input.description, model: (t.input && t.input.model) || 'inherited' }))
657
1004
  });
658
1005
  }
659
1006
 
@@ -666,7 +1013,95 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
666
1013
  const { sumPerMessageUsage } = require('./utils/pricing');
667
1014
  const usage = sumPerMessageUsage(mirror.usageByMsg);
668
1015
 
669
- if (sessionError && (!mirror.output || pollFailureBail || toolStalled)) {
1016
+ // ---- v4.4 B3: one TERMINAL progress record carrying the settled usage ----
1017
+ // progress.json's `usage` block was previously stamped only on 'receiving'
1018
+ // flushes, which fire on text/tool/reasoning GROWTH — always strictly before
1019
+ // OpenCode's finalization stamp — and writeProgress rebuilds the file from
1020
+ // scratch, so it could not preserve an earlier snapshot either. Net effect on
1021
+ // real runs: 31 of 35 legs ended with an all-zero usage snapshot while their
1022
+ // metadata.json held thousands of real tokens. That snapshot is what the LIVE
1023
+ // workspace GUI reads (src/observe/live-doc.js enrichLegUsage → resolveUsage),
1024
+ // so every completed leg rendered as free.
1025
+ //
1026
+ // `messagesReceived` is deliberately omitted: readProgress() derives `messages`
1027
+ // from conversation.jsonl's assistant entries and only falls back to this field
1028
+ // when there are none, so re-stating it here would add nothing and could only
1029
+ // disagree with the file it is meant to summarize.
1030
+ //
1031
+ // v4.4 B4: when the settle grace was exceeded the leg completed with tool
1032
+ // calls still live, so its reported cost is a FLOOR and its session may still
1033
+ // be billing. That must travel with the leg, not just sit in a log line — the
1034
+ // live GUI reads this file (src/observe/live-doc.js). `unsettledToolCalls` is
1035
+ // a COUNT here (progress.json is a compact snapshot); the full list is on the
1036
+ // returned result for the caller's metadata.
1037
+ //
1038
+ // v4.4.1 LC-2: `toolSettleAborted` rides alongside it — `true` = the session
1039
+ // was told to stop, `false` = it may still be billing. Both are meaningful
1040
+ // ONLY when the ceiling was hit, so neither appears on a clean leg.
1041
+ const settleFlags = toolSettleTimedOut
1042
+ ? { toolSettleTimedOut: true, unsettledToolCalls: unsettledAtCeiling.length,
1043
+ toolSettleAborted }
1044
+ : {};
1045
+ // v4.4 B4 (Task 2) + v4.4.1 CA-1: a leg that made a SUBAGENT call has spend
1046
+ // in a CHILD OpenCode session that is billed separately and is NOT rolled
1047
+ // into the parent session's cost. `subtree` carries what the walk MEASURED;
1048
+ // `subtreeUnknown` is what it could not. The proxy count survives as the
1049
+ // fallback for the case where the walk could not run at all (see the
1050
+ // enumeration block above) — src/sidecar/tool-part.js isSubagentToolCall
1051
+ // has the 1:1 evidence for it.
1052
+ const subtreeFlags = subagentToolCalls.length > 0
1053
+ ? { subagentToolCalls: subagentToolCalls.length }
1054
+ : {};
1055
+ const subtreeResult = subtree ? { subtree } : {};
1056
+ // The live workspace reads progress.json directly (src/observe/live-doc.js
1057
+ // enrichLegUsage), so the attribution has to travel on BOTH channels or the
1058
+ // GUI's cost-by-seat silently disagrees with run.json.
1059
+ const subtreeProgress = subtree
1060
+ ? { ...(subtree.sessions > 0
1061
+ ? { subtree: { sessions: subtree.sessions, tokens: subtree.tokens, costReported: subtree.costReported } }
1062
+ : {}),
1063
+ ...(subtree.unknown ? { subtreeUnknown: true } : {}) }
1064
+ : (subagentToolCalls.length > 0 ? { subtreeUnknown: true } : {});
1065
+ // ---- v4.4.1 LC-3: the terminal stage is DERIVED, never hardcoded ---------
1066
+ // This write sits above BOTH returns below, so EVERY terminal path reaches
1067
+ // it — the external-abort break, the --timeout, the poll-failure bail, the
1068
+ // tool-call wedge — and it used to stamp 'complete' on all of them. The live
1069
+ // workspace reads progress.json directly (src/observe/live-doc.js
1070
+ // enrichLegUsage), so an aborted or errored leg rendered with a green check
1071
+ // until metadata.json landed. Fix the WRITER: src/observe/council-legs.js
1072
+ // already prefers metadata.json for a terminal leg and is NOT the problem.
1073
+ //
1074
+ // resolveTerminalState is the codebase's single source of truth for the
1075
+ // (completed, timedOut, aborted, error) → status mapping, and it is what
1076
+ // start.js / continue.js / resume.js / finalizeHeadlessResult will run on
1077
+ // THIS function's return value to stamp metadata.json. Deriving the stage
1078
+ // from it — rather than re-deriving a second, hand-rolled expression here —
1079
+ // is what guarantees progress.json's stage and metadata.json's status cannot
1080
+ // disagree. It also covers the two cases a hand-rolled `aborted ? … :
1081
+ // sessionError ? …` would get wrong: a TIMED-OUT leg (which would still have
1082
+ // read 'complete'), and the F1 case where a session error arrived alongside
1083
+ // usable output and the leg legitimately returns completed (which would have
1084
+ // read a false 'error').
1085
+ //
1086
+ // `failedWithNoUsableOutput` is hoisted out of the `if` below so the stage
1087
+ // and the returned shape are decided by ONE predicate and cannot drift.
1088
+ const failedWithNoUsableOutput = !!(sessionError && (!mirror.output || pollFailureBail || toolStalled));
1089
+ const { resolveTerminalState } = require('./sidecar/session-finalize');
1090
+ const terminalStage = resolveTerminalState({
1091
+ completed,
1092
+ timedOut,
1093
+ aborted,
1094
+ error: failedWithNoUsableOutput ? sessionError : null,
1095
+ }).status;
1096
+ try { writeProgress(sessionDir, terminalStage, { usage: { ...usage, ...subtreeProgress }, ...settleFlags }); }
1097
+ catch (progressErr) {
1098
+ logger.debug('terminal progress write failed (best-effort)', { taskId, error: progressErr.message });
1099
+ }
1100
+ const settleResult = toolSettleTimedOut
1101
+ ? { toolSettleTimedOut: true, unsettledToolCalls: unsettledAtCeiling, toolSettleAborted }
1102
+ : {};
1103
+
1104
+ if (failedWithNoUsableOutput) {
670
1105
  return {
671
1106
  summary: mirror.output ? extractSummary(mirror.output, foldNonce) : '',
672
1107
  completed: false,
@@ -675,6 +1110,9 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
675
1110
  taskId,
676
1111
  toolCalls: mirror.toolCalls,
677
1112
  usage,
1113
+ ...settleResult,
1114
+ ...subtreeFlags,
1115
+ ...subtreeResult,
678
1116
  error: sessionError
679
1117
  };
680
1118
  }
@@ -687,6 +1125,9 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
687
1125
  taskId,
688
1126
  toolCalls: mirror.toolCalls, // Include tool calls in result for verification
689
1127
  usage,
1128
+ ...settleResult,
1129
+ ...subtreeFlags,
1130
+ ...subtreeResult,
690
1131
  exitCode: 0
691
1132
  };
692
1133
 
@@ -707,7 +1148,64 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
707
1148
  }
708
1149
  if (watchdog) { watchdog.cancel(); }
709
1150
  if (uninstallSignals) { uninstallSignals(); }
710
- if (!externalServer) { await server.close(); }
1151
+ // ⚠️ v4.4.1 M2: guarded — this used to be a bare `await server.close()` sitting directly
1152
+ // above A3's terminal-write block, OUTSIDE any try, in the one place a close failure is
1153
+ // least affordable: the error handler. A rejection here would have skipped the terminal
1154
+ // progress write below entirely AND replaced the original `error` (the one A3 exists to
1155
+ // preserve) with this close failure instead. `close()` has already done its job of freeing
1156
+ // the port by the time we get here; a failure to close cleanly is not this handler's
1157
+ // problem to propagate, so it is logged and swallowed, same discipline as every other
1158
+ // best-effort close in this file (see the signal handler above).
1159
+ if (!externalServer) {
1160
+ try { await server.close(); } catch (closeErr) {
1161
+ logger.debug('server.close() failed in the outer exception handler (best-effort)', {
1162
+ taskId, error: closeErr.message,
1163
+ });
1164
+ }
1165
+ }
1166
+ // ⚠️ v4.4.1 A3 — the last hole in LC-3's story. LC-3 made the SUCCESS path's terminal
1167
+ // progress write derive its stage from resolveTerminalState instead of hardcoding 'complete',
1168
+ // so an aborted/errored/timed-out leg stopped rendering with a green check. This path — the
1169
+ // outer exception handler — wrote NO terminal progress record at all, so progress.json kept
1170
+ // whatever non-terminal stage the last flush left on it (usually 'receiving') while the
1171
+ // caller's finalizeHeadlessResult stamped metadata.json 'error' off the return value below.
1172
+ // The live workspace and `amicus watch` read progress.json DIRECTLY (live-doc.js
1173
+ // enrichLegUsage, council-legs.js), so a leg that exploded rendered as still-streaming
1174
+ // forever: exactly the stale-state class LC-3 closed one path over.
1175
+ //
1176
+ // ⚠️ This runs INSIDE an error handler: it must never throw and must never mask the original
1177
+ // error, so the whole thing sits in its own try and its failure is a debug line, exactly like
1178
+ // the success path's write. The stage comes from the same single source of truth that path
1179
+ // uses, so progress.json's stage and metadata.json's status still cannot disagree.
1180
+ //
1181
+ // ⚠️ The prior `usage` is READ BACK and re-attached deliberately. writeProgress REBUILDS
1182
+ // progress.json from `{stage, stageLabel, updatedAt, ...extra}` — it does not merge — so a
1183
+ // bare terminal write would silently delete whatever real spend the last 'receiving' flush had
1184
+ // already recorded, trading a stale-stage bug for a cost-under-report on exactly the legs that
1185
+ // failed. There are no settled totals on this path (that is what the exception cost us), so
1186
+ // carrying the last known usage forward unchanged is the honest maximum.
1187
+ //
1188
+ // v4.4.1 M3 — scope of "the last known ones": `usage` ONLY. That same 'receiving' flush also
1189
+ // wrote `p.extra` (e.g. `messagesReceived`), and that is deliberately left to drop here, not
1190
+ // carried forward too — the same call LC-3's success-path terminal write already made (see
1191
+ // that block's comment above): readProgress() derives `messages` from conversation.jsonl's
1192
+ // assistant entries directly and only falls back to `messagesReceived` when there are none, so
1193
+ // restating a stale count on an exception — where conversation.jsonl is the more truthful,
1194
+ // already-mirrored source — could only disagree with the file it exists to summarize.
1195
+ try {
1196
+ let priorUsage = null;
1197
+ try {
1198
+ const prior = JSON.parse(fs.readFileSync(path.join(sessionDir, 'progress.json'), 'utf-8'));
1199
+ if (prior && prior.usage) { priorUsage = prior.usage; }
1200
+ } catch { /* no readable prior record: write the terminal stage without usage */ }
1201
+ const { resolveTerminalState } = require('./sidecar/session-finalize');
1202
+ const stage = resolveTerminalState({ error: error.message }).status;
1203
+ writeProgress(sessionDir, stage, priorUsage ? { usage: priorUsage } : {});
1204
+ } catch (progressErr) {
1205
+ logger.debug('terminal progress write failed after exception (best-effort)', {
1206
+ taskId, error: progressErr.message,
1207
+ });
1208
+ }
711
1209
  const { emptyUsageTotals } = require('./utils/pricing');
712
1210
  return {
713
1211
  summary: '',
@@ -808,4 +1306,8 @@ module.exports = {
808
1306
  POLL_CALL_TIMEOUT_MS,
809
1307
  MAX_CONSECUTIVE_POLL_FAILURES,
810
1308
  TOOL_CALL_STALL_MS,
1309
+ USAGE_SETTLE_POLLS,
1310
+ USAGE_SETTLE_INTERVAL_MS,
1311
+ USAGE_SETTLE_CALL_TIMEOUT_MS,
1312
+ TOOL_SETTLE_GRACE_MS,
811
1313
  };