mstro-app 0.5.1 → 0.5.6

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 (283) hide show
  1. package/PRIVACY.md +9 -9
  2. package/README.md +71 -28
  3. package/bin/commands/config.js +1 -1
  4. package/bin/mstro.js +55 -4
  5. package/dist/server/cli/eta-estimator.d.ts +55 -0
  6. package/dist/server/cli/eta-estimator.d.ts.map +1 -0
  7. package/dist/server/cli/eta-estimator.js +222 -0
  8. package/dist/server/cli/eta-estimator.js.map +1 -0
  9. package/dist/server/cli/headless/claude-invoker-process.d.ts.map +1 -1
  10. package/dist/server/cli/headless/claude-invoker-process.js +9 -1
  11. package/dist/server/cli/headless/claude-invoker-process.js.map +1 -1
  12. package/dist/server/cli/headless/mcp-config.d.ts +22 -5
  13. package/dist/server/cli/headless/mcp-config.d.ts.map +1 -1
  14. package/dist/server/cli/headless/mcp-config.js +7 -5
  15. package/dist/server/cli/headless/mcp-config.js.map +1 -1
  16. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  17. package/dist/server/cli/headless/runner.js +19 -0
  18. package/dist/server/cli/headless/runner.js.map +1 -1
  19. package/dist/server/cli/headless/stall-assessor.d.ts +50 -0
  20. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  21. package/dist/server/cli/headless/stall-assessor.js +64 -9
  22. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  23. package/dist/server/cli/headless/tool-watchdog.d.ts +21 -0
  24. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  25. package/dist/server/cli/headless/tool-watchdog.js +19 -12
  26. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  27. package/dist/server/cli/headless/types.d.ts +16 -1
  28. package/dist/server/cli/headless/types.d.ts.map +1 -1
  29. package/dist/server/cli/improvisation-history-store.d.ts.map +1 -1
  30. package/dist/server/cli/improvisation-history-store.js +5 -1
  31. package/dist/server/cli/improvisation-history-store.js.map +1 -1
  32. package/dist/server/cli/improvisation-output-queue.d.ts +5 -1
  33. package/dist/server/cli/improvisation-output-queue.d.ts.map +1 -1
  34. package/dist/server/cli/improvisation-output-queue.js +30 -7
  35. package/dist/server/cli/improvisation-output-queue.js.map +1 -1
  36. package/dist/server/cli/improvisation-session-manager.d.ts +35 -0
  37. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  38. package/dist/server/cli/improvisation-session-manager.js +58 -1
  39. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  40. package/dist/server/cli/improvisation-types.d.ts +9 -0
  41. package/dist/server/cli/improvisation-types.d.ts.map +1 -1
  42. package/dist/server/cli/improvisation-types.js.map +1 -1
  43. package/dist/server/cli/retry/retry-runner-factory.d.ts.map +1 -1
  44. package/dist/server/cli/retry/retry-runner-factory.js +1 -0
  45. package/dist/server/cli/retry/retry-runner-factory.js.map +1 -1
  46. package/dist/server/engines/EngineEvent.d.ts +126 -0
  47. package/dist/server/engines/EngineEvent.d.ts.map +1 -0
  48. package/dist/server/engines/EngineEvent.js +11 -0
  49. package/dist/server/engines/EngineEvent.js.map +1 -0
  50. package/dist/server/engines/claude/ClaudeCodeEngine.d.ts +47 -0
  51. package/dist/server/engines/claude/ClaudeCodeEngine.d.ts.map +1 -0
  52. package/dist/server/engines/claude/ClaudeCodeEngine.js +338 -0
  53. package/dist/server/engines/claude/ClaudeCodeEngine.js.map +1 -0
  54. package/dist/server/engines/factory.d.ts +21 -0
  55. package/dist/server/engines/factory.d.ts.map +1 -0
  56. package/dist/server/engines/factory.js +152 -0
  57. package/dist/server/engines/factory.js.map +1 -0
  58. package/dist/server/engines/opencode/OpenCodeEngine.d.ts +148 -0
  59. package/dist/server/engines/opencode/OpenCodeEngine.d.ts.map +1 -0
  60. package/dist/server/engines/opencode/OpenCodeEngine.js +630 -0
  61. package/dist/server/engines/opencode/OpenCodeEngine.js.map +1 -0
  62. package/dist/server/engines/opencode/OpenCodeServerManager.d.ts +172 -0
  63. package/dist/server/engines/opencode/OpenCodeServerManager.d.ts.map +1 -0
  64. package/dist/server/engines/opencode/OpenCodeServerManager.js +390 -0
  65. package/dist/server/engines/opencode/OpenCodeServerManager.js.map +1 -0
  66. package/dist/server/engines/opencode/model-catalog.d.ts +94 -0
  67. package/dist/server/engines/opencode/model-catalog.d.ts.map +1 -0
  68. package/dist/server/engines/opencode/model-catalog.js +141 -0
  69. package/dist/server/engines/opencode/model-catalog.js.map +1 -0
  70. package/dist/server/engines/types.d.ts +146 -0
  71. package/dist/server/engines/types.d.ts.map +1 -0
  72. package/dist/server/engines/types.js +4 -0
  73. package/dist/server/engines/types.js.map +1 -0
  74. package/dist/server/index.js +9 -2
  75. package/dist/server/index.js.map +1 -1
  76. package/dist/server/mcp/bouncer-haiku.d.ts +17 -4
  77. package/dist/server/mcp/bouncer-haiku.d.ts.map +1 -1
  78. package/dist/server/mcp/bouncer-haiku.js +8 -124
  79. package/dist/server/mcp/bouncer-haiku.js.map +1 -1
  80. package/dist/server/mcp/bouncer-integration.d.ts +45 -0
  81. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  82. package/dist/server/mcp/bouncer-integration.js +69 -5
  83. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  84. package/dist/server/mcp/classifier/BouncerClassifier.d.ts +34 -0
  85. package/dist/server/mcp/classifier/BouncerClassifier.d.ts.map +1 -0
  86. package/dist/server/mcp/classifier/BouncerClassifier.js +4 -0
  87. package/dist/server/mcp/classifier/BouncerClassifier.js.map +1 -0
  88. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts +17 -0
  89. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts.map +1 -0
  90. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js +142 -0
  91. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js.map +1 -0
  92. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts +68 -0
  93. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts.map +1 -0
  94. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js +182 -0
  95. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js.map +1 -0
  96. package/dist/server/mcp/classifier/factory.d.ts +70 -0
  97. package/dist/server/mcp/classifier/factory.d.ts.map +1 -0
  98. package/dist/server/mcp/classifier/factory.js +155 -0
  99. package/dist/server/mcp/classifier/factory.js.map +1 -0
  100. package/dist/server/mcp/server.js +52 -0
  101. package/dist/server/mcp/server.js.map +1 -1
  102. package/dist/server/routes/index.d.ts +1 -0
  103. package/dist/server/routes/index.d.ts.map +1 -1
  104. package/dist/server/routes/index.js +1 -0
  105. package/dist/server/routes/index.js.map +1 -1
  106. package/dist/server/routes/internal.d.ts +16 -0
  107. package/dist/server/routes/internal.d.ts.map +1 -0
  108. package/dist/server/routes/internal.js +94 -0
  109. package/dist/server/routes/internal.js.map +1 -0
  110. package/dist/server/services/plan/agent-resolver.d.ts +26 -0
  111. package/dist/server/services/plan/agent-resolver.d.ts.map +1 -0
  112. package/dist/server/services/plan/agent-resolver.js +102 -0
  113. package/dist/server/services/plan/agent-resolver.js.map +1 -0
  114. package/dist/server/services/plan/composer.d.ts.map +1 -1
  115. package/dist/server/services/plan/composer.js +59 -11
  116. package/dist/server/services/plan/composer.js.map +1 -1
  117. package/dist/server/services/plan/executor.d.ts.map +1 -1
  118. package/dist/server/services/plan/executor.js +3 -1
  119. package/dist/server/services/plan/executor.js.map +1 -1
  120. package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
  121. package/dist/server/services/plan/issue-prompt-builder.js +33 -1
  122. package/dist/server/services/plan/issue-prompt-builder.js.map +1 -1
  123. package/dist/server/services/plan/parser-core.d.ts.map +1 -1
  124. package/dist/server/services/plan/parser-core.js +1 -0
  125. package/dist/server/services/plan/parser-core.js.map +1 -1
  126. package/dist/server/services/plan/types.d.ts +1 -0
  127. package/dist/server/services/plan/types.d.ts.map +1 -1
  128. package/dist/server/services/runtime-info.d.ts +3 -0
  129. package/dist/server/services/runtime-info.d.ts.map +1 -0
  130. package/dist/server/services/runtime-info.js +21 -0
  131. package/dist/server/services/runtime-info.js.map +1 -0
  132. package/dist/server/services/settings.d.ts +76 -2
  133. package/dist/server/services/settings.d.ts.map +1 -1
  134. package/dist/server/services/settings.js +127 -4
  135. package/dist/server/services/settings.js.map +1 -1
  136. package/dist/server/services/websocket/ask-user-question-bridge.d.ts +32 -0
  137. package/dist/server/services/websocket/ask-user-question-bridge.d.ts.map +1 -0
  138. package/dist/server/services/websocket/ask-user-question-bridge.js +115 -0
  139. package/dist/server/services/websocket/ask-user-question-bridge.js.map +1 -0
  140. package/dist/server/services/websocket/git-branch-handlers.d.ts.map +1 -1
  141. package/dist/server/services/websocket/git-branch-handlers.js +19 -6
  142. package/dist/server/services/websocket/git-branch-handlers.js.map +1 -1
  143. package/dist/server/services/websocket/handler.d.ts +25 -1
  144. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  145. package/dist/server/services/websocket/handler.js +84 -2
  146. package/dist/server/services/websocket/handler.js.map +1 -1
  147. package/dist/server/services/websocket/quality-complexity.d.ts.map +1 -1
  148. package/dist/server/services/websocket/quality-complexity.js +78 -26
  149. package/dist/server/services/websocket/quality-complexity.js.map +1 -1
  150. package/dist/server/services/websocket/quality-eta.d.ts +47 -0
  151. package/dist/server/services/websocket/quality-eta.d.ts.map +1 -0
  152. package/dist/server/services/websocket/quality-eta.js +110 -0
  153. package/dist/server/services/websocket/quality-eta.js.map +1 -0
  154. package/dist/server/services/websocket/quality-grading.d.ts +27 -4
  155. package/dist/server/services/websocket/quality-grading.d.ts.map +1 -1
  156. package/dist/server/services/websocket/quality-grading.js +369 -201
  157. package/dist/server/services/websocket/quality-grading.js.map +1 -1
  158. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
  159. package/dist/server/services/websocket/quality-handlers.js +145 -7
  160. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  161. package/dist/server/services/websocket/quality-operations.d.ts +34 -0
  162. package/dist/server/services/websocket/quality-operations.d.ts.map +1 -0
  163. package/dist/server/services/websocket/quality-operations.js +47 -0
  164. package/dist/server/services/websocket/quality-operations.js.map +1 -0
  165. package/dist/server/services/websocket/quality-persistence.d.ts +9 -0
  166. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
  167. package/dist/server/services/websocket/quality-persistence.js +10 -0
  168. package/dist/server/services/websocket/quality-persistence.js.map +1 -1
  169. package/dist/server/services/websocket/quality-review-agent.d.ts +1 -1
  170. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  171. package/dist/server/services/websocket/quality-review-agent.js +105 -56
  172. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  173. package/dist/server/services/websocket/quality-service.d.ts +9 -1
  174. package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
  175. package/dist/server/services/websocket/quality-service.js +334 -14
  176. package/dist/server/services/websocket/quality-service.js.map +1 -1
  177. package/dist/server/services/websocket/quality-tools.d.ts +21 -0
  178. package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
  179. package/dist/server/services/websocket/quality-tools.js +49 -0
  180. package/dist/server/services/websocket/quality-tools.js.map +1 -1
  181. package/dist/server/services/websocket/quality-types.d.ts +35 -2
  182. package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
  183. package/dist/server/services/websocket/quality-types.js +1 -1
  184. package/dist/server/services/websocket/quality-types.js.map +1 -1
  185. package/dist/server/services/websocket/session-handlers.d.ts +3 -1
  186. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  187. package/dist/server/services/websocket/session-handlers.js +60 -9
  188. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  189. package/dist/server/services/websocket/session-history.js +3 -0
  190. package/dist/server/services/websocket/session-history.js.map +1 -1
  191. package/dist/server/services/websocket/session-initialization.d.ts.map +1 -1
  192. package/dist/server/services/websocket/session-initialization.js +158 -42
  193. package/dist/server/services/websocket/session-initialization.js.map +1 -1
  194. package/dist/server/services/websocket/session-registry.d.ts +25 -0
  195. package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
  196. package/dist/server/services/websocket/session-registry.js +19 -0
  197. package/dist/server/services/websocket/session-registry.js.map +1 -1
  198. package/dist/server/services/websocket/settings-handlers.d.ts +1 -1
  199. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -1
  200. package/dist/server/services/websocket/settings-handlers.js +35 -4
  201. package/dist/server/services/websocket/settings-handlers.js.map +1 -1
  202. package/dist/server/services/websocket/tab-broadcast.d.ts +7 -2
  203. package/dist/server/services/websocket/tab-broadcast.d.ts.map +1 -1
  204. package/dist/server/services/websocket/tab-broadcast.js +10 -2
  205. package/dist/server/services/websocket/tab-broadcast.js.map +1 -1
  206. package/dist/server/services/websocket/tab-event-buffer.d.ts +97 -8
  207. package/dist/server/services/websocket/tab-event-buffer.d.ts.map +1 -1
  208. package/dist/server/services/websocket/tab-event-buffer.js +138 -12
  209. package/dist/server/services/websocket/tab-event-buffer.js.map +1 -1
  210. package/dist/server/services/websocket/tab-event-replay.d.ts +29 -13
  211. package/dist/server/services/websocket/tab-event-replay.d.ts.map +1 -1
  212. package/dist/server/services/websocket/tab-event-replay.js +55 -2
  213. package/dist/server/services/websocket/tab-event-replay.js.map +1 -1
  214. package/dist/server/services/websocket/tab-handlers.d.ts +9 -1
  215. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
  216. package/dist/server/services/websocket/tab-handlers.js +47 -2
  217. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  218. package/dist/server/services/websocket/types.d.ts +67 -7
  219. package/dist/server/services/websocket/types.d.ts.map +1 -1
  220. package/dist/server/services/websocket/types.js +12 -6
  221. package/dist/server/services/websocket/types.js.map +1 -1
  222. package/package.json +5 -3
  223. package/server/cli/eta-estimator.ts +249 -0
  224. package/server/cli/headless/claude-invoker-process.ts +9 -1
  225. package/server/cli/headless/mcp-config.ts +30 -5
  226. package/server/cli/headless/runner.ts +21 -0
  227. package/server/cli/headless/stall-assessor.ts +93 -0
  228. package/server/cli/headless/tool-watchdog.ts +21 -0
  229. package/server/cli/headless/types.ts +16 -1
  230. package/server/cli/improvisation-history-store.ts +4 -1
  231. package/server/cli/improvisation-output-queue.ts +29 -7
  232. package/server/cli/improvisation-session-manager.ts +63 -1
  233. package/server/cli/improvisation-types.ts +9 -0
  234. package/server/cli/retry/retry-runner-factory.ts +1 -0
  235. package/server/engines/EngineEvent.ts +156 -0
  236. package/server/engines/claude/ClaudeCodeEngine.ts +404 -0
  237. package/server/engines/factory.ts +176 -0
  238. package/server/engines/opencode/OpenCodeEngine.ts +786 -0
  239. package/server/engines/opencode/OpenCodeServerManager.ts +577 -0
  240. package/server/engines/opencode/model-catalog.ts +217 -0
  241. package/server/engines/types.ts +173 -0
  242. package/server/index.ts +9 -1
  243. package/server/mcp/bouncer-haiku.ts +21 -145
  244. package/server/mcp/bouncer-integration.ts +107 -5
  245. package/server/mcp/classifier/BouncerClassifier.ts +40 -0
  246. package/server/mcp/classifier/ClaudeBouncerClassifier.ts +189 -0
  247. package/server/mcp/classifier/OpenCodeBouncerClassifier.ts +305 -0
  248. package/server/mcp/classifier/factory.ts +195 -0
  249. package/server/mcp/server.ts +57 -0
  250. package/server/routes/index.ts +1 -0
  251. package/server/routes/internal.ts +112 -0
  252. package/server/services/plan/agent-resolver.ts +115 -0
  253. package/server/services/plan/agents/code-review.md +38 -8
  254. package/server/services/plan/composer.ts +63 -11
  255. package/server/services/plan/executor.ts +3 -1
  256. package/server/services/plan/issue-prompt-builder.ts +39 -1
  257. package/server/services/plan/parser-core.ts +1 -0
  258. package/server/services/plan/types.ts +4 -0
  259. package/server/services/runtime-info.ts +24 -0
  260. package/server/services/settings.ts +161 -4
  261. package/server/services/websocket/ask-user-question-bridge.ts +148 -0
  262. package/server/services/websocket/git-branch-handlers.ts +20 -6
  263. package/server/services/websocket/handler.ts +89 -2
  264. package/server/services/websocket/quality-complexity.ts +80 -26
  265. package/server/services/websocket/quality-eta.ts +155 -0
  266. package/server/services/websocket/quality-grading.ts +445 -222
  267. package/server/services/websocket/quality-handlers.ts +153 -7
  268. package/server/services/websocket/quality-operations.ts +72 -0
  269. package/server/services/websocket/quality-persistence.ts +17 -0
  270. package/server/services/websocket/quality-review-agent.ts +154 -64
  271. package/server/services/websocket/quality-service.ts +361 -13
  272. package/server/services/websocket/quality-tools.ts +51 -0
  273. package/server/services/websocket/quality-types.ts +41 -2
  274. package/server/services/websocket/session-handlers.ts +67 -10
  275. package/server/services/websocket/session-history.ts +3 -0
  276. package/server/services/websocket/session-initialization.ts +189 -46
  277. package/server/services/websocket/session-registry.ts +37 -0
  278. package/server/services/websocket/settings-handlers.ts +41 -4
  279. package/server/services/websocket/tab-broadcast.ts +10 -2
  280. package/server/services/websocket/tab-event-buffer.ts +143 -11
  281. package/server/services/websocket/tab-event-replay.ts +70 -3
  282. package/server/services/websocket/tab-handlers.ts +53 -5
  283. package/server/services/websocket/types.ts +85 -7
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { existsSync, readFileSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
+ import { getEtaProfileCached } from '../../cli/eta-estimator.js';
13
14
  import type { ToolUseEvent } from '../../cli/headless/index.js';
14
15
  import { ResilientRunner } from '../../cli/headless/resilient-runner.js';
15
16
  import { cleanupAttachments, preparePromptAndAttachments } from '../../cli/improvisation-attachments.js';
@@ -28,6 +29,21 @@ const PROMPT_TOOL_MESSAGES: Record<string, string> = {
28
29
  Bash: 'Running commands...',
29
30
  };
30
31
 
32
+ /**
33
+ * Resolve the ETA quantile profile used by the planning indicator. Reads
34
+ * .mstro/history (chat improv movements) since planning-prompt durations
35
+ * cluster in the same range. Failures degrade silently to undefined — the
36
+ * indicator falls back to elapsed-only display.
37
+ */
38
+ async function resolvePromptEtaProfile(workingDir: string): Promise<NonNullable<Awaited<ReturnType<typeof getEtaProfileCached>>> | undefined> {
39
+ try {
40
+ const profile = await getEtaProfileCached(join(workingDir, '.mstro', 'history'));
41
+ return profile ?? undefined;
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+
31
47
  function getPromptToolCompleteMessage(event: ToolUseEvent): string | null {
32
48
  const input = event.completeInput;
33
49
  if (!input) return null;
@@ -229,6 +245,7 @@ blocks: [] # Use backlog-relative paths: backlog/IS-NNN.md
229
245
  review_gate: auto
230
246
  output_type: auto # code = modify source files, document = produce written artifact, auto = infer
231
247
  output_file: null
248
+ agents: [] # Agent hints — see "agents field rules" below
232
249
  ---
233
250
 
234
251
  # IS-NNN: Title
@@ -268,6 +285,21 @@ Implementation guidance.
268
285
  - When output_type is \`document\`, "Files to Modify" entries are treated as references, not files to edit. The AI produces a document artifact and is reviewed on document quality.
269
286
  - When output_type is \`code\`, "Files to Modify" lists actual source files the AI must edit. The review gate verifies source files were changed.
270
287
 
288
+ ## agents field rules
289
+
290
+ The \`agents\` field is a list of agent hints for the executing Claude Code session. The executor uses Claude Code's Task tool to delegate work to matching subagents in the user's \`.claude/agents/\` directory (project / global / bundled), with a fallback to the general-purpose agent when no match is found.
291
+
292
+ - ALWAYS populate \`agents\` with the most relevant 1–4 agents for the work the issue describes. Empty arrays mean "no hints" — only use \`[]\` when no agent type plausibly applies (rare).
293
+ - Entries can be specific agent file names (e.g. \`backend-architect\`, \`frontend-developer\`, \`code-reviewer\`, \`security-auditor\`) OR general role pointers the user's system can match (e.g. \`backend engineer\`, \`product designer\`, \`marketing\`). Prefer specific names — they resolve more reliably.
294
+ - Match agents to the actual work, not the issue's surface topic. A "fix login button" issue is frontend work (\`frontend-developer\`); a "design login flow" issue is product/design (\`product-designer\`, \`ux-writer\`).
295
+ - Common pairings:
296
+ - Code implementation: pick from \`frontend-developer\`, \`backend-architect\`, \`typescript-pro\`, \`python-pro\`, \`golang-pro\`, etc., based on stack
297
+ - UI/design work: \`ui-designer\`, \`product-designer\`, \`design-system-architect\`, \`ux-writer\`
298
+ - Data/DB: \`database-architect\`, \`database-optimizer\`, \`data-engineer\`, \`sql-pro\`
299
+ - Quality: pair an implementation agent with \`code-reviewer\`, \`test-automator\`, or \`security-auditor\` for sensitive issues
300
+ - Product/strategy: \`product-manager\`, \`product-marketing\`, \`business-analyst\`
301
+ - YAML format: inline \`agents: [backend-architect, code-reviewer]\` or block list with \`-\` items both work.
302
+
271
303
  ## Epic creation rules
272
304
 
273
305
  - Create an EP-*.md file in ${cc.backlogPath} with type: epic and a children: [] field in front matter
@@ -287,11 +319,27 @@ User request: ${userPrompt}`;
287
319
  prepareAttachmentPrompt(ctx, enrichedPrompt, attachments, workingDir, cc.effectiveBoardId);
288
320
 
289
321
  const streamBoardId = cc.effectiveBoardId ?? null;
322
+ const etaProfile = await resolvePromptEtaProfile(workingDir);
323
+
324
+ // Tracks whether `planPromptResponse` has been broadcast. The web side
325
+ // treats this event as the authoritative completion signal — without it,
326
+ // the composer todo list stays stuck on a spinner. The finally block
327
+ // guarantees a completion broadcast even if the runner throws or exits
328
+ // through an unexpected path.
329
+ let responseSent = false;
330
+ const sendResponse = (response: string, success: boolean, error: string | null) => {
331
+ if (responseSent) return;
332
+ responseSent = true;
333
+ ctx.broadcastToAll({
334
+ type: 'planPromptResponse',
335
+ data: { response, success, error, boardId: streamBoardId },
336
+ });
337
+ };
290
338
 
291
339
  try {
292
340
  ctx.broadcastToAll({
293
341
  type: 'planPromptProgress',
294
- data: { message: 'Starting project planning...', boardId: streamBoardId },
342
+ data: { message: 'Starting project planning...', boardId: streamBoardId, etaProfile },
295
343
  });
296
344
 
297
345
  const runner = new ResilientRunner({
@@ -339,15 +387,11 @@ User request: ${userPrompt}`;
339
387
  data: { message: 'Finalizing project plan...', boardId: streamBoardId },
340
388
  });
341
389
 
342
- ctx.broadcastToAll({
343
- type: 'planPromptResponse',
344
- data: {
345
- response: result.completed ? 'Prompt executed successfully.' : (result.error || 'Unknown error'),
346
- success: result.completed,
347
- error: result.error || null,
348
- boardId: streamBoardId,
349
- },
350
- });
390
+ sendResponse(
391
+ result.completed ? 'Prompt executed successfully.' : (result.error || 'Unknown error'),
392
+ result.completed,
393
+ result.error || null,
394
+ );
351
395
 
352
396
  // Re-parse and broadcast updated state
353
397
  const updatedState = parsePlanDirectory(workingDir);
@@ -355,11 +399,19 @@ User request: ${userPrompt}`;
355
399
  ctx.broadcastToAll({ type: 'planStateUpdated', data: updatedState });
356
400
  }
357
401
  } catch (error) {
402
+ const errorMsg = error instanceof Error ? error.message : String(error);
358
403
  ctx.broadcastToAll({
359
404
  type: 'planError',
360
- data: { error: error instanceof Error ? error.message : String(error), boardId: streamBoardId },
405
+ data: { error: errorMsg, boardId: streamBoardId },
361
406
  });
407
+ // Send a completion signal too — `planError` clears streaming on the web
408
+ // but doesn't set the response banner. Without this, the user sees a
409
+ // half-finished UI (no spinner, no message).
410
+ sendResponse(errorMsg, false, errorMsg);
362
411
  } finally {
363
412
  cleanupAttachments(workingDir, attachmentSessionId);
413
+ // Defense in depth: guarantee a completion broadcast for any control
414
+ // flow not covered above (process abort, unexpected throw types, etc.).
415
+ sendResponse('Prompt execution ended unexpectedly.', false, 'No completion signal');
364
416
  }
365
417
  }
@@ -265,7 +265,6 @@ export class PlanExecutor extends EventEmitter {
265
265
  /** Run waves until done, paused, stopped, or stalled. */
266
266
  private async runWaveLoop(): Promise<'done' | 'stalled' | 'dead'> {
267
267
  let consecutiveZeroCompletions = 0;
268
- const maxParallel = await getBoardMaxParallelAgents(this.context.pmDir, this.effectiveBoardId(), this.emitWarn);
269
268
 
270
269
  while (!this.shouldStop && !this.shouldPause) {
271
270
  const readyIssues = await this.pickReadyIssues();
@@ -274,6 +273,9 @@ export class PlanExecutor extends EventEmitter {
274
273
  return await this.hasDeadIssues() ? 'dead' : 'done';
275
274
  }
276
275
 
276
+ // Re-read on each wave so users can scale agents up/down mid-execution
277
+ // without restarting — the new value takes effect at the next wave boundary.
278
+ const maxParallel = await getBoardMaxParallelAgents(this.context.pmDir, this.effectiveBoardId(), this.emitWarn);
277
279
  const completedCount = await this.executeWave(readyIssues.slice(0, maxParallel));
278
280
 
279
281
  if (completedCount > 0) {
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { join } from 'node:path';
11
+ import { type ResolvedAgent, resolveAgentHints } from './agent-resolver.js';
11
12
  import { resolveIsCodeTask } from './issue-classification.js';
12
13
  import type { Issue } from './types.js';
13
14
 
@@ -21,6 +22,41 @@ export interface IssuePromptOptions {
21
22
  outputPath: string;
22
23
  }
23
24
 
25
+ /**
26
+ * Render the Agents section of an issue prompt. Splits resolved hints from
27
+ * unresolved ones so Claude knows which names are real subagents to delegate to
28
+ * via the Task tool, vs. role hints for which the user has no installed match.
29
+ */
30
+ function renderAgentsSection(resolved: ResolvedAgent[]): string {
31
+ if (resolved.length === 0) return '';
32
+
33
+ const matched = resolved.filter(r => r.resolvedName);
34
+ const unmatched = resolved.filter(r => !r.resolvedName);
35
+
36
+ const lines: string[] = ['', '## Suggested Agents'];
37
+
38
+ if (matched.length > 0) {
39
+ lines.push('Delegate the relevant portions of this work to these subagents using the Task tool (subagent_type = the agent name). Use them as primary executors when the work matches their expertise:');
40
+ for (const r of matched) {
41
+ const desc = r.info?.description ? ` — ${r.info.description}` : '';
42
+ const labelHint = r.hint.toLowerCase() !== (r.resolvedName ?? '').toLowerCase()
43
+ ? ` (matched from "${r.hint}")`
44
+ : '';
45
+ lines.push(`- \`${r.resolvedName}\`${labelHint}${desc}`);
46
+ }
47
+ }
48
+
49
+ if (unmatched.length > 0) {
50
+ lines.push('');
51
+ lines.push('Role hints with no installed subagent match — use the general-purpose agent (or your best judgment) for work in these areas:');
52
+ for (const r of unmatched) {
53
+ lines.push(`- ${r.hint}`);
54
+ }
55
+ }
56
+
57
+ return `\n${lines.join('\n')}`;
58
+ }
59
+
24
60
  /**
25
61
  * Build a self-contained prompt for one issue.
26
62
  * The resulting Claude Code session will work independently —
@@ -45,6 +81,8 @@ export function buildIssuePrompt(options: IssuePromptOptions): string {
45
81
  ? `\n## Predecessor Outputs\nRead these before starting — they contain context from upstream issues:\n${predecessorDocs.map(d => `- ${d}`).join('\n')}`
46
82
  : '';
47
83
 
84
+ const agentSection = renderAgentsSection(resolveAgentHints(issue.agents, workingDir));
85
+
48
86
  const outDir = boardDir ? join(boardDir, 'out') : pmDir ? join(pmDir, 'out') : join(workingDir, '.mstro', 'pm', 'out');
49
87
 
50
88
  return `You are executing issue ${issue.id}: ${issue.title}.
@@ -67,7 +105,7 @@ ${criteria || 'No specific criteria defined.'}
67
105
 
68
106
  ### Technical Notes
69
107
  ${issue.technicalNotes || 'None'}
70
- ${files}${predecessorSection}
108
+ ${files}${predecessorSection}${agentSection}
71
109
 
72
110
  ## Your Task
73
111
 
@@ -287,6 +287,7 @@ export function parseIssue(content: string, filePath: string): Issue {
287
287
  reviewGate: (['none', 'auto', 'required'].includes(String(fm.review_gate)) ? String(fm.review_gate) : 'auto') as Issue['reviewGate'],
288
288
  outputType: (['code', 'document', 'auto'].includes(String(fm.output_type)) ? String(fm.output_type) : 'auto') as Issue['outputType'],
289
289
  outputFile: optionalString(fm.output_file),
290
+ agents: toStringArray(fm.agents),
290
291
  body,
291
292
  path: filePath,
292
293
  };
@@ -98,6 +98,10 @@ export interface Issue {
98
98
  outputType: 'code' | 'document' | 'auto';
99
99
  // Planned output file path (from front matter output_file, relative to working dir)
100
100
  outputFile: string | null;
101
+ // Agent hints for the executing Claude Code session — names or general roles
102
+ // (e.g. ["backend-architect", "database-architect"], ["product, design"]).
103
+ // Empty = no agent hints; the executor uses default behavior.
104
+ agents: string[];
101
105
  // Full markdown body
102
106
  body: string;
103
107
  // File path relative to .mstro/pm/
@@ -0,0 +1,24 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+
3
+ /**
4
+ * Runtime Info
5
+ *
6
+ * Holds process-wide singletons that are known after server startup but
7
+ * needed by code paths (e.g. the headless runner / MCP config generator)
8
+ * that don't have a natural reference to the Hono app or instance registry.
9
+ *
10
+ * Specifically: the port the CLI server is bound to. We need it so the MCP
11
+ * bouncer subprocess can call back into us via HTTP for AskUserQuestion.
12
+ *
13
+ * Set once at server startup (see `server/index.ts`).
14
+ */
15
+
16
+ let currentPort: number | undefined;
17
+
18
+ export function setCurrentMstroPort(port: number): void {
19
+ currentPort = port;
20
+ }
21
+
22
+ export function getCurrentMstroPort(): number | undefined {
23
+ return currentPort;
24
+ }
@@ -15,10 +15,75 @@
15
15
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
16
16
  import { homedir } from 'node:os'
17
17
  import { join } from 'node:path'
18
+ import type { EngineId } from '../engines/types.js'
18
19
 
19
20
  const MSTRO_DIR = join(homedir(), '.mstro')
20
21
  const SETTINGS_FILE = join(MSTRO_DIR, 'settings.json')
21
22
 
23
+ /**
24
+ * Configuration for the Layer-2 Bouncer classifier (the AI model that runs
25
+ * for every ambiguous tool call). The model MUST be flagged
26
+ * `bouncerEligible` in the engine's model catalogue — frontier models
27
+ * (Opus, GPT-4o, …) are deliberately disallowed because they slow the
28
+ * classifier path and degrade the whole security layer.
29
+ */
30
+ export interface BouncerClassifierConfig {
31
+ engine: EngineId
32
+ /** Engine-specific model id, e.g. 'haiku', 'sonnet', 'openai/gpt-5-mini'. */
33
+ model: string
34
+ }
35
+
36
+ /**
37
+ * Canonical list of bouncer-eligible models per engine. Mirrors
38
+ * `web/src/components/views/SettingsView/constants.ts` — keep the two in
39
+ * sync. Only cheap/fast models appear here; if you need to add a model,
40
+ * check p50 latency < ~1s and JSON-mode capability first.
41
+ */
42
+ export const BOUNCER_ELIGIBLE_MODELS: Record<EngineId, readonly string[]> = {
43
+ 'claude-code': ['haiku', 'sonnet'],
44
+ opencode: [
45
+ 'openai/gpt-5-mini',
46
+ 'openai/gpt-5-nano',
47
+ 'google/gemini-2.5-flash',
48
+ 'ollama/llama3.1:8b',
49
+ ],
50
+ }
51
+
52
+ /** Default classifier — Claude Haiku. Matches the pre-feature-flag behavior. */
53
+ export const DEFAULT_BOUNCER_CLASSIFIER: BouncerClassifierConfig = {
54
+ engine: 'claude-code',
55
+ model: 'haiku',
56
+ }
57
+
58
+ /**
59
+ * Validate a `BouncerClassifierConfig`. Rejects with a thrown `Error` when
60
+ * the model is not flagged `bouncerEligible` under the requested engine —
61
+ * e.g. attempting to use Opus as a classifier, or a frontier OpenCode
62
+ * model. The WebSocket settings handler uses this to reject crafted
63
+ * payloads from the web client.
64
+ */
65
+ export function validateBouncerClassifier(config: unknown): BouncerClassifierConfig {
66
+ if (config === null || typeof config !== 'object') {
67
+ throw new Error('bouncerClassifier must be an object with { engine, model }')
68
+ }
69
+ const { engine, model } = config as { engine?: unknown; model?: unknown }
70
+ if (engine !== 'claude-code' && engine !== 'opencode') {
71
+ throw new Error(`bouncerClassifier.engine must be 'claude-code' or 'opencode' (got ${String(engine)})`)
72
+ }
73
+ if (typeof model !== 'string' || model.length === 0) {
74
+ throw new Error('bouncerClassifier.model must be a non-empty string')
75
+ }
76
+ const eligible = BOUNCER_ELIGIBLE_MODELS[engine]
77
+ if (!eligible.includes(model)) {
78
+ throw new Error(
79
+ `Model '${model}' is not bouncer-eligible for engine '${engine}'. ` +
80
+ `Eligible models: ${eligible.join(', ')}. ` +
81
+ `Frontier models (Opus, GPT-4o, etc.) are deliberately excluded to keep the classifier fast.`,
82
+ )
83
+ }
84
+ return { engine, model }
85
+ }
86
+
22
87
  export interface MstroSettings {
23
88
  /**
24
89
  * Claude model to use for main execution.
@@ -37,11 +102,26 @@ export interface MstroSettings {
37
102
  effortLevel: string
38
103
  /** Per-repo preferred PR base branch, keyed by normalized remote URL */
39
104
  prBaseBranches?: Record<string, string>
105
+ /**
106
+ * Feature flag gating all OpenCode code paths (engine factory, classifier
107
+ * factory, and UI). When `false`, the system behaves byte-identically to
108
+ * pre-OpenCode main: no `opencode serve` subprocess, no classifier picker,
109
+ * no EngineSection/EnginePicker in the web UI. Resolution order in
110
+ * `isEngineSwapEnabled()`: env var → stored setting → NODE_ENV default.
111
+ */
112
+ engineSwap?: boolean
113
+ /**
114
+ * Which engine + model backs the Layer-2 Bouncer classifier. Defaults to
115
+ * `{ engine: 'claude-code', model: 'haiku' }`. Only models flagged
116
+ * `bouncerEligible` are accepted — see {@link validateBouncerClassifier}.
117
+ */
118
+ bouncerClassifier?: BouncerClassifierConfig
40
119
  }
41
120
 
42
121
  const DEFAULT_SETTINGS: MstroSettings = {
43
122
  model: 'opus',
44
- effortLevel: 'auto'
123
+ effortLevel: 'auto',
124
+ bouncerClassifier: { ...DEFAULT_BOUNCER_CLASSIFIER },
45
125
  }
46
126
 
47
127
  /**
@@ -54,7 +134,11 @@ function ensureMstroDir(): void {
54
134
  }
55
135
 
56
136
  /**
57
- * Get current settings, merged with defaults for any missing fields
137
+ * Get current settings, merged with defaults for any missing fields. A
138
+ * persisted `bouncerClassifier` that is no longer bouncer-eligible (e.g. a
139
+ * catalogue change removed the model) is dropped in favor of the default
140
+ * and a warning is logged — the Bouncer must never silently run a
141
+ * non-eligible model just because someone edited settings.json by hand.
58
142
  */
59
143
  export function getSettings(): MstroSettings {
60
144
  if (!existsSync(SETTINGS_FILE)) {
@@ -64,10 +148,22 @@ export function getSettings(): MstroSettings {
64
148
  try {
65
149
  const content = readFileSync(SETTINGS_FILE, 'utf-8')
66
150
  const stored = JSON.parse(content)
67
- return {
151
+ const merged: MstroSettings = {
68
152
  ...DEFAULT_SETTINGS,
69
153
  ...stored,
70
154
  }
155
+ if (stored && typeof stored === 'object' && 'bouncerClassifier' in stored) {
156
+ try {
157
+ merged.bouncerClassifier = validateBouncerClassifier(stored.bouncerClassifier)
158
+ } catch (err) {
159
+ console.warn(
160
+ '[settings] Stored bouncerClassifier is not bouncer-eligible, falling back to default:',
161
+ err instanceof Error ? err.message : String(err),
162
+ )
163
+ merged.bouncerClassifier = { ...DEFAULT_BOUNCER_CLASSIFIER }
164
+ }
165
+ }
166
+ return merged
71
167
  } catch (err) {
72
168
  console.warn('Failed to read settings file, using defaults:', err)
73
169
  return { ...DEFAULT_SETTINGS }
@@ -75,9 +171,18 @@ export function getSettings(): MstroSettings {
75
171
  }
76
172
 
77
173
  /**
78
- * Save full settings to disk
174
+ * Save full settings to disk. Rejects if `bouncerClassifier` is present but
175
+ * its model is not flagged `bouncerEligible` — this is the save-time half
176
+ * of the guard; `getSettings` enforces the read-time half. Together they
177
+ * ensure the Bouncer is never configured with a frontier model (Opus,
178
+ * GPT-4o, …) regardless of whether the mutation came from the web UI or a
179
+ * direct edit of settings.json.
79
180
  */
80
181
  export function saveSettings(settings: MstroSettings): void {
182
+ if (settings.bouncerClassifier !== undefined) {
183
+ // Throws on non-eligible model — callers must surface the error.
184
+ validateBouncerClassifier(settings.bouncerClassifier)
185
+ }
81
186
  ensureMstroDir()
82
187
  writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), {
83
188
  mode: 0o600
@@ -116,6 +221,58 @@ export function setEffortLevel(effortLevel: string): void {
116
221
  saveSettings(settings)
117
222
  }
118
223
 
224
+ /**
225
+ * Get the current Bouncer classifier configuration. Returns the default
226
+ * `{ engine: 'claude-code', model: 'haiku' }` when nothing is persisted.
227
+ */
228
+ export function getBouncerClassifier(): BouncerClassifierConfig {
229
+ const settings = getSettings()
230
+ if (settings.bouncerClassifier) {
231
+ try {
232
+ return validateBouncerClassifier(settings.bouncerClassifier)
233
+ } catch {
234
+ // Stored config is no longer eligible (e.g. model removed from the
235
+ // catalogue). Fall back to the safe default rather than crashing.
236
+ return { ...DEFAULT_BOUNCER_CLASSIFIER }
237
+ }
238
+ }
239
+ return { ...DEFAULT_BOUNCER_CLASSIFIER }
240
+ }
241
+
242
+ /**
243
+ * Persist a new Bouncer classifier config. Throws if the model is not
244
+ * flagged `bouncerEligible` under the requested engine — callers should
245
+ * surface the error to the UI so the user sees a clear rejection reason.
246
+ */
247
+ export function setBouncerClassifier(config: unknown): BouncerClassifierConfig {
248
+ const validated = validateBouncerClassifier(config)
249
+ const settings = getSettings()
250
+ settings.bouncerClassifier = validated
251
+ saveSettings(settings)
252
+ return validated
253
+ }
254
+
255
+ /**
256
+ * Resolve the engineSwap feature flag. Precedence:
257
+ * 1. `MSTRO_ENABLE_ENGINE_SWAP` env var ('true'|'1' → on, 'false'|'0' → off).
258
+ * 2. `engineSwap` field in `~/.mstro/settings.json`.
259
+ * 3. NODE_ENV default — off in production, on otherwise (dev/staging/test).
260
+ *
261
+ * Callers who need a single boolean should use this helper rather than
262
+ * reading the field directly, so the precedence stays in one place.
263
+ */
264
+ export function isEngineSwapEnabled(): boolean {
265
+ const envFlag = process.env.MSTRO_ENABLE_ENGINE_SWAP
266
+ if (envFlag !== undefined) {
267
+ const normalized = envFlag.trim().toLowerCase()
268
+ if (normalized === 'true' || normalized === '1') return true
269
+ if (normalized === 'false' || normalized === '0') return false
270
+ }
271
+ const stored = getSettings().engineSwap
272
+ if (typeof stored === 'boolean') return stored
273
+ return process.env.NODE_ENV !== 'production'
274
+ }
275
+
119
276
  /** Normalize a remote URL into a stable key (e.g. "github.com/owner/repo") */
120
277
  function normalizeRemoteUrl(remoteUrl: string): string {
121
278
  return remoteUrl
@@ -0,0 +1,148 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+
3
+ /**
4
+ * AskUserQuestion Bridge
5
+ *
6
+ * Bridges the MCP bouncer subprocess (which receives Claude's AskUserQuestion
7
+ * tool calls) and the web client (which collects the user's answers).
8
+ *
9
+ * Flow:
10
+ * Claude → MCP bouncer (subprocess)
11
+ * → POST /internal/ask-user-question (this CLI server)
12
+ * → registerPendingQuestion() stores a resolver
13
+ * → broadcastTabEvent('askUserQuestion', …) pushes the question to web
14
+ * → web user answers → WS `askUserQuestionResponse`
15
+ * → resolvePendingQuestion() resolves the awaited promise
16
+ * → HTTP response to bouncer with answers
17
+ * → bouncer returns { behavior: "allow", updatedInput: { questions, answers } }
18
+ *
19
+ * Ownership of state: pending questions live only here, in-process. The
20
+ * registry is keyed by `toolUseId` (Claude's per-call id) which guarantees
21
+ * uniqueness across tabs and sessions.
22
+ *
23
+ * Timeouts: questions auto-reject after `DEFAULT_TIMEOUT_MS`. The bouncer's
24
+ * HTTP call gets a 504 and returns a deny to Claude rather than blocking the
25
+ * Claude turn forever.
26
+ */
27
+
28
+ import { randomUUID } from 'node:crypto';
29
+
30
+ /** Default per-question timeout (15 minutes). */
31
+ const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
32
+
33
+ interface PendingQuestion {
34
+ toolUseId: string;
35
+ tabId: string;
36
+ resolve: (answers: Record<string, string>) => void;
37
+ reject: (reason: 'timeout' | 'cancelled' | 'session-ended') => void;
38
+ timer: ReturnType<typeof setTimeout>;
39
+ createdAt: number;
40
+ }
41
+
42
+ const pending = new Map<string, PendingQuestion>();
43
+
44
+ /** Per-process secret the MCP bouncer must echo to authenticate.
45
+ * Generated once at server start, passed to bouncers via env var. */
46
+ const bouncerSharedSecret = randomUUID();
47
+
48
+ /** Get the per-process bouncer secret (passed via env var to bouncer subprocesses). */
49
+ export function getBouncerSecret(): string {
50
+ return bouncerSharedSecret;
51
+ }
52
+
53
+ /** Validate a secret claimed by an inbound /internal request. */
54
+ export function isValidBouncerSecret(secret: string | undefined | null): boolean {
55
+ if (!secret) return false;
56
+ return secret === bouncerSharedSecret;
57
+ }
58
+
59
+ export interface RegisterPendingQuestionOptions {
60
+ toolUseId: string;
61
+ tabId: string;
62
+ timeoutMs?: number;
63
+ }
64
+
65
+ /**
66
+ * Register a pending question. The returned promise resolves when
67
+ * `resolvePendingQuestion` is called for the same toolUseId, or rejects on
68
+ * timeout / cancellation.
69
+ */
70
+ export function registerPendingQuestion(
71
+ opts: RegisterPendingQuestionOptions,
72
+ ): Promise<Record<string, string>> {
73
+ const { toolUseId, tabId, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
74
+
75
+ // Defensive: reject any prior pending entry for this id (shouldn't happen
76
+ // but a process restart or duplicate POST shouldn't leak handlers).
77
+ const existing = pending.get(toolUseId);
78
+ if (existing) {
79
+ clearTimeout(existing.timer);
80
+ existing.reject('cancelled');
81
+ pending.delete(toolUseId);
82
+ }
83
+
84
+ return new Promise<Record<string, string>>((resolve, reject) => {
85
+ const timer = setTimeout(() => {
86
+ const entry = pending.get(toolUseId);
87
+ if (!entry) return;
88
+ pending.delete(toolUseId);
89
+ entry.reject('timeout');
90
+ }, timeoutMs);
91
+
92
+ pending.set(toolUseId, {
93
+ toolUseId,
94
+ tabId,
95
+ resolve,
96
+ reject: (reason) => reject(new Error(reason)),
97
+ timer,
98
+ createdAt: Date.now(),
99
+ });
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Resolve a pending question with the user's answers. Returns true if a
105
+ * pending entry was found and resolved; false if there was no matching
106
+ * pending question (already answered, timed out, or unknown id).
107
+ */
108
+ export function resolvePendingQuestion(
109
+ toolUseId: string,
110
+ answers: Record<string, string>,
111
+ ): boolean {
112
+ const entry = pending.get(toolUseId);
113
+ if (!entry) return false;
114
+ pending.delete(toolUseId);
115
+ clearTimeout(entry.timer);
116
+ entry.resolve(answers);
117
+ return true;
118
+ }
119
+
120
+ /**
121
+ * Cancel all pending questions for a given tab. Used when a tab is removed,
122
+ * a session is reset, or an orchestra disconnects. Returns the toolUseIds
123
+ * that were cancelled so callers can broadcast `askUserQuestionDismissed`.
124
+ */
125
+ export function cancelPendingQuestionsForTab(
126
+ tabId: string,
127
+ reason: 'cancelled' | 'session-ended' = 'cancelled',
128
+ ): string[] {
129
+ const cancelled: string[] = [];
130
+ for (const [id, entry] of pending) {
131
+ if (entry.tabId !== tabId) continue;
132
+ pending.delete(id);
133
+ clearTimeout(entry.timer);
134
+ entry.reject(reason);
135
+ cancelled.push(id);
136
+ }
137
+ return cancelled;
138
+ }
139
+
140
+ /** Look up the tab that owns a pending question, or undefined. */
141
+ export function getPendingQuestionTab(toolUseId: string): string | undefined {
142
+ return pending.get(toolUseId)?.tabId;
143
+ }
144
+
145
+ /** Diagnostic: how many questions are currently waiting on user input. */
146
+ export function pendingQuestionCount(): number {
147
+ return pending.size;
148
+ }