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
@@ -0,0 +1,249 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+
3
+ /**
4
+ * ETA estimator for the chat composing indicator.
5
+ *
6
+ * Reads recent movements from `.mstro/history/*.json` and builds a small
7
+ * conditional-quantile table: for each elapsed-time checkpoint, the p50/p90
8
+ * of TOTAL movement duration among movements that hadn't finished yet at
9
+ * that elapsed time. The web indicator interpolates against this table to
10
+ * render "Composing · {elapsed} · ~{p50} typical · {tokens}".
11
+ *
12
+ * Why conditional-on-elapsed and not a regression on prompt features:
13
+ * - prompt length is uncorrelated with duration (r≈0.05); tool count is
14
+ * strong (r≈0.74) but unknown a priori. Conditioning on elapsed alone
15
+ * beats a static estimate dramatically — accuracy at 5m elapsed is
16
+ * ~38% MAPE vs 160% at 0s with the same lookup, because the longer the
17
+ * run goes, the smaller the cohort it could still belong to.
18
+ *
19
+ * Why a quantile table and not a regression model:
20
+ * - The duration distribution is heavily skewed (mean 4m20s, median 1m49s,
21
+ * p99 29m). A point estimate from a regression would be misleading; the
22
+ * web shows a typical/range pair so users see "around X, can be up to Y".
23
+ *
24
+ * Sample selection:
25
+ * - Up to MAX_SAMPLE_FILES most recent files by mtime, keeping work bounded
26
+ * and biasing toward recent behavior. Movements with durationMs < 1s or
27
+ * above SANITY_CEILING_MS are dropped as outliers (cancelled before they
28
+ * started, or runaway sessions that don't represent typical waits).
29
+ *
30
+ * Returns `null` when there are fewer than MIN_SAMPLES movements; the caller
31
+ * falls back to "no ETA" rather than inventing one from too little data.
32
+ */
33
+
34
+ import { promises as fsp } from 'node:fs';
35
+ import { join } from 'node:path';
36
+ import type { SessionHistory } from './improvisation-types.js';
37
+
38
+ /** Bucket boundaries (ms) at which we precompute conditional quantiles. */
39
+ const ELAPSED_CHECKPOINTS_MS = [
40
+ 0, // a-priori (elapsed=0)
41
+ 10_000, // 10s
42
+ 30_000, // 30s
43
+ 60_000, // 1m
44
+ 120_000, // 2m
45
+ 300_000, // 5m
46
+ 600_000, // 10m
47
+ 900_000, // 15m
48
+ 1_500_000, // 25m
49
+ 2_400_000, // 40m
50
+ 3_600_000, // 60m
51
+ ];
52
+
53
+ const MAX_SAMPLE_FILES = 200;
54
+ const MIN_SAMPLES = 30;
55
+ const SANITY_FLOOR_MS = 1_000; // <1s = noise (errors, instant cancels)
56
+ const SANITY_CEILING_MS = 6 * 60 * 60_000; // 6h cap
57
+
58
+ export interface EtaBucket {
59
+ /** Elapsed-ms threshold for this bucket. */
60
+ elapsedMs: number;
61
+ /** Conditional p50 of TOTAL duration among movements still running at elapsedMs. */
62
+ p50TotalMs: number;
63
+ /** Conditional p90 of TOTAL duration. */
64
+ p90TotalMs: number;
65
+ /** Sample count behind this bucket. */
66
+ n: number;
67
+ }
68
+
69
+ export interface EtaProfile {
70
+ /** Buckets in ascending elapsedMs. */
71
+ buckets: EtaBucket[];
72
+ /** Number of movements the profile was built from. */
73
+ sampleSize: number;
74
+ /** ISO timestamp of when this profile was computed. */
75
+ computedAt: string;
76
+ }
77
+
78
+ export interface EtaPrediction {
79
+ /** Predicted total duration (p50). Always >= elapsed. */
80
+ p50TotalMs: number;
81
+ /** Predicted upper bound (p90). Always >= p50. */
82
+ p90TotalMs: number;
83
+ /** Sample size for the bucket used. */
84
+ n: number;
85
+ }
86
+
87
+ /**
88
+ * Build an EtaProfile from a `.mstro/history/` directory. Returns null if
89
+ * there isn't enough data to form a stable estimate.
90
+ */
91
+ export async function buildEtaProfile(
92
+ historyDir: string,
93
+ opts: { maxFiles?: number } = {},
94
+ ): Promise<EtaProfile | null> {
95
+ const maxFiles = opts.maxFiles ?? MAX_SAMPLE_FILES;
96
+ const durations = await collectRecentDurations(historyDir, maxFiles);
97
+ if (durations.length < MIN_SAMPLES) return null;
98
+ return buildProfileFromDurations(durations);
99
+ }
100
+
101
+ /**
102
+ * Cached variant for the WebSocket flow: same project's many tabs ask for
103
+ * the same profile within minutes of each other, and rescanning 200 files
104
+ * each time wastes I/O. Cache by historyDir with a TTL so that fresh
105
+ * movements eventually feed back into the estimate.
106
+ *
107
+ * Falls back to BASELINE_ETA_PROFILE when the local history is too thin —
108
+ * new installs still get a sensible "Composing · Xs / ~Ys" indicator from
109
+ * prompt 1 instead of waiting for 30+ runs to accumulate.
110
+ */
111
+ const PROFILE_CACHE_TTL_MS = 5 * 60_000; // 5 minutes
112
+ const profileCache = new Map<string, { profile: EtaProfile | null; expiresAt: number; pending?: Promise<EtaProfile | null> }>();
113
+
114
+ export async function getEtaProfileCached(historyDir: string): Promise<EtaProfile | null> {
115
+ const now = Date.now();
116
+ const hit = profileCache.get(historyDir);
117
+ if (hit && hit.expiresAt > now) return hit.profile ?? BASELINE_ETA_PROFILE;
118
+ if (hit?.pending) return hit.pending;
119
+ const pending = buildEtaProfile(historyDir).then(profile => {
120
+ profileCache.set(historyDir, { profile, expiresAt: Date.now() + PROFILE_CACHE_TTL_MS });
121
+ return profile ?? BASELINE_ETA_PROFILE;
122
+ }).catch(() => {
123
+ profileCache.set(historyDir, { profile: null, expiresAt: Date.now() + PROFILE_CACHE_TTL_MS });
124
+ return BASELINE_ETA_PROFILE;
125
+ });
126
+ profileCache.set(historyDir, { profile: hit?.profile ?? null, expiresAt: hit?.expiresAt ?? 0, pending });
127
+ return pending;
128
+ }
129
+
130
+ /** Test hook: clear the in-process cache. */
131
+ export function _clearEtaCache(): void { profileCache.clear(); }
132
+
133
+ /**
134
+ * Baseline profile shipped in the package so a fresh install (no
135
+ * `.mstro/history`) still gets a useful "typical" estimate from the very
136
+ * first prompt. Numbers below were computed offline from the largest
137
+ * available real-world history sample (mstro's own project, 379 movements
138
+ * spanning short Q&A through multi-hour autonomous runs); they reflect a
139
+ * heavy mix of chat, planning, and execution prompts. Once a project
140
+ * accumulates >= MIN_SAMPLES local movements its own profile takes over.
141
+ */
142
+ export const BASELINE_ETA_PROFILE: EtaProfile = {
143
+ buckets: [
144
+ { elapsedMs: 0, p50TotalMs: 108_000, p90TotalMs: 768_000, n: 379 },
145
+ { elapsedMs: 10_000, p50TotalMs: 117_000, p90TotalMs: 769_000, n: 368 },
146
+ { elapsedMs: 30_000, p50TotalMs: 155_000, p90TotalMs: 860_000, n: 328 },
147
+ { elapsedMs: 60_000, p50TotalMs: 245_000, p90TotalMs: 1_013_000, n: 252 },
148
+ { elapsedMs: 120_000, p50TotalMs: 392_000, p90TotalMs: 1_171_000, n: 182 },
149
+ { elapsedMs: 300_000, p50TotalMs: 605_000, p90TotalMs: 1_412_000, n: 116 },
150
+ { elapsedMs: 600_000, p50TotalMs: 945_000, p90TotalMs: 1_679_000, n: 58 },
151
+ { elapsedMs: 900_000, p50TotalMs: 1_265_000, p90TotalMs: 1_845_000, n: 30 },
152
+ { elapsedMs: 1_500_000, p50TotalMs: 1_728_000, p90TotalMs: 1_986_000, n: 10 },
153
+ ],
154
+ sampleSize: 379,
155
+ computedAt: '2026-05-06T00:00:00.000Z',
156
+ };
157
+
158
+ /** Synchronously build a profile from an in-memory list of durationMs values. Exposed for tests. */
159
+ export function buildProfileFromDurations(durationsMs: number[]): EtaProfile {
160
+ const cleaned = durationsMs
161
+ .filter(d => Number.isFinite(d) && d >= SANITY_FLOOR_MS && d <= SANITY_CEILING_MS)
162
+ .sort((a, b) => a - b);
163
+ const buckets: EtaBucket[] = [];
164
+ for (const elapsedMs of ELAPSED_CHECKPOINTS_MS) {
165
+ const stillRunning = cleaned.filter(d => d > elapsedMs);
166
+ if (stillRunning.length === 0) break;
167
+ buckets.push({
168
+ elapsedMs,
169
+ p50TotalMs: quantile(stillRunning, 0.5),
170
+ p90TotalMs: quantile(stillRunning, 0.9),
171
+ n: stillRunning.length,
172
+ });
173
+ }
174
+ return {
175
+ buckets,
176
+ sampleSize: cleaned.length,
177
+ computedAt: new Date().toISOString(),
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Predict total duration given current elapsed ms. Returns null if the
183
+ * profile has no usable buckets. The returned p50 is clamped to elapsed (so
184
+ * the indicator never shows a typical that has already passed).
185
+ */
186
+ export function predictEta(profile: EtaProfile, elapsedMs: number): EtaPrediction | null {
187
+ if (profile.buckets.length === 0) return null;
188
+ let bucket: EtaBucket = profile.buckets[0];
189
+ for (const b of profile.buckets) {
190
+ if (b.elapsedMs <= elapsedMs) bucket = b;
191
+ else break;
192
+ }
193
+ // If elapsed has surpassed the last bucket's p50, the run is in the long
194
+ // tail. Keep the last bucket's quantiles but never report a "typical" that
195
+ // is shorter than elapsed itself — that would be nonsensical UX.
196
+ const p50TotalMs = Math.max(bucket.p50TotalMs, elapsedMs);
197
+ const p90TotalMs = Math.max(bucket.p90TotalMs, p50TotalMs);
198
+ return { p50TotalMs, p90TotalMs, n: bucket.n };
199
+ }
200
+
201
+ // -- internals --
202
+
203
+ async function collectRecentDurations(historyDir: string, maxFiles: number): Promise<number[]> {
204
+ let entries: string[];
205
+ try {
206
+ entries = (await fsp.readdir(historyDir)).filter(f => f.endsWith('.json'));
207
+ } catch {
208
+ return [];
209
+ }
210
+ if (entries.length === 0) return [];
211
+
212
+ // Sort by mtime DESC for recency. statting up to N files is acceptable —
213
+ // even a few thousand stats is sub-100ms on local disk.
214
+ const stats = await Promise.all(
215
+ entries.map(async name => {
216
+ try {
217
+ const full = join(historyDir, name);
218
+ const s = await fsp.stat(full);
219
+ return { full, mtime: s.mtimeMs };
220
+ } catch {
221
+ return null;
222
+ }
223
+ }),
224
+ );
225
+ const ordered = stats
226
+ .filter((x): x is { full: string; mtime: number } => x !== null)
227
+ .sort((a, b) => b.mtime - a.mtime)
228
+ .slice(0, maxFiles);
229
+
230
+ const durations: number[] = [];
231
+ for (const { full } of ordered) {
232
+ let raw: string;
233
+ try { raw = await fsp.readFile(full, 'utf-8'); } catch { continue; }
234
+ let data: SessionHistory;
235
+ try { data = JSON.parse(raw) as SessionHistory; } catch { continue; }
236
+ if (!Array.isArray(data.movements)) continue;
237
+ for (const m of data.movements) {
238
+ const d = m.durationMs;
239
+ if (typeof d === 'number' && Number.isFinite(d)) durations.push(d);
240
+ }
241
+ }
242
+ return durations;
243
+ }
244
+
245
+ function quantile(sortedAsc: number[], q: number): number {
246
+ if (sortedAsc.length === 0) return 0;
247
+ const idx = Math.min(sortedAsc.length - 1, Math.floor(sortedAsc.length * q));
248
+ return sortedAsc[idx];
249
+ }
@@ -138,7 +138,15 @@ export async function spawnAndRegister(
138
138
  runningProcesses: Map<number, ChildProcess>,
139
139
  perfStart: number,
140
140
  ): Promise<ChildProcess> {
141
- const mcpConfigPath = generateMcpConfig(config.workingDir, config.verbose, prompt, randomUUID(), config.deployMode);
141
+ const askUserQuestionRouting = (config.tabId && config.mstroPort && config.bouncerSecret)
142
+ ? { tabId: config.tabId, port: config.mstroPort, bouncerSecret: config.bouncerSecret }
143
+ : undefined;
144
+ const mcpConfigPath = generateMcpConfig(config.workingDir, config.verbose, {
145
+ userPrompt: prompt,
146
+ sessionId: randomUUID(),
147
+ deployMode: config.deployMode,
148
+ askUserQuestionRouting,
149
+ });
142
150
 
143
151
  if (!mcpConfigPath && config.outputCallback) {
144
152
  config.outputCallback(
@@ -58,16 +58,36 @@ function truncatePrompt(prompt: string): string {
58
58
  return `${clean}... [truncated]`;
59
59
  }
60
60
 
61
+ /**
62
+ * Routing context for the AskUserQuestion bridge. The bouncer subprocess
63
+ * uses these env vars to call back into the CLI server when Claude pauses
64
+ * on AskUserQuestion. Optional — without them the bouncer falls back to
65
+ * passing the tool through with no answers (same as legacy behavior).
66
+ */
67
+ export interface AskUserQuestionRouting {
68
+ /** Local CLI server port (e.g. 4101). */
69
+ port: number;
70
+ /** Tab the question should be routed to in the web UI. */
71
+ tabId: string;
72
+ /** Per-process bouncer secret from `getBouncerSecret()`. */
73
+ bouncerSecret: string;
74
+ }
75
+
76
+ export interface GenerateMcpConfigOptions {
77
+ userPrompt?: string;
78
+ /** Unique sessionId for the per-session config file name (filename only). */
79
+ sessionId?: string;
80
+ deployMode?: boolean;
81
+ askUserQuestionRouting?: AskUserQuestionRouting;
82
+ }
83
+
61
84
  /**
62
85
  * Generate MCP config with bouncer + user's MCP servers from ~/.claude.json.
63
86
  * Writes to ~/.mstro/mcp-config-{sessionId}.json for use with --mcp-config flag.
64
87
  * Per-session files prevent concurrent sessions from overwriting each other's config.
65
- *
66
- * @param userPrompt — The user's original prompt, passed to the bouncer so its
67
- * AI layer can distinguish user-requested operations from prompt injection.
68
- * @param sessionId — Unique session identifier for per-session config isolation.
69
88
  */
70
- export function generateMcpConfig(workingDir: string, verbose: boolean = false, userPrompt?: string, sessionId?: string, deployMode?: boolean): string | null {
89
+ export function generateMcpConfig(workingDir: string, verbose: boolean = false, options: GenerateMcpConfigOptions = {}): string | null {
90
+ const { userPrompt, sessionId, deployMode, askUserQuestionRouting } = options;
71
91
  try {
72
92
  if (!existsSync(MCP_SERVER_PATH)) {
73
93
  herror(`[${new Date().toISOString()}] MCP server not found at ${MCP_SERVER_PATH}`);
@@ -86,6 +106,11 @@ export function generateMcpConfig(workingDir: string, verbose: boolean = false,
86
106
  ? truncatePrompt(userPrompt)
87
107
  : userPrompt;
88
108
  }
109
+ if (askUserQuestionRouting) {
110
+ bouncerEnv.MSTRO_PORT = String(askUserQuestionRouting.port);
111
+ bouncerEnv.MSTRO_TAB_ID = askUserQuestionRouting.tabId;
112
+ bouncerEnv.MSTRO_BOUNCER_SECRET = askUserQuestionRouting.bouncerSecret;
113
+ }
89
114
 
90
115
  const mcpServers: Record<string, unknown> = {
91
116
  'mstro-bouncer': {
@@ -8,6 +8,8 @@
8
8
  */
9
9
 
10
10
  import type { ChildProcess } from 'node:child_process';
11
+ import { getCurrentMstroPort } from '../../services/runtime-info.js';
12
+ import { getBouncerSecret } from '../../services/websocket/ask-user-question-bridge.js';
11
13
  import { type ClaudeInvokerOptions, executeClaudeCommand } from './claude-invoker.js';
12
14
  import { estimateTokensFromOutput } from './output-utils.js';
13
15
  import { enrichPromptWithContext } from './prompt-utils.js';
@@ -19,6 +21,22 @@ import type {
19
21
  SessionResult,
20
22
  } from './types.js';
21
23
 
24
+ /**
25
+ * Process-wide singletons used to wire AskUserQuestion routing. Both return
26
+ * undefined if the server hasn't started yet (e.g. unit-test contexts that
27
+ * construct HeadlessRunner directly), in which case AskUserQuestion falls
28
+ * back to legacy "no answers" behavior.
29
+ */
30
+ function readDefaultMstroPort(): number | undefined {
31
+ return getCurrentMstroPort();
32
+ }
33
+
34
+ function readDefaultBouncerSecret(): string | undefined {
35
+ // The bridge module's secret is generated at module-eval time, so it's
36
+ // always defined. We still null-coalesce in the caller for symmetry.
37
+ return getBouncerSecret();
38
+ }
39
+
22
40
  // Re-export types for backward compatibility
23
41
  export type { ExecutionCheckpoint, HeadlessConfig, ImageAttachment, SessionResult, SessionState, ToolTimeoutProfile, ToolUseEvent } from './types.js';
24
42
 
@@ -129,6 +147,9 @@ export class HeadlessRunner {
129
147
  onToolTimeout: config.onToolTimeout,
130
148
  extraEnv: config.extraEnv,
131
149
  deployMode: config.deployMode,
150
+ tabId: config.tabId,
151
+ mstroPort: config.mstroPort ?? readDefaultMstroPort(),
152
+ bouncerSecret: config.bouncerSecret ?? readDefaultBouncerSecret(),
132
153
  };
133
154
  }
134
155
 
@@ -10,6 +10,7 @@
10
10
  * best result, error classification) live in haiku-assessments.ts.
11
11
  */
12
12
 
13
+ import type { EngineEvent } from '../../engines/EngineEvent.js';
13
14
  import { loadSkillPrompt } from '../../services/plan/agent-loader.js';
14
15
  import { spawnHaikuRaw } from './haiku-assessments.js';
15
16
  import { hlog } from './headless-logger.js';
@@ -36,6 +37,98 @@ export interface StallVerdict {
36
37
  reason: string;
37
38
  }
38
39
 
40
+ /**
41
+ * Mutable tool-activity accumulator fed by an engine-agnostic `EngineEvent`
42
+ * stream. Consumed by {@link buildStallContext} to produce the tool-related
43
+ * fields of a {@link StallContext} without coupling to any specific engine's
44
+ * internal shapes.
45
+ */
46
+ export interface ToolActivityState {
47
+ /** Tool calls observed by `tool.start` but not yet ended. */
48
+ pendingToolIds: Set<string>;
49
+ /** Names of tools still pending (used by the stall heuristic). */
50
+ pendingToolNames: Set<string>;
51
+ /** Map of toolId -> toolName so `tool.end` can drop names when the last id goes. */
52
+ pendingToolNameById: Map<string, string>;
53
+ /** Last tool name seen via `tool.start`. */
54
+ lastToolName?: string;
55
+ /** Short summary of the last tool input (url/query/command/prompt). */
56
+ lastToolInputSummary?: string;
57
+ /** Total number of `tool.start` events observed this session. */
58
+ totalToolCalls: number;
59
+ }
60
+
61
+ /** Allocate a fresh, empty tool-activity state. */
62
+ export function createToolActivityState(): ToolActivityState {
63
+ return {
64
+ pendingToolIds: new Set(),
65
+ pendingToolNames: new Set(),
66
+ pendingToolNameById: new Map(),
67
+ totalToolCalls: 0,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Update a {@link ToolActivityState} from a single engine event. Non-tool
73
+ * events are ignored. This lets the stall assessor operate on any
74
+ * CodingAgentEngine's event stream without knowing the engine's internals.
75
+ */
76
+ export function applyEngineEventToActivity(state: ToolActivityState, event: EngineEvent): void {
77
+ if (event.kind === 'tool.start') {
78
+ state.pendingToolIds.add(event.toolCallId);
79
+ state.pendingToolNames.add(event.toolName);
80
+ state.pendingToolNameById.set(event.toolCallId, event.toolName);
81
+ state.lastToolName = event.toolName;
82
+ state.lastToolInputSummary = summarizeToolInput(event.input);
83
+ state.totalToolCalls++;
84
+ return;
85
+ }
86
+ if (event.kind === 'tool.end') {
87
+ state.pendingToolIds.delete(event.toolCallId);
88
+ state.pendingToolNameById.delete(event.toolCallId);
89
+ // Only drop the name from pendingToolNames if no other pending call uses it.
90
+ const stillPending = Array.from(state.pendingToolNameById.values()).includes(event.toolName);
91
+ if (!stillPending) state.pendingToolNames.delete(event.toolName);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Build a {@link StallContext} from an engine-agnostic activity state plus
97
+ * the caller-owned timing fields. The stall heuristics and Haiku assessment
98
+ * in this module already operate on {@link StallContext}, so they are now
99
+ * fully drivable by any CodingAgentEngine's event stream.
100
+ */
101
+ export function buildStallContext(
102
+ activity: ToolActivityState,
103
+ timing: {
104
+ originalPrompt: string;
105
+ silenceMs: number;
106
+ elapsedTotalMs: number;
107
+ tokenSilenceMs?: number;
108
+ },
109
+ ): StallContext {
110
+ return {
111
+ originalPrompt: timing.originalPrompt,
112
+ silenceMs: timing.silenceMs,
113
+ elapsedTotalMs: timing.elapsedTotalMs,
114
+ tokenSilenceMs: timing.tokenSilenceMs,
115
+ lastToolName: activity.lastToolName,
116
+ lastToolInputSummary: activity.lastToolInputSummary,
117
+ pendingToolCount: activity.pendingToolIds.size,
118
+ pendingToolNames: new Set(activity.pendingToolNames),
119
+ totalToolCalls: activity.totalToolCalls,
120
+ };
121
+ }
122
+
123
+ function summarizeToolInput(input: Record<string, unknown>): string | undefined {
124
+ if (input.url) return `URL: ${String(input.url).slice(0, 200)}`;
125
+ if (input.query) return `Query: ${String(input.query).slice(0, 200)}`;
126
+ if (input.command) return `Command: ${String(input.command).slice(0, 200)}`;
127
+ if (input.prompt) return `Prompt: ${String(input.prompt).slice(0, 200)}`;
128
+ const serialized = JSON.stringify(input);
129
+ return serialized ? serialized.slice(0, 200) : undefined;
130
+ }
131
+
39
132
  // ========== Fast Heuristic ==========
40
133
 
41
134
  function hasSubagentPending(pendingNames: Set<string>, lastToolName: string | undefined, hasPendingTools: boolean): boolean {
@@ -13,6 +13,7 @@
13
13
  * 3. Haiku tiebreaker: optional AI assessment before killing ambiguous cases
14
14
  */
15
15
 
16
+ import type { EngineEvent } from '../../engines/EngineEvent.js';
16
17
  import { hlog } from './headless-logger.js';
17
18
  import type {
18
19
  ExecutionCheckpoint,
@@ -349,6 +350,26 @@ export class ToolWatchdog {
349
350
  }, extensionMs);
350
351
  }
351
352
 
353
+ /**
354
+ * Drive the watchdog from an engine-agnostic `EngineEvent` stream.
355
+ * Routes `tool.start` to `startWatch`, and `tool.end` to `clearWatch` +
356
+ * `recordCompletion` — so any CodingAgentEngine (Claude Code, OpenCode)
357
+ * can feed this watchdog without leaking engine-specific shapes.
358
+ * Non-tool events are ignored.
359
+ */
360
+ onEngineEvent(event: EngineEvent, onTimeout: (toolId: string) => void): void {
361
+ if (event.kind === 'tool.start') {
362
+ this.startWatch(event.toolCallId, event.toolName, event.input, () => onTimeout(event.toolCallId));
363
+ return;
364
+ }
365
+ if (event.kind === 'tool.end') {
366
+ this.clearWatch(event.toolCallId);
367
+ if (typeof event.durationMs === 'number' && event.durationMs >= 0) {
368
+ this.recordCompletion(event.toolName, event.durationMs);
369
+ }
370
+ }
371
+ }
372
+
352
373
  /** Stop watching a tool (it completed normally) */
353
374
  clearWatch(toolId: string): void {
354
375
  const watch = this.activeWatches.get(toolId);
@@ -129,6 +129,18 @@ export interface HeadlessConfig {
129
129
  disallowedTools?: string[];
130
130
  /** Enable deploy-mode patterns in the bouncer (stricter rules for end-user-driven sessions) */
131
131
  deployMode?: boolean;
132
+ /**
133
+ * Tab id used to route AskUserQuestion calls back to the right web client.
134
+ * When set together with `mstroPort` and `bouncerSecret`, the MCP bouncer
135
+ * pauses Claude on AskUserQuestion and waits for the user to answer in the
136
+ * web UI before resuming. When unset (e.g. CLI ad-hoc runs), the bouncer
137
+ * falls back to legacy behavior (allow with no answers).
138
+ */
139
+ tabId?: string;
140
+ /** CLI server port for the AskUserQuestion bridge. Pairs with `tabId`. */
141
+ mstroPort?: number;
142
+ /** Per-process bouncer secret for the AskUserQuestion bridge. Pairs with `tabId`. */
143
+ bouncerSecret?: string;
132
144
  }
133
145
 
134
146
  export interface SessionState {
@@ -215,7 +227,7 @@ export interface ExecutionResult {
215
227
  }
216
228
 
217
229
  /** Resolved config with all defaults applied */
218
- export type ResolvedHeadlessConfig = Omit<Required<HeadlessConfig>, 'outputCallback' | 'thinkingCallback' | 'toolUseCallback' | 'tokenUsageCallback' | 'continueSession' | 'claudeSessionId' | 'imageAttachments' | 'model' | 'effortLevel' | 'toolTimeoutProfiles' | 'onToolTimeout' | 'extraEnv' | 'disallowedTools' | 'deployMode'> & {
230
+ export type ResolvedHeadlessConfig = Omit<Required<HeadlessConfig>, 'outputCallback' | 'thinkingCallback' | 'toolUseCallback' | 'tokenUsageCallback' | 'continueSession' | 'claudeSessionId' | 'imageAttachments' | 'model' | 'effortLevel' | 'toolTimeoutProfiles' | 'onToolTimeout' | 'extraEnv' | 'disallowedTools' | 'deployMode' | 'tabId' | 'mstroPort' | 'bouncerSecret'> & {
219
231
  outputCallback?: (text: string) => void;
220
232
  thinkingCallback?: (text: string) => void;
221
233
  toolUseCallback?: (event: ToolUseEvent) => void;
@@ -230,6 +242,9 @@ export type ResolvedHeadlessConfig = Omit<Required<HeadlessConfig>, 'outputCallb
230
242
  extraEnv?: Record<string, string>;
231
243
  disallowedTools?: string[];
232
244
  deployMode?: boolean;
245
+ tabId?: string;
246
+ mstroPort?: number;
247
+ bouncerSecret?: string;
233
248
  };
234
249
 
235
250
 
@@ -39,7 +39,9 @@ export function loadHistory(historyPath: string, sessionId: string): SessionHist
39
39
  if (existsSync(historyPath)) {
40
40
  try {
41
41
  const data = readFileSync(historyPath, 'utf-8');
42
- return JSON.parse(data) as SessionHistory;
42
+ const parsed = JSON.parse(data) as SessionHistory;
43
+ if (!parsed.engine) parsed.engine = 'claude-code';
44
+ return parsed;
43
45
  } catch (error) {
44
46
  herror('Failed to load history:', error);
45
47
  }
@@ -51,6 +53,7 @@ export function loadHistory(historyPath: string, sessionId: string): SessionHist
51
53
  lastActivityAt: now,
52
54
  totalTokens: 0,
53
55
  movements: [],
56
+ engine: 'claude-code',
54
57
  };
55
58
  }
56
59
 
@@ -4,12 +4,29 @@
4
4
  * Small FIFO output buffer with a fixed-interval flush timer, used by the
5
5
  * improvisation session manager to coalesce rapid stdout writes into
6
6
  * steady `onOutput` emissions.
7
+ *
8
+ * ## Why coalesce inside `flush()`
9
+ *
10
+ * Claude's stdout arrives as many small chunks during streaming. Each chunk
11
+ * lands here via `queue_`. When `flush()` ran one `onEmit` per queued chunk,
12
+ * a streaming-heavy run produced thousands of `onOutput` events per minute,
13
+ * each becoming a tab-scoped broadcast that consumes a slot in the per-tab
14
+ * replay buffer (`tab-event-buffer.ts`). For 14-min runs with ~120 tool
15
+ * calls, that easily exceeded the buffer's 1000-event cap and triggered
16
+ * silent replay gaps on web reconnect.
17
+ *
18
+ * The flush window (50ms) is below the human-perceptible paint threshold and
19
+ * below WebSocket roundtrip latency, so concatenating all queued text into a
20
+ * single `onEmit` per tick is invisible to the user but cuts buffer pressure
21
+ * by 3-10× during streaming. No call site downstream depends on chunk
22
+ * boundaries — `onOutput` consumers (terminal renderer, history persistence)
23
+ * already treat the text as an opaque append.
7
24
  */
8
25
 
9
26
  const FLUSH_INTERVAL_MS = 50;
10
27
 
11
28
  export class OutputQueue {
12
- private queue: Array<{ text: string; timestamp: number }> = [];
29
+ private queue: string[] = [];
13
30
  private timer: NodeJS.Timeout | null = null;
14
31
 
15
32
  constructor(private readonly onEmit: (text: string) => void) {}
@@ -20,15 +37,20 @@ export class OutputQueue {
20
37
  }
21
38
 
22
39
  queue_(text: string): void {
23
- this.queue.push({ text, timestamp: Date.now() });
40
+ if (text.length === 0) return;
41
+ this.queue.push(text);
24
42
  }
25
43
 
26
- /** Drain all buffered entries, emitting each via `onEmit` in order. */
44
+ /**
45
+ * Drain all buffered entries, emitting them as a single concatenated
46
+ * string via `onEmit`. Order is preserved (FIFO). No-op when the queue is
47
+ * empty so the periodic timer doesn't fire spurious empty-string emits.
48
+ */
27
49
  flush(): void {
28
- while (this.queue.length > 0) {
29
- const item = this.queue.shift();
30
- if (item) this.onEmit(item.text);
31
- }
50
+ if (this.queue.length === 0) return;
51
+ const merged = this.queue.join('');
52
+ this.queue.length = 0;
53
+ this.onEmit(merged);
32
54
  }
33
55
 
34
56
  /** Stop the flush timer. Does NOT drain; call `flush()` first if needed. */