muonroi-cli 1.8.5 → 1.9.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 (206) hide show
  1. package/dist/packages/agent-harness-core/src/event-filter.js +11 -0
  2. package/dist/packages/agent-harness-core/src/event-redact.js +7 -0
  3. package/dist/packages/agent-harness-core/src/event-tee.d.ts +20 -4
  4. package/dist/packages/agent-harness-core/src/event-tee.js +32 -5
  5. package/dist/packages/agent-harness-core/src/mcp-server.d.ts +14 -0
  6. package/dist/packages/agent-harness-core/src/mcp-server.js +86 -10
  7. package/dist/packages/agent-harness-core/src/predicate.d.ts +1 -1
  8. package/dist/packages/agent-harness-core/src/protocol.d.ts +24 -2
  9. package/dist/src/agent-harness/mock-model.d.ts +10 -0
  10. package/dist/src/agent-harness/mock-model.js +6 -2
  11. package/dist/src/chat/chat-keychain.d.ts +7 -12
  12. package/dist/src/chat/chat-keychain.js +19 -86
  13. package/dist/src/cli/keys.d.ts +8 -45
  14. package/dist/src/cli/keys.js +18 -324
  15. package/dist/src/council/clarifier.d.ts +4 -2
  16. package/dist/src/council/clarifier.js +158 -36
  17. package/dist/src/council/debate-planner.js +3 -2
  18. package/dist/src/council/debate.js +59 -5
  19. package/dist/src/council/index.d.ts +23 -0
  20. package/dist/src/council/index.js +37 -2
  21. package/dist/src/council/llm.d.ts +62 -0
  22. package/dist/src/council/llm.js +123 -23
  23. package/dist/src/council/panel-select.js +13 -3
  24. package/dist/src/council/planner.js +17 -0
  25. package/dist/src/council/preflight.d.ts +10 -0
  26. package/dist/src/council/preflight.js +36 -0
  27. package/dist/src/council/prompts.d.ts +9 -1
  28. package/dist/src/council/prompts.js +23 -6
  29. package/dist/src/council/types.d.ts +10 -0
  30. package/dist/src/ee/auth.d.ts +19 -0
  31. package/dist/src/ee/auth.js +39 -0
  32. package/dist/src/ee/client.js +28 -3
  33. package/dist/src/ee/ee-onboarding.js +6 -26
  34. package/dist/src/flow/compaction/compress.d.ts +2 -2
  35. package/dist/src/flow/compaction/compress.js +21 -8
  36. package/dist/src/flow/compaction/extract.d.ts +3 -3
  37. package/dist/src/flow/compaction/extract.js +6 -6
  38. package/dist/src/flow/compaction/index.d.ts +2 -1
  39. package/dist/src/flow/compaction/index.js +29 -3
  40. package/dist/src/flow/compaction/progress.d.ts +35 -0
  41. package/dist/src/flow/compaction/progress.js +35 -0
  42. package/dist/src/generated/version.d.ts +1 -1
  43. package/dist/src/generated/version.js +1 -1
  44. package/dist/src/gsd/flags.d.ts +11 -0
  45. package/dist/src/gsd/flags.js +19 -0
  46. package/dist/src/gsd/plan-council.js +104 -72
  47. package/dist/src/gsd/verdict-schema.d.ts +1 -1
  48. package/dist/src/headless/council-answers.js +4 -0
  49. package/dist/src/index.js +129 -260
  50. package/dist/src/lsp/builtins.js +3 -1
  51. package/dist/src/lsp/manager.d.ts +5 -1
  52. package/dist/src/lsp/manager.js +249 -3
  53. package/dist/src/lsp/npm-cache.d.ts +11 -1
  54. package/dist/src/lsp/npm-cache.js +17 -1
  55. package/dist/src/lsp/runtime.d.ts +6 -1
  56. package/dist/src/lsp/runtime.js +17 -1
  57. package/dist/src/lsp/types.d.ts +83 -1
  58. package/dist/src/lsp/types.js +10 -0
  59. package/dist/src/mcp/client-pool.js +43 -15
  60. package/dist/src/mcp/lsp-tools.d.ts +5 -1
  61. package/dist/src/mcp/lsp-tools.js +93 -2
  62. package/dist/src/mcp/mcp-keychain.d.ts +3 -5
  63. package/dist/src/mcp/mcp-keychain.js +9 -49
  64. package/dist/src/mcp/setup-guide-text.d.ts +1 -1
  65. package/dist/src/mcp/setup-guide-text.js +22 -2
  66. package/dist/src/mcp/tools-server.d.ts +10 -0
  67. package/dist/src/mcp/tools-server.js +10 -2
  68. package/dist/src/models/catalog.json +19 -19
  69. package/dist/src/orchestrator/ask-user.d.ts +61 -0
  70. package/dist/src/orchestrator/ask-user.js +65 -0
  71. package/dist/src/orchestrator/compaction.d.ts +2 -3
  72. package/dist/src/orchestrator/compaction.js +8 -8
  73. package/dist/src/orchestrator/council-manager.js +9 -8
  74. package/dist/src/orchestrator/council-request.d.ts +49 -0
  75. package/dist/src/orchestrator/council-request.js +62 -0
  76. package/dist/src/orchestrator/interactive-pause.d.ts +26 -0
  77. package/dist/src/orchestrator/interactive-pause.js +36 -0
  78. package/dist/src/orchestrator/message-processor.d.ts +4 -0
  79. package/dist/src/orchestrator/message-processor.js +26 -8
  80. package/dist/src/orchestrator/orchestrator.d.ts +25 -0
  81. package/dist/src/orchestrator/orchestrator.js +204 -50
  82. package/dist/src/orchestrator/preprocessor.js +2 -2
  83. package/dist/src/orchestrator/safety-askcard.d.ts +1 -1
  84. package/dist/src/orchestrator/safety-askcard.js +5 -2
  85. package/dist/src/orchestrator/safety-intercept.d.ts +5 -0
  86. package/dist/src/orchestrator/safety-intercept.js +7 -0
  87. package/dist/src/orchestrator/stall-watchdog.d.ts +8 -1
  88. package/dist/src/orchestrator/stall-watchdog.js +24 -3
  89. package/dist/src/orchestrator/stream-runner.d.ts +13 -3
  90. package/dist/src/orchestrator/stream-runner.js +54 -21
  91. package/dist/src/orchestrator/tool-engine.d.ts +19 -0
  92. package/dist/src/orchestrator/tool-engine.js +241 -25
  93. package/dist/src/orchestrator/turn-watchdog.d.ts +7 -0
  94. package/dist/src/orchestrator/turn-watchdog.js +38 -9
  95. package/dist/src/pil/agent-operating-contract.d.ts +1 -1
  96. package/dist/src/pil/agent-operating-contract.js +6 -4
  97. package/dist/src/pil/discovery.d.ts +1 -1
  98. package/dist/src/pil/discovery.js +2 -2
  99. package/dist/src/pil/layer1_5-complexity-size.d.ts +7 -0
  100. package/dist/src/pil/layer1_5-complexity-size.js +31 -5
  101. package/dist/src/pil/llm-classify.d.ts +78 -3
  102. package/dist/src/pil/llm-classify.js +351 -111
  103. package/dist/src/pil/native-capabilities-workbook.d.ts +1 -1
  104. package/dist/src/pil/native-capabilities-workbook.js +7 -0
  105. package/dist/src/pil/pipeline.js +2 -0
  106. package/dist/src/pil/repo-grounding-probe.d.ts +15 -0
  107. package/dist/src/pil/repo-grounding-probe.js +136 -0
  108. package/dist/src/pil/repo-structure-hints.d.ts +7 -0
  109. package/dist/src/pil/repo-structure-hints.js +45 -0
  110. package/dist/src/product-loop/artifact-io.js +4 -0
  111. package/dist/src/product-loop/criteria-seed.d.ts +51 -0
  112. package/dist/src/product-loop/criteria-seed.js +200 -0
  113. package/dist/src/product-loop/discovery-interview.d.ts +9 -0
  114. package/dist/src/product-loop/discovery-interview.js +37 -18
  115. package/dist/src/product-loop/discovery-recommender.js +2 -1
  116. package/dist/src/product-loop/discovery-schema.js +14 -1
  117. package/dist/src/product-loop/discovery-triage.d.ts +23 -0
  118. package/dist/src/product-loop/discovery-triage.js +109 -0
  119. package/dist/src/product-loop/gather.js +150 -2
  120. package/dist/src/product-loop/index.js +7 -0
  121. package/dist/src/product-loop/loop-driver.js +21 -8
  122. package/dist/src/product-loop/phase-plan.d.ts +16 -0
  123. package/dist/src/product-loop/phase-plan.js +42 -4
  124. package/dist/src/product-loop/phase-rituals.d.ts +3 -0
  125. package/dist/src/product-loop/phase-rituals.js +8 -3
  126. package/dist/src/product-loop/phase-runner.js +30 -11
  127. package/dist/src/product-loop/plan-adherence-review.d.ts +26 -0
  128. package/dist/src/product-loop/plan-adherence-review.js +144 -0
  129. package/dist/src/product-loop/sprint-runner.d.ts +62 -0
  130. package/dist/src/product-loop/sprint-runner.js +309 -8
  131. package/dist/src/product-loop/types.d.ts +25 -0
  132. package/dist/src/providers/anthropic.d.ts +9 -8
  133. package/dist/src/providers/anthropic.js +13 -47
  134. package/dist/src/providers/auth/grok-oauth.d.ts +1 -0
  135. package/dist/src/providers/auth/grok-oauth.js +30 -5
  136. package/dist/src/providers/auth/openai-oauth.d.ts +1 -0
  137. package/dist/src/providers/auth/openai-oauth.js +14 -0
  138. package/dist/src/providers/auth/token-store.d.ts +9 -9
  139. package/dist/src/providers/auth/token-store.js +8 -70
  140. package/dist/src/providers/auth/types.d.ts +8 -0
  141. package/dist/src/providers/env-store.d.ts +17 -0
  142. package/dist/src/providers/env-store.js +228 -0
  143. package/dist/src/providers/keychain.d.ts +21 -17
  144. package/dist/src/providers/keychain.js +124 -135
  145. package/dist/src/providers/runtime.d.ts +24 -9
  146. package/dist/src/providers/runtime.js +48 -37
  147. package/dist/src/providers/strategies/thinking-mode.js +9 -1
  148. package/dist/src/providers/strategies/xai.strategy.js +27 -0
  149. package/dist/src/providers/warm.d.ts +65 -0
  150. package/dist/src/providers/warm.js +145 -0
  151. package/dist/src/self-qa/agentic-loop.js +3 -2
  152. package/dist/src/storage/transcript.js +56 -2
  153. package/dist/src/tools/git-safety.d.ts +19 -0
  154. package/dist/src/tools/git-safety.js +168 -0
  155. package/dist/src/tools/native-tools.d.ts +1 -1
  156. package/dist/src/tools/native-tools.js +76 -1
  157. package/dist/src/tools/registry.d.ts +17 -0
  158. package/dist/src/tools/registry.js +116 -1
  159. package/dist/src/types/index.d.ts +29 -1
  160. package/dist/src/ui/app.js +67 -4
  161. package/dist/src/ui/components/agent-rail-activities.d.ts +26 -0
  162. package/dist/src/ui/components/agent-rail-activities.js +47 -0
  163. package/dist/src/ui/components/compact-progress-card.d.ts +24 -0
  164. package/dist/src/ui/components/compact-progress-card.js +42 -0
  165. package/dist/src/ui/components/council-phase-timeline.js +17 -2
  166. package/dist/src/ui/components/council-question-card.js +1 -0
  167. package/dist/src/ui/components/message-view.d.ts +15 -0
  168. package/dist/src/ui/components/message-view.js +50 -1
  169. package/dist/src/ui/components/tool-group.d.ts +15 -3
  170. package/dist/src/ui/components/tool-group.js +69 -11
  171. package/dist/src/ui/containers/modals-layer.d.ts +2 -1
  172. package/dist/src/ui/containers/modals-layer.js +2 -2
  173. package/dist/src/ui/council-harness-event.d.ts +57 -0
  174. package/dist/src/ui/council-harness-event.js +46 -0
  175. package/dist/src/ui/heartbeat-debug.d.ts +29 -0
  176. package/dist/src/ui/heartbeat-debug.js +45 -0
  177. package/dist/src/ui/modals/api-key-modal.js +1 -1
  178. package/dist/src/ui/modals/model-picker-modal.d.ts +8 -18
  179. package/dist/src/ui/modals/model-picker-modal.js +8 -10
  180. package/dist/src/ui/slash/ee.js +81 -0
  181. package/dist/src/ui/slash/menu-items.js +11 -2
  182. package/dist/src/ui/use-app-logic.js +354 -224
  183. package/dist/src/ui/utils/agent-activities.d.ts +39 -0
  184. package/dist/src/ui/utils/agent-activities.js +96 -0
  185. package/dist/src/ui/utils/group-tool-entries.d.ts +26 -0
  186. package/dist/src/ui/utils/group-tool-entries.js +111 -0
  187. package/dist/src/ui/utils/tool-summary.d.ts +21 -0
  188. package/dist/src/ui/utils/tool-summary.js +91 -0
  189. package/dist/src/utils/event-loop-monitor.d.ts +85 -0
  190. package/dist/src/utils/event-loop-monitor.js +107 -0
  191. package/dist/src/utils/llm-deadline.d.ts +14 -0
  192. package/dist/src/utils/llm-deadline.js +19 -0
  193. package/dist/src/utils/loop-profiler.d.ts +102 -0
  194. package/dist/src/utils/loop-profiler.js +202 -0
  195. package/dist/src/utils/settings.d.ts +27 -0
  196. package/dist/src/utils/settings.js +38 -2
  197. package/dist/src/utils/side-question.d.ts +1 -2
  198. package/dist/src/utils/side-question.js +2 -2
  199. package/dist/src/verify/entrypoint.js +51 -16
  200. package/dist/src/verify/orchestrator.d.ts +1 -1
  201. package/dist/src/verify/orchestrator.js +20 -3
  202. package/package.json +1 -2
  203. package/dist/src/cli/bw-vault.d.ts +0 -55
  204. package/dist/src/cli/bw-vault.js +0 -133
  205. package/dist/src/mcp/ee-tools.d.ts +0 -46
  206. package/dist/src/mcp/ee-tools.js +0 -194
@@ -26,6 +26,7 @@
26
26
  // - C3 (cross-turn dedup wrap) — wrapToolSetWithDedup
27
27
  // - F1 (sub-agent cumulative cap) — wrapToolSetWithCap
28
28
  // - reasoning-strip (provider quirk) — taskCaps.sanitizeHistory
29
+ import { appendFileSync } from "node:fs";
29
30
  import { stepCountIs, streamText } from "ai";
30
31
  import { recordArtifact } from "../ee/artifact-cache.js";
31
32
  import { getDefaultEEClient } from "../ee/intercept.js";
@@ -36,7 +37,7 @@ import { injectCheapModelWorkbook, shouldInjectCheapModelWorkbook, subagentTaskT
36
37
  import { getProviderCapabilities } from "../providers/capabilities.js";
37
38
  import { getVisionGuidanceForTextOnly } from "../providers/mcp-vision-bridge.js";
38
39
  import { captureToolSchemas } from "../providers/patch-zod-schema.js";
39
- import { buildTurnProviderOptions, requireRuntimeProvider, resolveModelRuntime, resolveTemperatureParam, shouldDropParam, } from "../providers/runtime.js";
40
+ import { buildTurnProviderOptions, factoryForModel, requireRuntimeProvider, resolveModelRuntime, resolveTemperatureParam, shouldDropParam, } from "../providers/runtime.js";
40
41
  import { needsVisionProxy } from "../providers/vision-proxy.js";
41
42
  import { wireDebug } from "../providers/wire-debug.js";
42
43
  import { statusBarStore } from "../state/status-bar-store.js";
@@ -60,6 +61,35 @@ import { createStallWatchdog, STALL_ERROR_MESSAGE } from "./stall-watchdog.js";
60
61
  import { wrapToolSetWithCap } from "./sub-agent-cap.js";
61
62
  import { applyAnthropicPromptCaching, compactSubAgentMessages } from "./subagent-compactor.js";
62
63
  import { combineAbortSignals, firstLine, formatSubagentActivity } from "./tool-utils.js";
64
+ /**
65
+ * Emit one sub-agent diagnostic line (MUONROI_DEBUG_SUBAGENT=1).
66
+ *
67
+ * Writes to the file named by MUONROI_SUBAGENT_DEBUG_LOG when set, else stderr.
68
+ * The file sink exists because stderr is a dead end for the case this
69
+ * diagnostic was written for: under the MCP harness the TUI runs as a child
70
+ * whose stderr nobody reads (opentui-spawn.ts consumes only the fd3/named-pipe
71
+ * stream), so every line went into a pipe no one drains. G1 — "Task failed: No
72
+ * output generated" — was named, given this flag, and never diagnosed, because
73
+ * the diagnostic could not reach whoever turned it on. Mirrors the working
74
+ * MUONROI_COUNCIL_DEBUG_LOG sink in council/llm.ts.
75
+ */
76
+ export function writeSubagentDebug(enabled, line) {
77
+ if (!enabled)
78
+ return;
79
+ const text = `[subagent] ${line}\n`;
80
+ const path = process.env.MUONROI_SUBAGENT_DEBUG_LOG?.trim();
81
+ if (!path) {
82
+ process.stderr.write(text);
83
+ return;
84
+ }
85
+ try {
86
+ appendFileSync(path, text);
87
+ }
88
+ catch (err) {
89
+ // Never let diagnostics become the failure. Fall back to stderr and say why.
90
+ process.stderr.write(`[subagent] debug-log append failed (path=${path}): ${err instanceof Error ? err.message : String(err)}\n${text}`);
91
+ }
92
+ }
63
93
  /**
64
94
  * StreamRunner — extracted sub-agent stream lifecycle.
65
95
  *
@@ -81,7 +111,6 @@ export class StreamRunner {
81
111
  * Returns either a prepared call or a short-circuit ToolResult.
82
112
  */
83
113
  async setup(request, onActivity, signal) {
84
- const provider = this.deps.getProvider();
85
114
  const agentKey = String(request.agent);
86
115
  const isExplore = agentKey === "explore";
87
116
  const isGeneral = agentKey === "general";
@@ -189,12 +218,18 @@ export class StreamRunner {
189
218
  if (childModelId !== topModelId) {
190
219
  statusBarStore.setState({ routed_from: topModelId, model: childModelId });
191
220
  }
221
+ // The vision branch must build its model from the CHILD model's own factory.
222
+ // It previously called the PARENT session's factory directly, so a sub-agent
223
+ // routed to another provider had its id POSTed to the parent's endpoint.
192
224
  const childRuntime = isVision
193
- ? {
194
- ...resolveModelRuntime(provider, childModelId),
195
- model: provider.responses?.(childModelId) ?? provider(childModelId),
196
- }
197
- : resolveModelRuntime(provider, childModelId);
225
+ ? (() => {
226
+ const childFactory = factoryForModel(childModelId);
227
+ return {
228
+ ...resolveModelRuntime(childModelId),
229
+ model: childFactory.responses?.(childModelId) ?? childFactory(childModelId),
230
+ };
231
+ })()
232
+ : resolveModelRuntime(childModelId);
198
233
  const taskCaps = getProviderCapabilities(requireRuntimeProvider(childRuntime));
199
234
  if (isComputer && !taskCaps.supportsClientTools(childRuntime.modelInfo)) {
200
235
  return {
@@ -321,10 +356,7 @@ export class StreamRunner {
321
356
  // diagnose "No output generated" / silent task failures (e.g. with
322
357
  // gpt-5.4 reasoning models). Disabled by default — zero cost.
323
358
  const debugSubagent = process.env.MUONROI_DEBUG_SUBAGENT === "1";
324
- const debugLog = (line) => {
325
- if (debugSubagent)
326
- process.stderr.write(`[subagent] ${line}\n`);
327
- };
359
+ const debugLog = writeSubagentDebug.bind(null, debugSubagent);
328
360
  if (debugSubagent) {
329
361
  const mi = childRuntime.modelInfo;
330
362
  debugLog(`start: model=${childRuntime.modelId} provider=${mi?.provider} reasoning=${mi?.reasoning} thinkingType=${mi?.thinkingType} supportsClientTools=${mi?.supportsClientTools} supportsMaxOutputTokens=${mi?.supportsMaxOutputTokens} agent=${prepared.request.agent}`);
@@ -789,30 +821,31 @@ export class StreamRunner {
789
821
  // MUONROI_DEBUG_SUBAGENT=1 so we can see whether `msg` is "No output
790
822
  // generated" (AI SDK validation failure on empty assistant response)
791
823
  // or a deeper provider error.
792
- if (process.env.MUONROI_DEBUG_SUBAGENT === "1") {
824
+ const debugCatch = process.env.MUONROI_DEBUG_SUBAGENT === "1";
825
+ if (debugCatch) {
793
826
  const e = err;
794
- process.stderr.write(`[subagent] catch: name=${e.name ?? "?"} statusCode=${e.statusCode ?? "?"} message=${(e.message ?? "").slice(0, 400)}\n`);
827
+ writeSubagentDebug(true, `catch: name=${e.name ?? "?"} statusCode=${e.statusCode ?? "?"} message=${(e.message ?? "").slice(0, 400)}`);
795
828
  if (e.cause !== undefined) {
796
829
  try {
797
- process.stderr.write(`[subagent] catch.cause=${JSON.stringify(e.cause, Object.getOwnPropertyNames(e.cause)).slice(0, 600)}\n`);
830
+ writeSubagentDebug(true, `catch.cause=${JSON.stringify(e.cause, Object.getOwnPropertyNames(e.cause)).slice(0, 600)}`);
798
831
  }
799
- catch {
800
- process.stderr.write(`[subagent] catch.cause(string)=${String(e.cause).slice(0, 400)}\n`);
832
+ catch (jsonErr) {
833
+ writeSubagentDebug(true, `catch.cause(string)=${String(e.cause).slice(0, 400)} [stringify failed: ${jsonErr instanceof Error ? jsonErr.message : String(jsonErr)}]`);
801
834
  }
802
835
  }
803
836
  if (e.responseBody !== undefined) {
804
- process.stderr.write(`[subagent] catch.responseBody=${String(e.responseBody).slice(0, 600)}\n`);
837
+ writeSubagentDebug(true, `catch.responseBody=${String(e.responseBody).slice(0, 600)}`);
805
838
  }
806
839
  if (e.data !== undefined) {
807
840
  try {
808
- process.stderr.write(`[subagent] catch.data=${JSON.stringify(e.data).slice(0, 400)}\n`);
841
+ writeSubagentDebug(true, `catch.data=${JSON.stringify(e.data).slice(0, 400)}`);
809
842
  }
810
- catch {
811
- /* non-serializable */
843
+ catch (jsonErr) {
844
+ writeSubagentDebug(true, `catch.data(unserializable): ${jsonErr instanceof Error ? jsonErr.message : String(jsonErr)}`);
812
845
  }
813
846
  }
814
847
  if (e.stack)
815
- process.stderr.write(`[subagent] stack: ${e.stack.split("\n").slice(0, 6).join(" | ")}\n`);
848
+ writeSubagentDebug(true, `stack: ${e.stack.split("\n").slice(0, 6).join(" | ")}`);
816
849
  }
817
850
  const output = `Task failed: ${msg}`;
818
851
  return {
@@ -7,6 +7,7 @@ import type { SessionInfo, StreamChunk, SubagentStatus } from "../types/index.js
7
7
  import { type PermissionMode } from "../utils/permission-mode.js";
8
8
  import type { AbortContext } from "./abort.js";
9
9
  import type { LegacyProvider, ProcessMessageObserver } from "./agent-options.js";
10
+ import type { AskUserAskInfo } from "./ask-user.js";
10
11
  import type { CouncilManager } from "./council-manager.js";
11
12
  import type { CrossTurnDedup } from "./cross-turn-dedup.js";
12
13
  import type { PendingCallsLog } from "./pending-calls.js";
@@ -136,6 +137,8 @@ export interface MessageProcessorDeps extends TurnRunnerDepsBase {
136
137
  askToolLoopContinue?: ToolLoopCapAsk;
137
138
  /** Safety override handler — invoked when a tool call is blocked by the safety filter. */
138
139
  askSafetyOverride?: (info: SafetyOverrideAskInfo) => Promise<SafetyOverrideVerdict>;
140
+ /** ask_user handler — invoked when the model calls the `ask_user` tool; resolves the human's answer. */
141
+ askUser?: (info: AskUserAskInfo) => Promise<string>;
139
142
  runCouncilV2(userMessage: string, opts: {
140
143
  skipClarification: boolean;
141
144
  observer?: ProcessMessageObserver;
@@ -168,6 +171,22 @@ export declare function rewriteSafetyApprovedToolResults<T extends {
168
171
  role: string;
169
172
  content?: any;
170
173
  }>(messages: T[]): T[];
174
+ /**
175
+ * Replace the `result` value of a single tool-result part (matched by
176
+ * toolCallId) in an AI-SDK message history, in place, preserving the
177
+ * tool-call/tool-result pairing. Used by the convene_council path to splice the
178
+ * council synthesis into the placeholder tool_result the model saw, so on the
179
+ * restarted step the model reads the conclusion as that tool's result. Pure +
180
+ * exported for unit testing. Returns the same array reference untouched when the
181
+ * toolCallId is absent (with a caller-visible `replaced` flag).
182
+ */
183
+ export declare function spliceConveneToolResult<T extends {
184
+ role: string;
185
+ content?: any;
186
+ }>(messages: T[], toolCallId: string | null, value: string): {
187
+ messages: T[];
188
+ replaced: boolean;
189
+ };
171
190
  export declare class SimpleMutex {
172
191
  private queue;
173
192
  run<T>(fn: () => Promise<T> | T): Promise<T>;
@@ -79,6 +79,7 @@ import { createBuiltinTools } from "../tools/registry.js";
79
79
  import { snapshotFromTodoWriteArgs } from "../tools/todo-write-snapshot.js";
80
80
  import { visionToolsNeeded } from "../tools/vision-gate.js";
81
81
  import { appendDecisionLog } from "../usage/decision-log.js";
82
+ import { setLoopBreadcrumb } from "../utils/event-loop-monitor.js";
82
83
  import { logger } from "../utils/logger.js";
83
84
  import { openUrl } from "../utils/open-url.js";
84
85
  import { appendAudit, toolNeedsApproval } from "../utils/permission-mode.js";
@@ -88,9 +89,11 @@ import { resolveShell } from "../utils/shell.js";
88
89
  import { foldDynamicTailIntoUserMessage, splitFrontAndDynamicTail } from "./cache-prefix.js";
89
90
  import { consumeProactiveCompact } from "./compact-request.js";
90
91
  import { relaxCompactionSettings } from "./compaction.js";
92
+ import { consumeCouncilConvene, hasPendingCouncilConvene, peekCouncilConveneToolCallId } from "./council-request.js";
91
93
  import { wrapToolSetWithDedup } from "./cross-turn-dedup.js";
92
94
  import { humanizeApiError, isAuthenticationError, isContextLimitError, summarizeApiErrorForLog } from "./error-utils.js";
93
95
  import { buildGroundingFootnote, findUnverifiedClaims } from "./grounding-check.js";
96
+ import { isInteractivePaused } from "./interactive-pause.js";
94
97
  import { buildInterruptedTurnNote } from "./interrupted-turn.js";
95
98
  import { stableCallId } from "./pending-calls.js";
96
99
  import { buildMcpCapabilityBlock, MAX_LLM_CALLS_PER_TURN, } from "./prompts.js";
@@ -225,6 +228,38 @@ export function rewriteSafetyApprovedToolResults(messages) {
225
228
  return changed ? { ...m, content: newContent } : m;
226
229
  });
227
230
  }
231
+ /**
232
+ * Replace the `result` value of a single tool-result part (matched by
233
+ * toolCallId) in an AI-SDK message history, in place, preserving the
234
+ * tool-call/tool-result pairing. Used by the convene_council path to splice the
235
+ * council synthesis into the placeholder tool_result the model saw, so on the
236
+ * restarted step the model reads the conclusion as that tool's result. Pure +
237
+ * exported for unit testing. Returns the same array reference untouched when the
238
+ * toolCallId is absent (with a caller-visible `replaced` flag).
239
+ */
240
+ export function spliceConveneToolResult(messages, toolCallId, value) {
241
+ if (!toolCallId)
242
+ return { messages, replaced: false };
243
+ let replaced = false;
244
+ const out = messages.map((m) => {
245
+ if (m.role !== "tool" || !Array.isArray(m.content))
246
+ return m;
247
+ let changed = false;
248
+ const newContent = m.content.map((part) => {
249
+ if (part?.type === "tool-result" && part?.toolCallId === toolCallId) {
250
+ changed = true;
251
+ replaced = true;
252
+ // AI SDK v6 tool-result parts carry the value under `output` (typed) or
253
+ // `result` (legacy). Set both so whichever the provider serializer reads
254
+ // sees the synthesis, and clear any error flag.
255
+ return { ...part, isError: false, output: value, result: value };
256
+ }
257
+ return part;
258
+ });
259
+ return changed ? { ...m, content: newContent } : m;
260
+ });
261
+ return { messages: replaced ? out : messages, replaced };
262
+ }
228
263
  export class SimpleMutex {
229
264
  queue = Promise.resolve();
230
265
  async run(fn) {
@@ -284,6 +319,17 @@ export async function* executeToolEngine(args) {
284
319
  // (default 6, env MUONROI_TOOL_LIMIT_AUTO_RECOVER_CAP).
285
320
  let toolLimitAutoRecoverCount = 0;
286
321
  const TOOL_LIMIT_AUTO_RECOVER_CAP = getToolLimitAutoRecoverCap();
322
+ // Convene-council loop guard. The agent may convene the council at most once
323
+ // per turn — a second convene_council call in the SAME turn does NOT re-run a
324
+ // full (5-10min) council; instead its tool-result is a non-binding suggestion
325
+ // to USE the synthesis already above (respond, or ask_user). Turn-scoped (not
326
+ // per-stream-attempt) so a `continue` restart cannot reset it. Live-caught:
327
+ // the model looped convene→synthesis→convene, burning a second council.
328
+ let conveneRunsThisTurn = 0;
329
+ const COUNCIL_MAX_CONVENES_PER_TURN = (() => {
330
+ const raw = Number.parseInt(process.env.MUONROI_MAX_CONVENES_PER_TURN ?? "", 10);
331
+ return Number.isFinite(raw) && raw >= 1 ? raw : 1;
332
+ })();
287
333
  let stallTriggered = false;
288
334
  // Time-to-first-byte stall RE-PROMPT: some providers (observed:
289
335
  // xai/grok-build-0.1) accept the request then never send the first byte —
@@ -432,18 +478,23 @@ export async function* executeToolEngine(args) {
432
478
  skipClarification: !isAutoCouncilClarifyEnabled(),
433
479
  observer,
434
480
  userModelMessage,
481
+ // Suppress the CLI-hardcoded post-debate option card. The follow-up is
482
+ // decided by the agent's own intent via the neutral continuation below,
483
+ // not a fixed CLI menu. (Pre-debate clarification is orthogonal and still
484
+ // runs per skipClarification.)
485
+ convenePath: true,
435
486
  });
436
487
  const synthesis = deps.councilManager.lastSynthesis;
437
488
  const chosenAction = deps.councilManager.lastPostDebateAction;
438
489
  deps.councilManager.setLastSynthesis(null);
439
490
  deps.councilManager.setLastPostDebateAction(null);
440
- // Honor the user's post-debate choice instead of always continuing: an
441
- // evaluation/decision debate whose deliverable is the conclusion (default
442
- // save_exit) now returns to the composer rather than being force-fed a
443
- // meaningless "proceed with the action items" turn. postDebateContinuation
444
- // is shared with the /council slash path (orchestrator.runCouncilV2).
445
- const { postDebateContinuation } = await import("../council/index.js");
446
- const continuationPrompt = synthesis ? postDebateContinuation(chosenAction ?? undefined, synthesis) : null;
491
+ // convenePath suppressed the hardcoded card, so there is no chosenAction to
492
+ // branch on. Hand the synthesis to a normal agent turn with a non-binding
493
+ // nudge and let the agent decide the next step (respond / ask_user /
494
+ // implement). Re-entry is guarded by setContinuation(true) below so
495
+ // shouldAutoCouncil (which checks !isContinuation) can't re-fire into a loop.
496
+ const { buildNeutralPostCouncilContinuation } = await import("../council/index.js");
497
+ const continuationPrompt = synthesis ? buildNeutralPostCouncilContinuation(synthesis) || null : null;
447
498
  if (continuationPrompt) {
448
499
  yield { type: "content", content: "\n[Auto-continuing with council recommendations...]\n" };
449
500
  deps.councilManager.setContinuation(true);
@@ -456,12 +507,10 @@ export async function* executeToolEngine(args) {
456
507
  }
457
508
  return;
458
509
  }
459
- if (shouldSkipForReasoning) {
460
- yield {
461
- type: "content",
462
- content: `\n[Auto-council skipped: ${deps.modelId} is a reasoning model and already performs internal self-debate. Set MUONROI_AUTOCOUNCIL_SKIP_REASONING=0 or autoCouncilSkipReasoning=false to force council.]\n`,
463
- };
464
- }
510
+ // Skipping auto-council is the normal, expected path for a reasoning model —
511
+ // not an event the user needs narrated on every turn. The decision (and the
512
+ // gate values behind it) is still recorded via autoCouncilSkipReason above,
513
+ // so forensics keep the full story without the transcript noise.
465
514
  if (deps.batchApi) {
466
515
  try {
467
516
  yield* deps.processMessageBatchTurn({
@@ -612,15 +661,44 @@ export async function* executeToolEngine(args) {
612
661
  sessionId: deps.session?.id,
613
662
  includeVisionTools,
614
663
  consultParentSession: deps.consultParentSession,
664
+ // Register convene_council only when the council is actually usable
665
+ // for this session (enough configured roles) — else the model could
666
+ // call a council that can't convene.
667
+ councilConfigured: configuredRoleCount >= autoCouncilMinRoles,
668
+ askUser: deps.askUser,
615
669
  runDebate: async (topic) => {
670
+ // Reset before draining so a generator that throws BEFORE setting
671
+ // synthesis (orchestrator.setLastSynthesis) cannot return a STALE
672
+ // synthesis from a prior council run.
673
+ deps.councilManager.setLastSynthesis(null);
616
674
  const gen = deps.runCouncilV2(topic, {
617
675
  skipClarification: true,
618
676
  userModelMessage: { role: "user", content: `/council ${topic}` },
677
+ // Model-callable debate: the synthesis is returned to the model as
678
+ // the tool result and the model decides the follow-up — so suppress
679
+ // the CLI-hardcoded post-debate card, same as convene_council.
680
+ convenePath: true,
619
681
  });
682
+ // Capture a tail of content chunks so an empty-synthesis failure
683
+ // (provider unreachable / sub-phase fail-open / abort — all of which
684
+ // yield a content hint then `return null`) surfaces a real reason in
685
+ // the log instead of a silent "".
686
+ let lastContentHint = "";
620
687
  for await (const chunk of gen) {
621
- // Drain the generator
688
+ const text = chunk?.content;
689
+ if (chunk?.type === "content" && typeof text === "string" && text.trim()) {
690
+ lastContentHint = text.trim().slice(-200);
691
+ }
692
+ }
693
+ const synthesis = deps.councilManager.lastSynthesis ?? "";
694
+ if (!synthesis.trim()) {
695
+ // No-Silent-Catch: the debate produced no synthesis. plan-council
696
+ // will retry then fall back to the perspective path — log why here
697
+ // so the failure is diagnosable remotely.
698
+ console.error(`[tool-engine] plan-review runDebate returned empty synthesis` +
699
+ `${lastContentHint ? ` (last council chunk: ${lastContentHint})` : ""}`);
622
700
  }
623
- return deps.councilManager.lastSynthesis ?? "";
701
+ return synthesis;
624
702
  },
625
703
  });
626
704
  // Top-level cumulative cap state. We accumulate the raw tool set
@@ -799,6 +877,14 @@ export async function* executeToolEngine(args) {
799
877
  "ee_feedback",
800
878
  "ee_write",
801
879
  ]);
880
+ /**
881
+ * Tools that perform mutations (writes, edits, side-effects).
882
+ * MUTATION_TOOLS are read from this set to route them through the mutation
883
+ * gate and prevent accidental execution when GSD hard gate is active.
884
+ * Registration: add the tool name string to this set and ensure the tool
885
+ * itself is wired in the tool assembly step.
886
+ */
887
+ const MUTATION_TOOLS = new Set(["lsp_mutation_preview"]);
802
888
  // Task 8: native GSD mutation gate. Read once per turn (not per call) since
803
889
  // hardGateEnabled/directAnswer don't change mid-turn; the gate itself
804
890
  // re-reads STATE.md per call (cheap fs read) so it stays live if the
@@ -807,12 +893,23 @@ export async function* executeToolEngine(args) {
807
893
  const gsdDirectAnswer = pilCtx.directAnswer;
808
894
  for (const name of Object.keys(tools)) {
809
895
  const tool = tools[name];
810
- if (tool &&
811
- typeof tool.execute === "function" &&
812
- !READ_ONLY_TOOLS.has(name) &&
813
- !name.startsWith("respond_")) {
814
- const originalExecute = tool.execute;
815
- tool.execute = async (input, context) => {
896
+ if (!tool || typeof tool.execute !== "function")
897
+ continue;
898
+ const originalExecute = tool.execute;
899
+ // Read-only tools skip the mutation gate and the write mutex, but ALL
900
+ // tools get a breadcrumb: a freeze can start under any of them, and a
901
+ // block report that cannot name a suspect is much weaker evidence.
902
+ const guarded = !READ_ONLY_TOOLS.has(name) && !name.startsWith("respond_");
903
+ tool.execute = async (input, context) => {
904
+ // Never cleared, only overwritten — a block often starts just AFTER
905
+ // a tool returns (the 2026-07-16 freeze began on the tick after a
906
+ // bash result), so "after:bash" is exactly the clue we want. Also
907
+ // why this is not a stack: with parallel tool calls, last-writer-
908
+ // wins is the honest summary; the CPU profile is the real evidence.
909
+ setLoopBreadcrumb(`tool:${name}`);
910
+ try {
911
+ if (!guarded)
912
+ return await originalExecute(input, context);
816
913
  const gate = evaluateMutationGate(deps.bash.getCwd(), {
817
914
  toolName: name,
818
915
  hardGateEnabled: gsdHardGateEnabled,
@@ -821,9 +918,12 @@ export async function* executeToolEngine(args) {
821
918
  if (gate.blocked) {
822
919
  return { success: false, output: gate.reason, error: gate.reason };
823
920
  }
824
- return writeMutex.run(() => originalExecute(input, context));
825
- };
826
- }
921
+ return await writeMutex.run(() => originalExecute(input, context));
922
+ }
923
+ finally {
924
+ setLoopBreadcrumb(`after-tool:${name}`);
925
+ }
926
+ };
827
927
  }
828
928
  captureToolSchemas(tools);
829
929
  let responseToolCalled = false;
@@ -1225,6 +1325,14 @@ export async function* executeToolEngine(args) {
1225
1325
  _hardCapHit = true;
1226
1326
  return true;
1227
1327
  }
1328
+ // convene_council fast-path: if the model queued a council convening
1329
+ // during this step, STOP now so the outer loop runs the council and
1330
+ // splices the synthesis BEFORE the model consumes the placeholder
1331
+ // tool_result. This is a fast-path only — the authoritative
1332
+ // consumption is the outer-loop check after the stream drains (a
1333
+ // phase-1 SAMR step ends on stepCountIs(1) and never reaches here).
1334
+ if (hasPendingCouncilConvene())
1335
+ return true;
1228
1336
  // Terminal response tool: a `respond_*` call IS the model's final
1229
1337
  // structured answer (its `execute` is identity — the payload lives
1230
1338
  // in the tool-call args). `shouldHaltOnResponseTool` decides if the
@@ -1337,7 +1445,9 @@ export async function* executeToolEngine(args) {
1337
1445
  console.error(`[tool-engine] stream aborted: no text/tool output for ${getProviderProgressTimeoutMs()}ms ` +
1338
1446
  `(runaway reasoning / no forward progress) model=${runtime.modelId}`);
1339
1447
  },
1340
- });
1448
+ },
1449
+ // Hold the stream open while a blocking `ask_user` card awaits a human.
1450
+ isInteractivePaused);
1341
1451
  // F3c — hard-cap LLM calls per turn before this streamText()
1342
1452
  if (++llmCallsThisTurn > MAX_LLM_CALLS_PER_TURN) {
1343
1453
  stall.dispose();
@@ -1808,6 +1918,9 @@ export async function* executeToolEngine(args) {
1808
1918
  const _wireProviderIdTop = runtime.modelInfo?.provider ?? "unknown";
1809
1919
  for await (const part of result.fullStream) {
1810
1920
  stall.pet(); // chunk arrived — reset the stall watchdog
1921
+ // Breadcrumb the chunk type: if the loop blocks while draining the
1922
+ // stream, this says which part kind we were handling when it froze.
1923
+ setLoopBreadcrumb(`stream:${String(part.type ?? "unknown")}`);
1811
1924
  // Count only real content parts. The watchdog abort itself surfaces
1812
1925
  // as an "abort" part — counting it would defeat the TTFB-stall gate
1813
1926
  // (a frozen-before-first-byte stall yields ONLY the abort part).
@@ -2861,6 +2974,109 @@ export async function* executeToolEngine(args) {
2861
2974
  }
2862
2975
  }
2863
2976
  stall.dispose(); // stream drained normally — stop the stall watchdog
2977
+ // ─── convene_council consumption ───────────────────────────────
2978
+ // The convene_council tool queued a request during THIS step's execute().
2979
+ // Consume it here in the OUTER loop after every stream drain — NOT solely
2980
+ // via dynamicStopWhen, because a phase-1 SAMR step ends on stepCountIs(1)
2981
+ // and never evaluates the stop hook (design-debate BUG 2). Runs the
2982
+ // council autonomously (convenePath suppresses ALL post-debate decision
2983
+ // surface — no card, no continuation), splices the synthesis into the
2984
+ // convene tool_result, grafts into deps.messages, and restarts the step
2985
+ // so the model reads the conclusion as the tool result and continues.
2986
+ if (hasPendingCouncilConvene()) {
2987
+ if (responseToolCalled) {
2988
+ // The same step also emitted a terminal respond_* answer — the model
2989
+ // is done; discard the convene request rather than override the
2990
+ // answer (design-debate BUG 1: never let the flag leak across turns).
2991
+ consumeCouncilConvene();
2992
+ logger.warn("orchestrator", "convene: discarded — step also emitted a terminal response tool");
2993
+ }
2994
+ else {
2995
+ const pendingId = peekCouncilConveneToolCallId();
2996
+ let conveneResponse = null;
2997
+ try {
2998
+ conveneResponse = await result.response;
2999
+ }
3000
+ catch (err) {
3001
+ logger.error("orchestrator", "convene: failed to read response.messages", {
3002
+ error: err?.message,
3003
+ });
3004
+ }
3005
+ // BUG 3 guard: only run council when the pending convene toolCallId is
3006
+ // a recorded tool-result in THIS drain's messages — else a nested
3007
+ // frame's convene call would be wrongly consumed by this loop.
3008
+ const belongsHere = !!conveneResponse &&
3009
+ !!pendingId &&
3010
+ conveneResponse.messages.some((m) => m?.role === "tool" &&
3011
+ Array.isArray(m.content) &&
3012
+ m.content.some((p) => p?.type === "tool-result" && p?.toolCallId === pendingId));
3013
+ if (belongsHere && conveneResponse) {
3014
+ const req = consumeCouncilConvene();
3015
+ // Loop guard: a second convene in the SAME turn short-circuits —
3016
+ // do NOT pay for another council. Splice a non-binding suggestion
3017
+ // pointing the model at the synthesis already in the transcript so
3018
+ // it responds (or asks the user) instead of re-convening forever.
3019
+ if (conveneRunsThisTurn >= COUNCIL_MAX_CONVENES_PER_TURN) {
3020
+ logger.warn("orchestrator", "convene: loop guard hit — suppressing re-convene", {
3021
+ conveneRunsThisTurn,
3022
+ cap: COUNCIL_MAX_CONVENES_PER_TURN,
3023
+ });
3024
+ const suggestion = "[The council already convened this turn — its synthesis is in the transcript above. " +
3025
+ "Do NOT convene again for the same question. Use that synthesis now: give the user your " +
3026
+ "recommendation, or call ask_user if you need their go-ahead before implementing.]";
3027
+ const { messages: guardSpliced, replaced: guardReplaced } = spliceConveneToolResult(conveneResponse.messages, req?.toolCallId ?? null, suggestion);
3028
+ if (!guardReplaced) {
3029
+ logger.warn("orchestrator", "convene: loop-guard splice found no matching toolCallId", {
3030
+ toolCallId: req?.toolCallId ?? null,
3031
+ });
3032
+ }
3033
+ const guardNewMsgs = guardSpliced.slice(deps.messages.length);
3034
+ for (const msg of guardNewMsgs)
3035
+ deps.messages.push(msg);
3036
+ continue; // re-enter with the suggestion as the tool result
3037
+ }
3038
+ conveneRunsThisTurn++;
3039
+ yield { type: "content", content: "\n[Convening the council…]\n" };
3040
+ // Filter the terminal `done` runCouncilV2 emits on its
3041
+ // non-continuation path (orchestrator.ts ~2274): letting it through
3042
+ // would finalize the UI turn BEFORE we restart streamText below, so
3043
+ // the model's post-council continuation would be orphaned/invisible
3044
+ // (live-caught: council ran, synthesis produced, but no final answer
3045
+ // rendered). We continue the SAME turn ourselves via the splice +
3046
+ // `continue` restart, so the council's `done` must not propagate.
3047
+ for await (const chunk of deps.runCouncilV2(userMessage, {
3048
+ convenePath: true,
3049
+ skipClarification: true,
3050
+ observer,
3051
+ userModelMessage,
3052
+ })) {
3053
+ if (chunk.type === "done")
3054
+ continue;
3055
+ yield chunk;
3056
+ }
3057
+ const synthesis = deps.councilManager.lastSynthesis;
3058
+ const spliceValue = synthesis && synthesis.trim().length > 0
3059
+ ? synthesis
3060
+ : `[Council could not produce a conclusion${req?.reason ? ` for: ${req.reason}` : ""}. Proceed using your own judgment.]`;
3061
+ if (!synthesis || synthesis.trim().length === 0) {
3062
+ logger.warn("orchestrator", "convene: council returned no synthesis", { reason: req?.reason ?? null });
3063
+ }
3064
+ const { messages: spliced, replaced } = spliceConveneToolResult(conveneResponse.messages, req?.toolCallId ?? null, spliceValue);
3065
+ if (!replaced) {
3066
+ logger.warn("orchestrator", "convene: tool_result splice found no matching toolCallId", {
3067
+ toolCallId: req?.toolCallId ?? null,
3068
+ });
3069
+ }
3070
+ // Graft the new messages (assistant tool-call + spliced tool-result)
3071
+ // into deps.messages, then restart the step so the model reads the
3072
+ // synthesis as the convene tool's result (SAMR restart precedent).
3073
+ const newMsgs = spliced.slice(deps.messages.length);
3074
+ for (const msg of newMsgs)
3075
+ deps.messages.push(msg);
3076
+ continue; // re-enter the loop with the spliced history
3077
+ }
3078
+ }
3079
+ }
2864
3080
  // ─── SAMR Phase 1 → Phase 2 transition ─────────────────────────
2865
3081
  // Phase 1 (premium model) produced tool calls but the SDK stopped
2866
3082
  // before executing them (stopWhen: stepCountIs(1)). Append the
@@ -33,5 +33,12 @@ export interface TurnWatchdogOptions {
33
33
  totalMs: number;
34
34
  /** Human label used in the thrown error (e.g. "council continuation turn"). */
35
35
  label: string;
36
+ /**
37
+ * Optional gate consulted the instant a timer would fire. When it returns
38
+ * true the timer RE-ARMS instead of throwing — used to hold the turn open
39
+ * while a blocking `ask_user` card awaits a human (no chunks flow, but the
40
+ * turn is not hung). See interactive-pause.ts.
41
+ */
42
+ shouldSuppressFire?: () => boolean;
36
43
  }
37
44
  export declare function withTurnWatchdog(gen: AsyncGenerator<StreamChunk, void, unknown>, opts: TurnWatchdogOptions): AsyncGenerator<StreamChunk, void, unknown>;
@@ -7,15 +7,23 @@ export class TurnStallError extends Error {
7
7
  }
8
8
  }
9
9
  export async function* withTurnWatchdog(gen, opts) {
10
- const { idleMs, totalMs, label } = opts;
10
+ const { idleMs, totalMs, label, shouldSuppressFire } = opts;
11
11
  const it = gen[Symbol.asyncIterator]();
12
12
  let totalTimer;
13
13
  const total = totalMs > 0
14
14
  ? new Promise((_, reject) => {
15
- totalTimer = setTimeout(() => {
16
- reject(new TurnStallError("total", `${label} exceeded ${Math.round(totalMs / 1000)}s total watchdog — treated as hung`));
17
- }, totalMs);
18
- totalTimer.unref?.();
15
+ const armTotal = () => {
16
+ totalTimer = setTimeout(() => {
17
+ // Hold open while an interactive card blocks the turn.
18
+ if (shouldSuppressFire?.()) {
19
+ armTotal();
20
+ return;
21
+ }
22
+ reject(new TurnStallError("total", `${label} exceeded ${Math.round(totalMs / 1000)}s total watchdog — treated as hung`));
23
+ }, totalMs);
24
+ totalTimer.unref?.();
25
+ };
26
+ armTotal();
19
27
  })
20
28
  : null;
21
29
  try {
@@ -23,10 +31,17 @@ export async function* withTurnWatchdog(gen, opts) {
23
31
  let idleTimer;
24
32
  const idle = idleMs > 0
25
33
  ? new Promise((_, reject) => {
26
- idleTimer = setTimeout(() => {
27
- reject(new TurnStallError("idle", `${label} produced no output for ${Math.round(idleMs / 1000)}s treated as hung`));
28
- }, idleMs);
29
- idleTimer.unref?.();
34
+ const armIdle = () => {
35
+ idleTimer = setTimeout(() => {
36
+ if (shouldSuppressFire?.()) {
37
+ armIdle();
38
+ return;
39
+ }
40
+ reject(new TurnStallError("idle", `${label} produced no output for ${Math.round(idleMs / 1000)}s — treated as hung`));
41
+ }, idleMs);
42
+ idleTimer.unref?.();
43
+ };
44
+ armIdle();
30
45
  })
31
46
  : null;
32
47
  const racers = [it.next()];
@@ -38,6 +53,20 @@ export async function* withTurnWatchdog(gen, opts) {
38
53
  try {
39
54
  res = await Promise.race(racers);
40
55
  }
56
+ catch (err) {
57
+ // Stall (idle/total) fired while `it.next()` is still pending: the inner
58
+ // generator is suspended at an `await` and, unless told to unwind, its
59
+ // finally blocks (write-mutex release, in-flight council/tool cleanup)
60
+ // NEVER run — the turn "ends" for the UI but leaks a wedged generator
61
+ // that can block the NEXT turn (observed live: council reasoning-model
62
+ // hang, session c1d461439618 — user had to Ctrl+C and relaunch).
63
+ // Signal it to return. Fire-and-forget on purpose: the queued return
64
+ // only settles once the CALLER aborts its controller (in its catch,
65
+ // AFTER we rethrow), which settles the hung provider call — awaiting the
66
+ // return here would deadlock against that ordering.
67
+ void it.return?.(undefined).catch(() => { });
68
+ throw err;
69
+ }
41
70
  finally {
42
71
  if (idleTimer)
43
72
  clearTimeout(idleTimer);