muonroi-cli 1.7.2 → 1.8.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 (322) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +122 -122
  3. package/dist/packages/agent-harness-core/src/event-filter.js +1 -0
  4. package/dist/packages/agent-harness-core/src/event-redact.js +8 -0
  5. package/dist/packages/agent-harness-core/src/mcp-server.d.ts +16 -0
  6. package/dist/packages/agent-harness-core/src/mcp-server.js +88 -10
  7. package/dist/packages/agent-harness-core/src/protocol.d.ts +15 -0
  8. package/dist/packages/agent-harness-core/src/selector.js +2 -2
  9. package/dist/packages/agent-harness-core/src/transports/sidechannel.js +1 -1
  10. package/dist/packages/agent-harness-opentui/src/agent-mode.js +1 -1
  11. package/dist/packages/agent-harness-opentui/src/install.js +1 -1
  12. package/dist/src/__test-stubs__/vitest-setup.js +4 -0
  13. package/dist/src/agent-harness/__tests__/mock-model.spec.js +54 -0
  14. package/dist/src/agent-harness/mock-model.d.ts +1 -1
  15. package/dist/src/agent-harness/mock-model.js +32 -3
  16. package/dist/src/agent-harness/test-spawn.js +3 -1
  17. package/dist/src/chat/chat-keychain.js +9 -1
  18. package/dist/src/cli/cost-forensics.js +12 -12
  19. package/dist/src/cli/keys.d.ts +2 -2
  20. package/dist/src/cli/keys.js +107 -28
  21. package/dist/src/cli/usage-report.js +2 -2
  22. package/dist/src/council/__tests__/abort-threading.test.d.ts +1 -0
  23. package/dist/src/council/__tests__/abort-threading.test.js +193 -0
  24. package/dist/src/council/__tests__/clarification-prompt.test.js +21 -1
  25. package/dist/src/council/__tests__/council-turn-length-emit.test.d.ts +1 -0
  26. package/dist/src/council/__tests__/council-turn-length-emit.test.js +130 -0
  27. package/dist/src/council/__tests__/debate-round-budget.test.d.ts +1 -0
  28. package/dist/src/council/__tests__/debate-round-budget.test.js +45 -0
  29. package/dist/src/council/__tests__/evaluator-metrics.test.js +3 -0
  30. package/dist/src/council/__tests__/post-debate-recommendation.test.d.ts +1 -0
  31. package/dist/src/council/__tests__/post-debate-recommendation.test.js +58 -0
  32. package/dist/src/council/__tests__/research-tools.test.js +4 -0
  33. package/dist/src/council/__tests__/round-tools.test.js +6 -0
  34. package/dist/src/council/clarifier-question-cap.test.d.ts +12 -0
  35. package/dist/src/council/clarifier-question-cap.test.js +73 -0
  36. package/dist/src/council/clarifier.d.ts +29 -1
  37. package/dist/src/council/clarifier.js +64 -1
  38. package/dist/src/council/debate-planner.d.ts +2 -1
  39. package/dist/src/council/debate-planner.js +5 -3
  40. package/dist/src/council/debate.d.ts +9 -0
  41. package/dist/src/council/debate.js +88 -8
  42. package/dist/src/council/index.d.ts +46 -1
  43. package/dist/src/council/index.js +125 -19
  44. package/dist/src/council/llm.js +41 -13
  45. package/dist/src/council/prompts.js +56 -21
  46. package/dist/src/council/types.d.ts +14 -1
  47. package/dist/src/ee/__tests__/export-transcripts.test.js +1 -1
  48. package/dist/src/ee/bridge.d.ts +3 -1
  49. package/dist/src/ee/bridge.js +9 -3
  50. package/dist/src/ee/bridge.test.js +1 -2
  51. package/dist/src/ee/client.js +15 -2
  52. package/dist/src/ee/recall-ledger.d.ts +20 -0
  53. package/dist/src/ee/recall-ledger.js +40 -1
  54. package/dist/src/ee/search.d.ts +25 -0
  55. package/dist/src/ee/search.js +73 -0
  56. package/dist/src/ee/types.d.ts +15 -1
  57. package/dist/src/ee/who-am-i.d.ts +41 -0
  58. package/dist/src/ee/who-am-i.js +143 -0
  59. package/dist/src/ee/who-am-i.test.d.ts +1 -0
  60. package/dist/src/ee/who-am-i.test.js +101 -0
  61. package/dist/src/generated/version.d.ts +1 -1
  62. package/dist/src/generated/version.js +1 -1
  63. package/dist/src/headless/council-answers.js +1 -0
  64. package/dist/src/headless/output.d.ts +30 -1
  65. package/dist/src/headless/output.js +180 -0
  66. package/dist/src/headless/output.test.js +61 -1
  67. package/dist/src/hooks/index.js +48 -2
  68. package/dist/src/index.d.ts +2 -0
  69. package/dist/src/index.js +55 -60
  70. package/dist/src/lsp/client-normalize.test.d.ts +12 -0
  71. package/dist/src/lsp/client-normalize.test.js +34 -0
  72. package/dist/src/lsp/client.d.ts +1 -0
  73. package/dist/src/lsp/client.js +10 -2
  74. package/dist/src/lsp/manager.d.ts +1 -1
  75. package/dist/src/lsp/manager.js +2 -2
  76. package/dist/src/lsp/manager.test.js +2 -2
  77. package/dist/src/lsp/runtime.d.ts +1 -1
  78. package/dist/src/lsp/runtime.js +2 -2
  79. package/dist/src/maintain/__tests__/gh-create-pr.test.js +7 -3
  80. package/dist/src/maintain/__tests__/pr-builder.test.js +7 -3
  81. package/dist/src/mcp/__tests__/client-pool.spec.js +3 -3
  82. package/dist/src/mcp/__tests__/ee-tools.test.js +42 -14
  83. package/dist/src/mcp/__tests__/forensics-tools.test.js +3 -3
  84. package/dist/src/mcp/__tests__/lsp-tools.test.js +4 -4
  85. package/dist/src/mcp/client-pool.d.ts +1 -1
  86. package/dist/src/mcp/client-pool.js +25 -9
  87. package/dist/src/mcp/ee-tools.d.ts +10 -0
  88. package/dist/src/mcp/ee-tools.js +34 -0
  89. package/dist/src/mcp/oauth-callback.js +2 -2
  90. package/dist/src/mcp/parse-headers.test.js +14 -14
  91. package/dist/src/mcp/self-verify-jobs.js +1 -3
  92. package/dist/src/mcp/setup-guide-text.js +74 -74
  93. package/dist/src/mcp/smoke.test.js +43 -43
  94. package/dist/src/models/catalog-gemini.test.js +12 -10
  95. package/dist/src/models/catalog.json +106 -38
  96. package/dist/src/ops/doctor.js +7 -7
  97. package/dist/src/orchestrator/__tests__/auto-commit.test.d.ts +1 -0
  98. package/dist/src/orchestrator/__tests__/auto-commit.test.js +142 -0
  99. package/dist/src/orchestrator/__tests__/batch-turn-runner.test.js +2 -0
  100. package/dist/src/orchestrator/__tests__/delegations.test.d.ts +1 -0
  101. package/dist/src/orchestrator/__tests__/delegations.test.js +96 -0
  102. package/dist/src/orchestrator/__tests__/message-processor.test.js +1 -0
  103. package/dist/src/orchestrator/__tests__/stream-runner.test.js +29 -0
  104. package/dist/src/orchestrator/agent.test.js +5 -2
  105. package/dist/src/orchestrator/auto-commit.d.ts +110 -0
  106. package/dist/src/orchestrator/auto-commit.js +364 -0
  107. package/dist/src/orchestrator/batch-turn-runner.d.ts +3 -2
  108. package/dist/src/orchestrator/batch-turn-runner.js +2 -1
  109. package/dist/src/orchestrator/compaction-proposer-prompt.d.ts +8 -0
  110. package/dist/src/orchestrator/compaction-proposer-prompt.js +43 -0
  111. package/dist/src/orchestrator/compaction.d.ts +22 -0
  112. package/dist/src/orchestrator/compaction.js +42 -0
  113. package/dist/src/orchestrator/delegations.d.ts +1 -0
  114. package/dist/src/orchestrator/delegations.js +41 -0
  115. package/dist/src/orchestrator/error-utils.js +27 -2
  116. package/dist/src/orchestrator/message-processor.d.ts +21 -5
  117. package/dist/src/orchestrator/message-processor.js +219 -2573
  118. package/dist/src/orchestrator/orchestrator.d.ts +8 -2
  119. package/dist/src/orchestrator/orchestrator.js +187 -34
  120. package/dist/src/orchestrator/preprocessor.d.ts +13 -0
  121. package/dist/src/orchestrator/preprocessor.js +145 -0
  122. package/dist/src/orchestrator/prompts.d.ts +8 -0
  123. package/dist/src/orchestrator/prompts.js +76 -60
  124. package/dist/src/orchestrator/safety-askcard.d.ts +66 -0
  125. package/dist/src/orchestrator/safety-askcard.js +74 -0
  126. package/dist/src/orchestrator/sandbox.test.js +8 -5
  127. package/dist/src/orchestrator/scope-ceiling.js +1 -1
  128. package/dist/src/orchestrator/stall-watchdog.d.ts +47 -0
  129. package/dist/src/orchestrator/stall-watchdog.js +23 -0
  130. package/dist/src/orchestrator/stall-watchdog.test.js +36 -1
  131. package/dist/src/orchestrator/stream-runner.js +52 -30
  132. package/dist/src/orchestrator/sub-agent-model-tier.js +1 -1
  133. package/dist/src/orchestrator/subagent-compactor.d.ts +14 -0
  134. package/dist/src/orchestrator/subagent-compactor.js +83 -10
  135. package/dist/src/orchestrator/subagent-compactor.spec.js +132 -0
  136. package/dist/src/orchestrator/text-tool-call-detector.d.ts +9 -5
  137. package/dist/src/orchestrator/text-tool-call-detector.js +16 -12
  138. package/dist/src/orchestrator/text-tool-call-detector.test.js +66 -8
  139. package/dist/src/orchestrator/tool-args-repair.js +1 -1
  140. package/dist/src/orchestrator/tool-engine.d.ts +151 -0
  141. package/dist/src/orchestrator/tool-engine.js +3079 -0
  142. package/dist/src/orchestrator/turn-runner-deps.d.ts +1 -0
  143. package/dist/src/pil/__tests__/discovery-types.test.js +15 -4
  144. package/dist/src/pil/__tests__/discovery.test.js +89 -145
  145. package/dist/src/pil/__tests__/dual-run.test.js +2 -0
  146. package/dist/src/pil/__tests__/layer1-intent-trace.test.js +3 -0
  147. package/dist/src/pil/__tests__/layer1-intent.test.js +3 -0
  148. package/dist/src/pil/__tests__/layer16-clarity.test.js +35 -32
  149. package/dist/src/pil/__tests__/layer18-acceptance.test.js +1 -78
  150. package/dist/src/pil/__tests__/layer2_5-ponytail.test.d.ts +1 -0
  151. package/dist/src/pil/__tests__/layer2_5-ponytail.test.js +53 -0
  152. package/dist/src/pil/__tests__/layer3-ee-injection.test.js +67 -4
  153. package/dist/src/pil/__tests__/layer3-injected-chunk.test.js +14 -2
  154. package/dist/src/pil/__tests__/layer4-gsd.test.js +30 -0
  155. package/dist/src/pil/__tests__/layer5-context.test.js +39 -1
  156. package/dist/src/pil/__tests__/layer6-output.test.js +30 -2
  157. package/dist/src/pil/__tests__/orchestrator-integration.test.js +3 -1
  158. package/dist/src/pil/__tests__/pipeline.test.js +11 -8
  159. package/dist/src/pil/__tests__/response-tools.test.js +52 -0
  160. package/dist/src/pil/agent-operating-contract.d.ts +1 -1
  161. package/dist/src/pil/agent-operating-contract.js +5 -3
  162. package/dist/src/pil/agent-operating-contract.test.js +2 -2
  163. package/dist/src/pil/cheap-model-playbook.d.ts +1 -1
  164. package/dist/src/pil/cheap-model-playbook.js +1 -1
  165. package/dist/src/pil/cheap-model-workbooks.d.ts +1 -1
  166. package/dist/src/pil/cheap-model-workbooks.js +1 -1
  167. package/dist/src/pil/config.d.ts +20 -0
  168. package/dist/src/pil/config.js +35 -0
  169. package/dist/src/pil/discovery-types.d.ts +32 -9
  170. package/dist/src/pil/discovery.d.ts +3 -3
  171. package/dist/src/pil/discovery.js +138 -244
  172. package/dist/src/pil/index.d.ts +1 -1
  173. package/dist/src/pil/index.js +1 -1
  174. package/dist/src/pil/layer1-intent.d.ts +9 -1
  175. package/dist/src/pil/layer1-intent.js +99 -44
  176. package/dist/src/pil/layer1-intent.test.js +112 -2
  177. package/dist/src/pil/layer15-context-scan.js +1 -1
  178. package/dist/src/pil/layer16-clarity.d.ts +21 -25
  179. package/dist/src/pil/layer16-clarity.js +52 -88
  180. package/dist/src/pil/layer18-acceptance.d.ts +13 -3
  181. package/dist/src/pil/layer18-acceptance.js +1 -75
  182. package/dist/src/pil/layer2-personality.js +7 -8
  183. package/dist/src/pil/layer2_5-ponytail.d.ts +2 -0
  184. package/dist/src/pil/layer2_5-ponytail.js +41 -0
  185. package/dist/src/pil/layer3-ee-injection.js +180 -14
  186. package/dist/src/pil/layer4-gsd.js +22 -8
  187. package/dist/src/pil/layer5-context.d.ts +1 -0
  188. package/dist/src/pil/layer5-context.js +26 -11
  189. package/dist/src/pil/layer6-output.js +23 -5
  190. package/dist/src/pil/llm-classify.js +3 -3
  191. package/dist/src/pil/native-capabilities-workbook.d.ts +1 -1
  192. package/dist/src/pil/native-capabilities-workbook.js +4 -3
  193. package/dist/src/pil/pipeline.js +15 -1
  194. package/dist/src/pil/response-tools.d.ts +16 -0
  195. package/dist/src/pil/response-tools.js +41 -0
  196. package/dist/src/pil/schema.d.ts +8 -0
  197. package/dist/src/pil/schema.js +24 -3
  198. package/dist/src/pil/task-tier-map.js +7 -7
  199. package/dist/src/pil/types.d.ts +9 -3
  200. package/dist/src/product-loop/__tests__/discovery-interview.test.js +103 -1
  201. package/dist/src/product-loop/__tests__/gather-selectable-alts.test.js +34 -0
  202. package/dist/src/product-loop/__tests__/sprint-self-verify.test.js +12 -12
  203. package/dist/src/product-loop/discovery-interview.d.ts +20 -0
  204. package/dist/src/product-loop/discovery-interview.js +121 -7
  205. package/dist/src/product-loop/done-gate.js +3 -3
  206. package/dist/src/product-loop/gather.js +19 -5
  207. package/dist/src/product-loop/loop-driver.js +20 -20
  208. package/dist/src/product-loop/progress-snapshot.js +4 -4
  209. package/dist/src/product-loop/sprint-self-verify.js +2 -2
  210. package/dist/src/providers/__tests__/reasoning-roundtrip.test.js +12 -0
  211. package/dist/src/providers/auth/__tests__/gemini-oauth.test.d.ts +1 -5
  212. package/dist/src/providers/auth/__tests__/gemini-oauth.test.js +1 -5
  213. package/dist/src/providers/auth/gcloud.d.ts +28 -0
  214. package/dist/src/providers/auth/gcloud.js +102 -0
  215. package/dist/src/providers/auth/gemini-oauth.d.ts +46 -19
  216. package/dist/src/providers/auth/gemini-oauth.js +292 -105
  217. package/dist/src/providers/auth/grok-oauth.d.ts +1 -1
  218. package/dist/src/providers/auth/grok-oauth.js +1 -1
  219. package/dist/src/providers/auth/registry.js +28 -6
  220. package/dist/src/providers/gemini.d.ts +1 -1
  221. package/dist/src/providers/gemini.js +10 -3
  222. package/dist/src/providers/keychain.d.ts +2 -2
  223. package/dist/src/providers/keychain.js +20 -2
  224. package/dist/src/providers/mcp-vision-bridge.js +48 -48
  225. package/dist/src/providers/openai.js +5 -1
  226. package/dist/src/providers/runtime.d.ts +1 -1
  227. package/dist/src/providers/runtime.js +11 -2
  228. package/dist/src/providers/siliconflow-sse-repair.js +1 -1
  229. package/dist/src/providers/strategies/google.strategy.d.ts +9 -2
  230. package/dist/src/providers/strategies/google.strategy.js +159 -9
  231. package/dist/src/providers/strategies/thinking-mode.js +19 -6
  232. package/dist/src/providers/types.d.ts +1 -1
  233. package/dist/src/reporter/index.js +1 -1
  234. package/dist/src/router/decide.test.js +40 -8
  235. package/dist/src/router/step-router.d.ts +20 -0
  236. package/dist/src/router/step-router.js +73 -0
  237. package/dist/src/scaffold/bb-ecosystem-apply.js +47 -47
  238. package/dist/src/scaffold/bb-quality-gate.js +5 -5
  239. package/dist/src/scaffold/continuation-prompt.js +60 -60
  240. package/dist/src/scaffold/init-new.js +453 -453
  241. package/dist/src/self-qa/__tests__/scenario-planner.test.js +3 -3
  242. package/dist/src/self-qa/agentic-loop.js +19 -19
  243. package/dist/src/self-qa/orchestrator.js +5 -5
  244. package/dist/src/self-qa/scenario-planner.js +1 -1
  245. package/dist/src/self-qa/spec-emitter.js +4 -4
  246. package/dist/src/storage/__tests__/migrations.test.js +2 -2
  247. package/dist/src/storage/index.d.ts +1 -1
  248. package/dist/src/storage/index.js +1 -1
  249. package/dist/src/storage/interaction-log.js +5 -5
  250. package/dist/src/storage/migrations.js +122 -122
  251. package/dist/src/storage/session-experience-store.js +4 -4
  252. package/dist/src/storage/sessions.js +43 -43
  253. package/dist/src/storage/transcript-view.js +7 -1
  254. package/dist/src/storage/transcript.d.ts +1 -0
  255. package/dist/src/storage/transcript.js +55 -0
  256. package/dist/src/storage/ui-interaction-log.d.ts +17 -0
  257. package/dist/src/storage/usage.js +14 -14
  258. package/dist/src/storage/workspaces.js +12 -12
  259. package/dist/src/tools/__tests__/native-tools.test.js +6 -0
  260. package/dist/src/tools/bash.d.ts +36 -23
  261. package/dist/src/tools/bash.js +59 -301
  262. package/dist/src/tools/bash.test.js +2 -257
  263. package/dist/src/tools/file-tracker.d.ts +5 -0
  264. package/dist/src/tools/file-tracker.js +14 -0
  265. package/dist/src/tools/file.js +2 -0
  266. package/dist/src/tools/git-safety.d.ts +38 -1
  267. package/dist/src/tools/git-safety.js +94 -4
  268. package/dist/src/tools/git-safety.test.js +45 -1
  269. package/dist/src/tools/native-tools.d.ts +1 -1
  270. package/dist/src/tools/native-tools.js +56 -0
  271. package/dist/src/tools/registry-bash-empty-command.test.js +4 -3
  272. package/dist/src/tools/registry-git-safety.test.js +17 -6
  273. package/dist/src/tools/registry.d.ts +5 -0
  274. package/dist/src/tools/registry.js +185 -11
  275. package/dist/src/types/index.d.ts +7 -1
  276. package/dist/src/ui/app.d.ts +1 -0
  277. package/dist/src/ui/app.js +0 -0
  278. package/dist/src/ui/components/council-question-card.js +1 -0
  279. package/dist/src/ui/components/message-view.js +37 -3
  280. package/dist/src/ui/components/structured-response-view.js +7 -0
  281. package/dist/src/ui/components/task-list-panel.js +3 -12
  282. package/dist/src/ui/containers/chat-feed.d.ts +40 -0
  283. package/dist/src/ui/containers/chat-feed.js +66 -0
  284. package/dist/src/ui/containers/modals-layer.d.ts +87 -0
  285. package/dist/src/ui/containers/modals-layer.js +18 -0
  286. package/dist/src/ui/slash/__tests__/menu-parity.test.d.ts +1 -0
  287. package/dist/src/ui/slash/__tests__/menu-parity.test.js +1 -0
  288. package/dist/src/ui/slash/__tests__/ponytail.test.d.ts +1 -0
  289. package/dist/src/ui/slash/__tests__/ponytail.test.js +37 -0
  290. package/dist/src/ui/slash/council-inspect.js +4 -4
  291. package/dist/src/ui/slash/export.js +16 -1
  292. package/dist/src/ui/slash/menu-items.js +1 -0
  293. package/dist/src/ui/slash/ponytail.d.ts +2 -0
  294. package/dist/src/ui/slash/ponytail.js +19 -0
  295. package/dist/src/ui/status-bar/store.js +0 -1
  296. package/dist/src/ui/status-bar/store.test.js +2 -2
  297. package/dist/src/ui/use-app-logic.d.ts +26 -0
  298. package/dist/src/ui/use-app-logic.js +0 -0
  299. package/dist/src/ui/utils/relaunch.js +1 -1
  300. package/dist/src/ui/utils/text.d.ts +11 -0
  301. package/dist/src/ui/utils/text.js +21 -1
  302. package/dist/src/ui/utils/text.test.js +25 -1
  303. package/dist/src/ui/utils/tools.js +3 -1
  304. package/dist/src/usage/cost-log.js +2 -2
  305. package/dist/src/usage/estimator.d.ts +14 -0
  306. package/dist/src/usage/estimator.js +22 -0
  307. package/dist/src/utils/__tests__/compaction-caps.test.d.ts +21 -0
  308. package/dist/src/utils/__tests__/compaction-caps.test.js +187 -0
  309. package/dist/src/utils/__tests__/llm-deadline-abort.test.d.ts +1 -0
  310. package/dist/src/utils/__tests__/llm-deadline-abort.test.js +33 -0
  311. package/dist/src/utils/clipboard-image.js +23 -23
  312. package/dist/src/utils/llm-deadline.d.ts +9 -1
  313. package/dist/src/utils/llm-deadline.js +31 -2
  314. package/dist/src/utils/permission-mode.d.ts +33 -0
  315. package/dist/src/utils/permission-mode.js +164 -3
  316. package/dist/src/utils/permission-mode.test.js +92 -1
  317. package/dist/src/utils/settings.d.ts +27 -31
  318. package/dist/src/utils/settings.js +100 -109
  319. package/dist/src/utils/side-question.js +2 -2
  320. package/dist/src/utils/skills.js +3 -3
  321. package/dist/src/verify/__tests__/coverage-parsers.test.js +30 -30
  322. package/package.json +1 -1
@@ -31,6 +31,33 @@ const MAX_EMPTY_WITH_TOOLS = 2;
31
31
  const ABSOLUTE_MAX_ROUNDS = 8;
32
32
  /** Default initial round budget when the planner does not propose one. */
33
33
  const DEFAULT_PLANNED_ROUNDS = 3;
34
+ /**
35
+ * Per-kind hard ceiling on debate rounds — the leader may extend up to this, never
36
+ * past it. Discussion-style debates (decision / evaluation / investigation) converge
37
+ * fast, so capping them at 3 stops a simple "X or Y?" from burning 5-8 rounds of
38
+ * diminishing returns. Observed live (2026-06-20): a Redis-vs-in-memory decision ran
39
+ * 5 rounds / ~10 min on a slow provider, and the leader's own round-5 note was
40
+ * "remaining disagreements are minor and further rounds would repeat established
41
+ * positions" — i.e. rounds 4-5 added latency, not signal. Greenfield exploration
42
+ * keeps more breadth (5). Kinds absent here fall back to ABSOLUTE_MAX_ROUNDS.
43
+ */
44
+ const KIND_MAX_ROUNDS = {
45
+ implementation_plan: 3,
46
+ decision: 3,
47
+ evaluation: 3,
48
+ investigation: 3,
49
+ exploration: 5,
50
+ };
51
+ /**
52
+ * Resolve the initial round budget + hard ceiling from the plan's output-shape kind
53
+ * and the planner-proposed round count. Pure + exported for unit testing the cap.
54
+ */
55
+ export function resolveDebateRoundBudget(planKind, plannedRounds) {
56
+ const kindCap = planKind !== undefined ? KIND_MAX_ROUNDS[planKind] : undefined;
57
+ const effectiveCeiling = Math.min(ABSOLUTE_MAX_ROUNDS, kindCap ?? ABSOLUTE_MAX_ROUNDS);
58
+ const maxRounds = Math.min(effectiveCeiling, Math.max(1, typeof plannedRounds === "number" && plannedRounds > 0 ? plannedRounds : DEFAULT_PLANNED_ROUNDS));
59
+ return { maxRounds, effectiveCeiling, kindCapped: kindCap !== undefined };
60
+ }
34
61
  /** Cap on the size of a single archived position. Anything longer is
35
62
  * trimmed and reported via `length`. Mirrors the goal of keeping the
36
63
  * follow-up memory record small enough to be reloaded cheaply. */
@@ -42,6 +69,39 @@ function makeExcerpt(text) {
42
69
  length: trimmed.length,
43
70
  };
44
71
  }
72
+ /**
73
+ * Emit the observe-only `council-turn-length` harness event for one fully-assembled
74
+ * speaker turn (opening statement or discussion turn). Reports char + word count so
75
+ * a harness can measure council verbosity per role/model/round — NO truncation, NO
76
+ * behaviour change. Best-effort: a no-op when the agent runtime is absent (normal
77
+ * user mode) or the event kind is filtered out (MUONROI_HARNESS_EVENTS). Uses the
78
+ * same globalThis.__muonroiAgentRuntime emitter as sprint-runner.ts.
79
+ */
80
+ function emitCouncilTurnLength(args) {
81
+ try {
82
+ const ar = globalThis.__muonroiAgentRuntime;
83
+ if (!ar || typeof ar.emitEvent !== "function")
84
+ return;
85
+ const trimmed = args.text.trim();
86
+ ar.emitEvent({
87
+ t: "event",
88
+ kind: "council-turn-length",
89
+ role: args.role,
90
+ round: args.round,
91
+ charCount: trimmed.length,
92
+ wordCount: trimmed.length === 0 ? 0 : trimmed.split(/\s+/).filter(Boolean).length,
93
+ model: args.model,
94
+ correlationId: args.correlationId,
95
+ });
96
+ }
97
+ catch (err) {
98
+ // Observe-only telemetry — swallow so a harness hiccup can't break a debate turn.
99
+ // Logged only under MUONROI_DEBUG_HARNESS (No-Silent-Catch) to keep TUI output clean.
100
+ if (process.env.MUONROI_DEBUG_HARNESS === "1") {
101
+ console.error(`[council] council-turn-length emit failed: ${err?.message ?? String(err)}`);
102
+ }
103
+ }
104
+ }
45
105
  /**
46
106
  * Lock-phrase detector. Counts what fraction of pair-turns in the latest round
47
107
  * contain explicit convergence signals. When ≥80% of pair-turns signal "lock",
@@ -215,6 +275,10 @@ async function debateWithRetry(llm, model, system, prompt, signal, traceCb, tool
215
275
  }
216
276
  export async function* runDebate(spec, config, llm) {
217
277
  const { leaderModelId, participants, conversationContext, signal, debatePlan } = config;
278
+ // Correlation id for the observe-only council-turn-length telemetry (groups
279
+ // per-turn length samples by run). sessionId in production; a stable literal
280
+ // for direct callers/tests that omit runId.
281
+ const turnCorrelationId = config.runId ?? "council";
218
282
  const researchSkipOverride = config.researchSkipOverride === true;
219
283
  const leaderNeedsResearch = config.leaderNeedsResearch;
220
284
  const internetFirst = config.internetFirst === true;
@@ -352,6 +416,13 @@ export async function* runDebate(spec, config, llm) {
352
416
  attempts: o.attempts,
353
417
  },
354
418
  };
419
+ emitCouncilTurnLength({
420
+ role: speakerRole,
421
+ round: 0,
422
+ text: o.position,
423
+ model: o.model,
424
+ correlationId: turnCorrelationId,
425
+ });
355
426
  }
356
427
  }
357
428
  yield phaseDone({
@@ -378,14 +449,9 @@ export async function* runDebate(spec, config, llm) {
378
449
  // absolute ceiling still applies there.
379
450
  let roundCount = 0;
380
451
  const planKind = debatePlan?.outputShape?.kind;
381
- const KIND_MAX_ROUNDS = {
382
- implementation_plan: 3,
383
- };
384
- const kindCap = planKind ? KIND_MAX_ROUNDS[planKind] : undefined;
385
- const initialPlanned = debatePlan?.plannedRounds;
386
- const effectiveCeiling = Math.min(ABSOLUTE_MAX_ROUNDS, kindCap ?? ABSOLUTE_MAX_ROUNDS);
387
- let maxRounds = Math.min(effectiveCeiling, Math.max(1, typeof initialPlanned === "number" && initialPlanned > 0 ? initialPlanned : DEFAULT_PLANNED_ROUNDS));
388
- const ceilingNote = kindCap
452
+ const { maxRounds: plannedMaxRounds, effectiveCeiling, kindCapped, } = resolveDebateRoundBudget(planKind, debatePlan?.plannedRounds);
453
+ let maxRounds = plannedMaxRounds;
454
+ const ceilingNote = kindCapped
389
455
  ? ` (hard ceiling ${effectiveCeiling} for ${planKind})`
390
456
  : ` (hard ceiling ${ABSOLUTE_MAX_ROUNDS})`;
391
457
  yield {
@@ -401,6 +467,13 @@ export async function* runDebate(spec, config, llm) {
401
467
  // — the LLM is clearly under provider stress and more rounds won't help.
402
468
  let consecutiveRoundFailures = 0;
403
469
  for (let round = 1; round <= maxRounds; round++) {
470
+ // User cancelled mid-debate — stop before spending another round of
471
+ // parallel pair LLM calls. The caller (runCouncil) re-checks the signal at
472
+ // its next phase boundary and skips synthesis too.
473
+ if (signal?.aborted) {
474
+ yield { type: "content", content: `\n> Debate cancelled by user.\n` };
475
+ break;
476
+ }
404
477
  roundCount = round;
405
478
  const p2Start = Date.now();
406
479
  const roundPhaseId = `phase:round-${round}`;
@@ -627,6 +700,13 @@ export async function* runDebate(spec, config, llm) {
627
700
  attempts: chunk.attempts,
628
701
  },
629
702
  };
703
+ emitCouncilTurnLength({
704
+ role: speakerName,
705
+ round,
706
+ text: chunk.text,
707
+ model: modelId,
708
+ correlationId: turnCorrelationId,
709
+ });
630
710
  }
631
711
  for (const trace of chunk.traces ?? []) {
632
712
  yield { type: "council_status", content: trace };
@@ -1,10 +1,31 @@
1
1
  import type { ModelMessage } from "ai";
2
2
  import type { StreamChunk } from "../types/index.js";
3
3
  import type { CouncilLLM, CouncilStats, PreflightResponder, QuestionResponder } from "./types.js";
4
+ /**
5
+ * Wrap a CouncilLLM so every `generate` call inherits the council-wide abort
6
+ * signal. The whole generate-based call path (clarifier, research-need eval,
7
+ * leader round-eval, opening statements, round summary, spec/plan synthesis,
8
+ * and the debate-planner retry) calls `llm.generate(...)` with NO signal arg —
9
+ * none of those sites thread one. Injecting it here in ONE place makes them all
10
+ * cancellable without touching each signature. `debate`/`research` already get
11
+ * `config.signal` explicitly, so they pass through unchanged.
12
+ *
13
+ * An explicit per-call signal (none exist today, but the param is there) wins
14
+ * over the injected one. Returns the original llm untouched when no signal is
15
+ * configured (e.g. the sprint-planner path, which has no user-abort signal).
16
+ */
17
+ export declare function withCouncilSignal(llm: CouncilLLM, signal: AbortSignal | undefined): CouncilLLM;
4
18
  export interface RunCouncilOptions {
5
19
  skipClarification?: boolean;
6
20
  userModelMessage?: ModelMessage;
7
21
  signal?: AbortSignal;
22
+ /**
23
+ * Hard cap on clarification rounds for the explicit /council path. Defaults to
24
+ * EXPLICIT_COUNCIL_CLARIFY_ROUNDS (1). Callers that genuinely want the full
25
+ * multi-round ready-gate can raise it; auto-council/sprint pass
26
+ * skipClarification:true and never reach the clarifier regardless.
27
+ */
28
+ clarifyMaxRounds?: number;
8
29
  /** Working directory used to resolve the "current project" snapshot. */
9
30
  cwd?: string;
10
31
  /** Shared stats object from orchestrator — when provided, runCouncil uses it instead of a local one so stats.calls is accurate (Phase 14 CQ-01). */
@@ -15,8 +36,32 @@ export interface RunCouncilOptions {
15
36
  */
16
37
  runDir?: string;
17
38
  }
39
+ export type PostDebateAction = "save_exit" | "generate_plan" | "refine" | "ask_followup" | "retry_synthesis";
40
+ /**
41
+ * Decide the DEFAULT post-debate action surfaced as the recommended option.
42
+ * Extracted as a pure function so the policy is unit-testable.
43
+ *
44
+ * Issue #3 (post-debate default mismatch): when synthesis succeeded and no plan
45
+ * exists yet, only an `implementation_plan`-shaped debate should default to
46
+ * "generate_plan" (Lock plan & execute Sprint 1). For a `decision`, `evaluation`,
47
+ * `investigation`, or `exploration` debate the synthesis IS the deliverable — the
48
+ * user asked a question, not for code — so the default is `save_exit`. The
49
+ * generate_plan OPTION is still offered downstream; it's just no longer the
50
+ * pre-selected default for non-build topics.
51
+ */
52
+ export declare function pickPostDebateRecommendation(input: {
53
+ synthesisFailed: boolean;
54
+ hasEmptySections: boolean;
55
+ refinementTopics: string[];
56
+ confidenceLevel: "high" | "medium" | "low";
57
+ hasPlan: boolean;
58
+ outputKind: string;
59
+ }): {
60
+ value: PostDebateAction;
61
+ reason: string;
62
+ };
18
63
  export declare function runCouncil(topic: string, sessionModelId: string, messages: Array<{
19
64
  role: string;
20
65
  content: string | unknown;
21
- }>, sessionId: string | undefined, llm: CouncilLLM, respondToQuestion: QuestionResponder, respondToPreflight: PreflightResponder, processMessageFn: (message: string) => AsyncGenerator<StreamChunk, void, unknown>, options?: RunCouncilOptions): AsyncGenerator<StreamChunk, string | null, unknown>;
66
+ }>, sessionId: string | undefined, rawLlm: CouncilLLM, respondToQuestion: QuestionResponder, respondToPreflight: PreflightResponder, processMessageFn: (message: string) => AsyncGenerator<StreamChunk, void, unknown>, options?: RunCouncilOptions): AsyncGenerator<StreamChunk, string | null, unknown>;
22
67
  export type { ClarifiedSpec, CouncilLLM, CouncilParticipant, CouncilStats } from "./types.js";
@@ -15,9 +15,91 @@ import { resolveLeaderModelDetailed, resolveParticipants } from "./leader.js";
15
15
  import { phaseDone, phaseStart } from "./phase-events.js";
16
16
  import { runPlanning } from "./planner.js";
17
17
  import { runPreflight } from "./preflight.js";
18
- export async function* runCouncil(topic, sessionModelId, messages, sessionId, llm, respondToQuestion, respondToPreflight, processMessageFn, options) {
18
+ /**
19
+ * Wrap a CouncilLLM so every `generate` call inherits the council-wide abort
20
+ * signal. The whole generate-based call path (clarifier, research-need eval,
21
+ * leader round-eval, opening statements, round summary, spec/plan synthesis,
22
+ * and the debate-planner retry) calls `llm.generate(...)` with NO signal arg —
23
+ * none of those sites thread one. Injecting it here in ONE place makes them all
24
+ * cancellable without touching each signature. `debate`/`research` already get
25
+ * `config.signal` explicitly, so they pass through unchanged.
26
+ *
27
+ * An explicit per-call signal (none exist today, but the param is there) wins
28
+ * over the injected one. Returns the original llm untouched when no signal is
29
+ * configured (e.g. the sprint-planner path, which has no user-abort signal).
30
+ */
31
+ export function withCouncilSignal(llm, signal) {
32
+ if (!signal)
33
+ return llm;
34
+ return {
35
+ ...llm,
36
+ generate: (modelId, system, prompt, maxTokens, onUsage, sig) => llm.generate(modelId, system, prompt, maxTokens, onUsage, sig ?? signal),
37
+ };
38
+ }
39
+ /**
40
+ * Explicit `/council …` is the ONLY caller that runs the clarifier — auto-council
41
+ * (message-processor) and the sprint-planner both pass `skipClarification: true`.
42
+ * Capping it to a single round (down from the ready-gate's MAX_CLARIFY_ROUNDS=12)
43
+ * stops the 2-3 rounds of follow-up askcards users hit on already-detailed topics
44
+ * (see project-council-subsystem memory). The per-round ready-gate has no
45
+ * production behavioural effect anyway — `spec.ready`/`confidenceScore`/
46
+ * `remainingGaps` are write-only — so its only real cost is the extra rounds.
47
+ * Round 0 still runs, so the user is asked the key questions exactly once.
48
+ */
49
+ const EXPLICIT_COUNCIL_CLARIFY_ROUNDS = 1;
50
+ /**
51
+ * Decide the DEFAULT post-debate action surfaced as the recommended option.
52
+ * Extracted as a pure function so the policy is unit-testable.
53
+ *
54
+ * Issue #3 (post-debate default mismatch): when synthesis succeeded and no plan
55
+ * exists yet, only an `implementation_plan`-shaped debate should default to
56
+ * "generate_plan" (Lock plan & execute Sprint 1). For a `decision`, `evaluation`,
57
+ * `investigation`, or `exploration` debate the synthesis IS the deliverable — the
58
+ * user asked a question, not for code — so the default is `save_exit`. The
59
+ * generate_plan OPTION is still offered downstream; it's just no longer the
60
+ * pre-selected default for non-build topics.
61
+ */
62
+ export function pickPostDebateRecommendation(input) {
63
+ if (input.synthesisFailed) {
64
+ return {
65
+ value: "retry_synthesis",
66
+ reason: "Re-run synthesis with a compact prompt — usually clears provider-timeout failures.",
67
+ };
68
+ }
69
+ if (input.hasEmptySections) {
70
+ return { value: "refine", reason: `Fill in ${input.refinementTopics.length} section(s) the debate left empty.` };
71
+ }
72
+ if (input.confidenceLevel === "low") {
73
+ return {
74
+ value: "ask_followup",
75
+ reason: "Press the council on the weakest claims rather than accepting a thin synthesis.",
76
+ };
77
+ }
78
+ if (!input.hasPlan) {
79
+ return input.outputKind === "implementation_plan"
80
+ ? { value: "generate_plan", reason: "Convert the agreed outcome into concrete steps." }
81
+ : {
82
+ value: "save_exit",
83
+ reason: `This was a ${input.outputKind} debate — the synthesis above is the deliverable; save it.`,
84
+ };
85
+ }
86
+ return { value: "save_exit", reason: "Outcome looks solid — save and move on." };
87
+ }
88
+ export async function* runCouncil(topic, sessionModelId, messages, sessionId, rawLlm, respondToQuestion, respondToPreflight, processMessageFn, options) {
19
89
  const stats = options?.councilStats ?? { calls: 0, startMs: Date.now(), phases: [] };
20
90
  const costAware = isCouncilCostAware();
91
+ // Inject the user-abort signal into every generate-based sub-call (clarify,
92
+ // research-need, leader-eval, opening, summary, synthesis, debate-plan retry).
93
+ // No-op passthrough when options.signal is undefined.
94
+ const llm = withCouncilSignal(rawLlm, options?.signal);
95
+ // Hard-stop guard. Threading the signal into LLM calls makes them abortable,
96
+ // but every council sub-phase wraps its work in fail-open try/catch that
97
+ // swallows the resulting AbortError and returns normally — so without an
98
+ // explicit check at each phase boundary the loop would march on to the next
99
+ // phase after a cancel. `userAborted()` is checked between phases; when true
100
+ // the run stops cleanly rather than burning the remaining (debate, synthesis)
101
+ // LLM budget. Cancellation latency is bounded by one in-flight sub-call.
102
+ const userAborted = () => options?.signal?.aborted === true;
21
103
  // ── Resolve models ──────────────────────────────────────────────────────────
22
104
  const leaderResolution = await resolveLeaderModelDetailed(sessionModelId);
23
105
  const leaderModelId = leaderResolution.modelId;
@@ -50,6 +132,11 @@ export async function* runCouncil(topic, sessionModelId, messages, sessionId, ll
50
132
  : baseContext;
51
133
  const internetFirst = projectInfo.isEmpty;
52
134
  const active = participants.map((p) => ({ ...p, position: "" }));
135
+ if (userAborted()) {
136
+ yield { type: "content", content: "\n> Council cancelled by user.\n" };
137
+ yield { type: "done" };
138
+ return null;
139
+ }
53
140
  // ── Phase A + B loop: Clarify → Confirm ─────────────────────────────────────
54
141
  let spec = buildSpecFromTopic(topic, conversationContext);
55
142
  let approved = false;
@@ -76,7 +163,7 @@ export async function* runCouncil(topic, sessionModelId, messages, sessionId, ll
76
163
  content: `\n> Clarification seeded by PIL (${pilSeed.length} gray-area question${pilSeed.length === 1 ? "" : "s"}).\n`,
77
164
  };
78
165
  }
79
- const clarifyGen = runClarification(topic, leaderModelId, conversationContext, respondToQuestion, llm, options?.signal, pilSeed, undefined, undefined, costAware);
166
+ const clarifyGen = runClarification(topic, leaderModelId, conversationContext, respondToQuestion, llm, options?.signal, pilSeed, options?.clarifyMaxRounds ?? EXPLICIT_COUNCIL_CLARIFY_ROUNDS, undefined, costAware);
80
167
  let clarifyResult;
81
168
  do {
82
169
  clarifyResult = await clarifyGen.next();
@@ -90,6 +177,9 @@ export async function* runCouncil(topic, sessionModelId, messages, sessionId, ll
90
177
  spec = buildSpecFromTopic(topic, conversationContext);
91
178
  yield { type: "content", content: `\n> Auto-council: skipping clarification (PIL pre-classified).\n` };
92
179
  }
180
+ // Cancelled during clarification — don't pop the preflight approval card.
181
+ if (userAborted())
182
+ break;
93
183
  const researchNeeded = true;
94
184
  const preflightGen = runPreflight(spec, participants, researchNeeded, respondToPreflight, {
95
185
  repoEmpty: internetFirst,
@@ -105,6 +195,11 @@ export async function* runCouncil(topic, sessionModelId, messages, sessionId, ll
105
195
  approved = preflightResult.value;
106
196
  }
107
197
  stats.phases.push({ name: "clarify+preflight", durationMs: Date.now() - phaseAStart });
198
+ if (userAborted()) {
199
+ yield { type: "content", content: "\n> Council cancelled by user.\n" };
200
+ yield { type: "done" };
201
+ return null;
202
+ }
108
203
  // ── Research-need check + user override ────────────────────────────────────
109
204
  // Leader-LLM decides if research is required. If yes, give the user a chance
110
205
  // to skip — research is the slowest part of council and trivial questions
@@ -171,6 +266,11 @@ export async function* runCouncil(topic, sessionModelId, messages, sessionId, ll
171
266
  content: `\n> [Experience] ${eeResult.warnings.length} past warning(s) loaded — Experience Auditor will calibrate debate.\n`,
172
267
  };
173
268
  }
269
+ if (userAborted()) {
270
+ yield { type: "content", content: "\n> Council cancelled by user.\n" };
271
+ yield { type: "done" };
272
+ return null;
273
+ }
174
274
  // ── Phase B.5: Leader plans the debate (stances + output shape) ─────────────
175
275
  const planStartMs = Date.now();
176
276
  yield phaseStart({
@@ -179,7 +279,7 @@ export async function* runCouncil(topic, sessionModelId, messages, sessionId, ll
179
279
  label: "Debate plan",
180
280
  detail: "stances + output shape",
181
281
  });
182
- const planGenerator = planDebate(spec, leaderModelId, llm, eeResult.warnings, experienceMode, pilCtx?.taskType ?? undefined, pilCtx?.complexityTier ?? undefined);
282
+ const planGenerator = planDebate(spec, leaderModelId, llm, eeResult.warnings, experienceMode, pilCtx?.taskType ?? undefined, pilCtx?.complexityTier ?? undefined, options?.signal);
183
283
  let planStep;
184
284
  do {
185
285
  planStep = await planGenerator.next();
@@ -222,6 +322,11 @@ export async function* runCouncil(topic, sessionModelId, messages, sessionId, ll
222
322
  active.length = debatePlan.stances.length;
223
323
  }
224
324
  stats.phases.push({ name: "plan_debate", durationMs: Date.now() - planStartMs });
325
+ if (userAborted()) {
326
+ yield { type: "content", content: "\n> Council cancelled by user.\n" };
327
+ yield { type: "done" };
328
+ return null;
329
+ }
225
330
  // ── Phase C: Dynamic Debate ─────────────────────────────────────────────────
226
331
  const debateStart = Date.now();
227
332
  const debateGen = runDebate(spec, {
@@ -235,6 +340,7 @@ export async function* runCouncil(topic, sessionModelId, messages, sessionId, ll
235
340
  leaderNeedsResearch,
236
341
  internetFirst,
237
342
  costAware,
343
+ runId: sessionId,
238
344
  }, llm);
239
345
  let debateResult;
240
346
  do {
@@ -270,6 +376,11 @@ export async function* runCouncil(topic, sessionModelId, messages, sessionId, ll
270
376
  durationMs: Date.now() - debateStart,
271
377
  data: { topic, roundCount: debateState.roundCount },
272
378
  });
379
+ if (userAborted()) {
380
+ yield { type: "content", content: "\n> Council cancelled by user — skipping synthesis.\n" };
381
+ yield { type: "done" };
382
+ return null;
383
+ }
273
384
  // ── Phase D: Plan ───────────────────────────────────────────────────────────
274
385
  const planStart = Date.now();
275
386
  const planGen = runPlanning(debateState, spec, debateState.active, leaderModelId, respondToPreflight, llm, debatePlan, pilCtx?.outputStyle ?? undefined);
@@ -338,22 +449,17 @@ export async function* runCouncil(topic, sessionModelId, messages, sessionId, ll
338
449
  : confidenceLevel === "medium"
339
450
  ? `⚠ Medium confidence (evidence density ${evidenceDensity.toFixed(2)})`
340
451
  : `⚠ Low confidence (evidence density ${evidenceDensity.toFixed(2)})`;
341
- // Recommendation surfaced to the user as the default action.
342
- const recommendation = synthesisFailed
343
- ? {
344
- value: "retry_synthesis",
345
- reason: "Re-run synthesis with a compact prompt — usually clears provider-timeout failures.",
346
- }
347
- : hasEmptySections
348
- ? { value: "refine", reason: `Fill in ${refinementTopics.length} section(s) the debate left empty.` }
349
- : confidenceLevel === "low"
350
- ? {
351
- value: "ask_followup",
352
- reason: "Press the council on the weakest claims rather than accepting a thin synthesis.",
353
- }
354
- : !hasPlan
355
- ? { value: "generate_plan", reason: "Convert the agreed outcome into concrete steps." }
356
- : { value: "save_exit", reason: "Outcome looks solid — save and move on." };
452
+ // Recommendation surfaced to the user as the default action. The
453
+ // implementation_plan-vs-decision/evaluation split lives in
454
+ // pickPostDebateRecommendation (issue #3 — see its doc comment).
455
+ const recommendation = pickPostDebateRecommendation({
456
+ synthesisFailed,
457
+ hasEmptySections,
458
+ refinementTopics,
459
+ confidenceLevel,
460
+ hasPlan: !!hasPlan,
461
+ outputKind: debatePlan.outputShape.kind,
462
+ });
357
463
  const baseOptions = [];
358
464
  if (synthesisFailed) {
359
465
  baseOptions.push({
@@ -4,8 +4,8 @@ import { getDefaultEEClient } from "../ee/intercept.js";
4
4
  import { emitMatches } from "../ee/render.js";
5
5
  import { buildMcpToolSet } from "../mcp/runtime.js";
6
6
  import { getProviderCapabilities } from "../providers/capabilities.js";
7
- import { loadKeyForProvider } from "../providers/keychain.js";
8
- import { createProviderFactory, detectProviderForModel, resolveModelRuntime } from "../providers/runtime.js";
7
+ import { loadKeyForProvider, ProviderKeyMissingError } from "../providers/keychain.js";
8
+ import { createProviderFactoryAsync, detectProviderForModel, resolveModelRuntime } from "../providers/runtime.js";
9
9
  import { createBuiltinTools as createTools } from "../tools/registry.js";
10
10
  import { appendCostLog } from "../usage/cost-log.js";
11
11
  import { projectCostUSD } from "../usage/estimator.js";
@@ -13,6 +13,33 @@ import { withDeadlineRace, withTimeoutSignal } from "../utils/llm-deadline.js";
13
13
  import { loadMcpServers } from "../utils/settings.js";
14
14
  import { withVisibleRetry } from "../utils/visible-retry.js";
15
15
  import { buildResearchSystemPrompt } from "./prompts.js";
16
+ /**
17
+ * Resolve a provider factory for a council sub-call, OAuth-aware.
18
+ *
19
+ * The council reachability gate (`isProviderReachable` in leader.ts) counts
20
+ * OAuth-authenticated providers as usable (via `getConfiguredProviders`), so a
21
+ * multi-provider roster can route a stance to an OAuth-only provider such as
22
+ * xai/grok. `loadKeyForProvider` is API-key-only and throws
23
+ * `ProviderKeyMissingError` for those — which previously surfaced as
24
+ * "[Error: No API key found for provider 'xai'.]" on every call of that stance,
25
+ * even though the provider was fully authenticated. Mirror the main
26
+ * orchestrator path: fall back to `createProviderFactoryAsync`, which loads +
27
+ * refreshes the stored OAuth bearer token. Only the expected missing-key case
28
+ * is swallowed; unexpected errors propagate.
29
+ */
30
+ async function resolveCouncilFactory(providerId) {
31
+ let apiKey;
32
+ try {
33
+ apiKey = await loadKeyForProvider(providerId);
34
+ }
35
+ catch (err) {
36
+ if (!(err instanceof ProviderKeyMissingError))
37
+ throw err;
38
+ // OAuth-only provider — createProviderFactoryAsync injects the bearer token.
39
+ }
40
+ const { factory } = await createProviderFactoryAsync(providerId, apiKey ? { apiKey } : {});
41
+ return factory;
42
+ }
16
43
  function getDebugLogPath() {
17
44
  const p = process.env.MUONROI_COUNCIL_DEBUG_LOG;
18
45
  return p && p.length > 0 ? p : null;
@@ -184,7 +211,7 @@ const COUNCIL_LLM_TIMEOUT_MS = (() => {
184
211
  // implementation. Imported at the top of this file.
185
212
  export function createCouncilLLM(bash, mode, sessionId, stats) {
186
213
  return {
187
- async generate(modelId, system, prompt, maxTokens = 4096, onUsage) {
214
+ async generate(modelId, system, prompt, maxTokens = 4096, onUsage, signal) {
188
215
  const mock = getMockLlm();
189
216
  if (mock) {
190
217
  stats.calls++;
@@ -192,11 +219,14 @@ export function createCouncilLLM(bash, mode, sessionId, stats) {
192
219
  return result.text;
193
220
  }
194
221
  const providerId = detectProviderForModel(modelId);
195
- const key = await loadKeyForProvider(providerId);
196
- const { factory } = createProviderFactory(providerId, { apiKey: key });
222
+ const factory = await resolveCouncilFactory(providerId);
197
223
  const runtime = resolveModelRuntime(factory, modelId);
198
224
  const t0 = Date.now();
199
- const { signal: timedSignal, cleanup: cleanupTimeout } = withTimeoutSignal(undefined, COUNCIL_LLM_TIMEOUT_MS);
225
+ // Combine the user-abort signal (when threaded from runCouncil) with the
226
+ // per-call wall-clock deadline. Without the parent signal, an Esc/Ctrl-C
227
+ // during the longest generate calls (8192-token synthesis, clarify, leader
228
+ // eval) was a no-op — the call ran to completion or hit the 5-min timeout.
229
+ const { signal: timedSignal, cleanup: cleanupTimeout } = withTimeoutSignal(signal, COUNCIL_LLM_TIMEOUT_MS);
200
230
  try {
201
231
  const result = await withDeadlineRace(() => withVisibleRetry(() => generateText({
202
232
  model: runtime.model,
@@ -211,7 +241,7 @@ export function createCouncilLLM(bash, mode, sessionId, stats) {
211
241
  maxRetries: 0,
212
242
  ...(runtime.providerOptions ? { providerOptions: runtime.providerOptions } : {}),
213
243
  abortSignal: timedSignal,
214
- }), { label: "council.generate" }), COUNCIL_LLM_TIMEOUT_MS + 5_000, "council.generate");
244
+ }), { label: "council.generate" }), COUNCIL_LLM_TIMEOUT_MS + 5_000, "council.generate", signal);
215
245
  cleanupTimeout();
216
246
  stats.calls++;
217
247
  const durMs = Date.now() - t0;
@@ -273,8 +303,7 @@ export function createCouncilLLM(bash, mode, sessionId, stats) {
273
303
  return { text: result.text, toolCalls: [] };
274
304
  }
275
305
  const providerId = detectProviderForModel(modelId);
276
- const key = await loadKeyForProvider(providerId);
277
- const { factory } = createProviderFactory(providerId, { apiKey: key });
306
+ const factory = await resolveCouncilFactory(providerId);
278
307
  const runtime = resolveModelRuntime(factory, modelId);
279
308
  // Verification tools — re-introduced after the no-tools fix (session
280
309
  // a7a5690d2049). The original failure was stepCountIs(4) + full toolset
@@ -352,7 +381,7 @@ export function createCouncilLLM(bash, mode, sessionId, stats) {
352
381
  maxRetries: 0,
353
382
  ...(runtime.providerOptions ? { providerOptions: runtime.providerOptions } : {}),
354
383
  abortSignal: timedSignal,
355
- }), { label: "council.debate" }), COUNCIL_LLM_TIMEOUT_MS + 5_000, "council.debate");
384
+ }), { label: "council.debate" }), COUNCIL_LLM_TIMEOUT_MS + 5_000, "council.debate", signal);
356
385
  cleanupTimeout();
357
386
  stats.calls++;
358
387
  // No tool calls expected, but the AI SDK shape still has the field —
@@ -430,8 +459,7 @@ export function createCouncilLLM(bash, mode, sessionId, stats) {
430
459
  return result.text;
431
460
  }
432
461
  const providerId = detectProviderForModel(modelId);
433
- const key = await loadKeyForProvider(providerId);
434
- const { factory } = createProviderFactory(providerId, { apiKey: key });
462
+ const factory = await resolveCouncilFactory(providerId);
435
463
  const runtime = resolveModelRuntime(factory, modelId);
436
464
  const builtinTools = createTools(bash, mode);
437
465
  // CQ-03: Lazy MCP bundle per research call — fail-open so builtins remain available
@@ -488,7 +516,7 @@ export function createCouncilLLM(bash, mode, sessionId, stats) {
488
516
  maxRetries: 0,
489
517
  ...(runtime.providerOptions ? { providerOptions: runtime.providerOptions } : {}),
490
518
  abortSignal: timedSignal,
491
- }), { label: "council.research" }), researchTimeoutMs + 5_000, "council.research");
519
+ }), { label: "council.research" }), researchTimeoutMs + 5_000, "council.research", signal);
492
520
  cleanupTimeout();
493
521
  const researchUsage = logCouncilCost({
494
522
  callsite: "council.research",