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
@@ -7,7 +7,42 @@ import { runQualityScan } from './quality-service.js';
7
7
  import type { SessionRegistry } from './session-registry.js';
8
8
  import { resolveSkillPrompt } from './skill-handlers.js';
9
9
  import { broadcastTabEvent } from './tab-broadcast.js';
10
- import type { WebSocketMessage, WSContext } from './types.js';
10
+ import { type EngineId, normalizeEngineId, type WebSocketMessage, type WSContext } from './types.js';
11
+
12
+ /**
13
+ * Apply per-prompt engine/model/effortLevel overrides from the `execute`
14
+ * WebSocket payload onto the session. `session.options` is mutated in place
15
+ * so the next `executePrompt` call (which reads options.model / effortLevel
16
+ * inside the retry loop) picks up the new values without needing a new
17
+ * session. Pass-through for undefined fields — global defaults already live
18
+ * on the session from initialization.
19
+ */
20
+ function applyExecuteOverrides(
21
+ session: ImprovisationSessionManager,
22
+ data: { engine?: unknown; model?: unknown; effortLevel?: unknown },
23
+ ): void {
24
+ const options = (session as unknown as { options: { model?: string; effortLevel?: string } }).options;
25
+ if (typeof data.model === 'string' && data.model.length > 0) {
26
+ options.model = data.model;
27
+ }
28
+ if (typeof data.effortLevel === 'string' && data.effortLevel.length > 0) {
29
+ options.effortLevel = data.effortLevel;
30
+ }
31
+ if (data.engine === 'claude-code' || data.engine === 'opencode') {
32
+ // Record the engine on the session history so `resolveEngineForSession`
33
+ // returns the user-chosen value on subsequent movementStart /
34
+ // movementComplete broadcasts. The actual engine factory swap happens
35
+ // at session-start (later-epic work); per-prompt engine flips flow
36
+ // through here so model/effort stay in sync.
37
+ const history = (session as unknown as { history: { engine: string } }).history;
38
+ history.engine = data.engine;
39
+ }
40
+ }
41
+
42
+ /** Resolve the engine for a session, defaulting to 'claude-code' for pre-engine sessions. */
43
+ export function resolveEngineForSession(session: ImprovisationSessionManager | undefined): EngineId {
44
+ return normalizeEngineId(session?.engine);
45
+ }
11
46
 
12
47
  // Re-export from extracted modules for backward compatibility
13
48
  export { handleHistoryMessage } from './session-history.js';
@@ -213,6 +248,10 @@ export function buildOutputHistory(session: ImprovisationSessionManager): Array<
213
248
  */
214
249
  export function setupSessionListeners(ctx: HandlerContext, session: ImprovisationSessionManager, _ws: WSContext, tabId: string): void {
215
250
  session.removeAllListeners();
251
+ // Bind tabId before listeners — the headless runner reads it at executePrompt
252
+ // time to wire AskUserQuestion routing back to this tab's web clients.
253
+ session.setTabId(tabId);
254
+ const engine = resolveEngineForSession(session);
216
255
 
217
256
  session.on('onHistoryPersisted', () => {
218
257
  const registry = ctx.getRegistry('');
@@ -227,19 +266,19 @@ export function setupSessionListeners(ctx: HandlerContext, session: Improvisatio
227
266
  broadcastTabEvent(ctx, tabId, 'thinking', { text });
228
267
  });
229
268
 
230
- session.on('onMovementStart', (sequenceNumber: number, prompt: string, isAutoContinue?: boolean) => {
231
- broadcastTabEvent(ctx, tabId, 'movementStart', { sequenceNumber, prompt, timestamp: Date.now(), executionStartTimestamp: session.executionStartTimestamp, isAutoContinue });
232
- ctx.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: true, executionStartTimestamp: session.executionStartTimestamp } });
269
+ session.on('onMovementStart', (sequenceNumber: number, prompt: string, isAutoContinue?: boolean, etaProfile?: import('../../cli/eta-estimator.js').EtaProfile | null) => {
270
+ broadcastTabEvent(ctx, tabId, 'movementStart', { sequenceNumber, prompt, timestamp: Date.now(), executionStartTimestamp: session.executionStartTimestamp, isAutoContinue, etaProfile: etaProfile ?? undefined }, engine);
271
+ ctx.broadcastToAll({ type: 'tabStateChanged', engine, data: { tabId, isExecuting: true, executionStartTimestamp: session.executionStartTimestamp } });
233
272
  });
234
273
 
235
274
  session.on('onMovementComplete', (movement: Record<string, unknown>) => {
236
- broadcastTabEvent(ctx, tabId, 'movementComplete', movement);
275
+ broadcastTabEvent(ctx, tabId, 'movementComplete', movement, engine);
237
276
 
238
277
  const registry = ctx.getRegistry('');
239
278
  // Use a try/catch since getRegistry may not have been initialized with the right workingDir
240
279
  try { registry.markTabUnviewed(tabId); } catch { /* ignore */ }
241
280
 
242
- ctx.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: false, hasUnviewedCompletion: true } });
281
+ ctx.broadcastToAll({ type: 'tabStateChanged', engine, data: { tabId, isExecuting: false, hasUnviewedCompletion: true } });
243
282
 
244
283
  if (ctx.usageReporter && movement.tokensUsed) {
245
284
  ctx.usageReporter({
@@ -257,12 +296,12 @@ export function setupSessionListeners(ctx: HandlerContext, session: Improvisatio
257
296
  });
258
297
 
259
298
  session.on('onMovementError', (error: Error) => {
260
- broadcastTabEvent(ctx, tabId, 'movementError', { message: error.message });
261
- ctx.broadcastToAll({ type: 'tabStateChanged', data: { tabId, isExecuting: false } });
299
+ broadcastTabEvent(ctx, tabId, 'movementError', { message: error.message }, engine);
300
+ ctx.broadcastToAll({ type: 'tabStateChanged', engine, data: { tabId, isExecuting: false } });
262
301
  });
263
302
 
264
303
  session.on('onSessionUpdate', (history: Record<string, unknown>) => {
265
- broadcastTabEvent(ctx, tabId, 'sessionUpdate', history);
304
+ broadcastTabEvent(ctx, tabId, 'sessionUpdate', history, engine);
266
305
  });
267
306
 
268
307
  session.on('onPlanNeedsConfirmation', (plan: Record<string, unknown>) => {
@@ -336,6 +375,13 @@ function handleExecuteMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocket
336
375
  console.log(`[session] execute accepted msgId=${msgId} tabId=${tabId} sessionId=${sessionId}`);
337
376
  }
338
377
 
378
+ // Apply per-prompt engine/model/effort overrides from the payload. The
379
+ // web client populates these from the tab's effective EnginePicker state
380
+ // (override > global). Missing fields fall through to whatever the session
381
+ // already has — typically the machine-level defaults from `settings.json`
382
+ // applied at session init.
383
+ applyExecuteOverrides(session, msg.data);
384
+
339
385
  const worktreeDir = ctx.gitDirectories.get(tabId);
340
386
  const attachments = mergePreUploadedAttachments(ctx, tabId, msg.data.attachments);
341
387
 
@@ -346,6 +392,17 @@ function handleExecuteMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocket
346
392
  const effectiveDir = worktreeDir || session.getSessionInfo().workingDir;
347
393
  const resolved = resolveSkillPrompt(rawPrompt, effectiveDir);
348
394
 
395
+ // Authoritative prompt-input clear for all connected devices. The submitter
396
+ // already cleared locally; this guarantees other devices clear even if the
397
+ // submitter's debounced syncPromptText never fires (e.g. mobile tab
398
+ // suspended after Send). Clients suppress this via locallyEditingTabs if
399
+ // the user is actively typing a new prompt.
400
+ ctx.broadcastToAll({
401
+ type: 'promptTextSync',
402
+ tabId,
403
+ data: { tabId, text: '' },
404
+ });
405
+
349
406
  session.executePrompt(
350
407
  resolved ? resolved.prompt : rawPrompt,
351
408
  attachments,
@@ -372,7 +429,7 @@ function handleNewSessionMessage(ctx: HandlerContext, ws: WSContext, tabId: stri
372
429
  if (tabMap) tabMap.set(tabId, newSessionId);
373
430
  const registry = ctx.getRegistry('');
374
431
  try { registry.updateTabSession(tabId, newSessionId); } catch { /* ignore */ }
375
- ctx.send(ws, { type: 'newSession', tabId, data: newSession.getSessionInfo() });
432
+ ctx.send(ws, { type: 'newSession', tabId, engine: resolveEngineForSession(newSession), data: newSession.getSessionInfo() });
376
433
  }
377
434
 
378
435
  export function handleSessionMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, permission?: 'view'): void {
@@ -91,6 +91,7 @@ function getSessionById(workingDir: string, sessionId: string): Record<string, u
91
91
  startedAt: historyData.startedAt,
92
92
  lastActivityAt: historyData.lastActivityAt,
93
93
  totalTokens: historyData.totalTokens,
94
+ engine: historyData.engine || 'claude-code',
94
95
  movementCount: historyData.movements?.length || 0,
95
96
  title: firstPrompt.slice(0, 80) + (firstPrompt.length > 80 ? '...' : ''),
96
97
  movements: historyData.movements || [],
@@ -171,11 +172,13 @@ function buildSessionSummary(historyData: Record<string, unknown>): Record<strin
171
172
  const movementPreviews = (movements || []).slice(0, 3).map((m: Record<string, unknown>) => ({
172
173
  userPrompt: (typeof m.userPrompt === 'string' ? m.userPrompt : '').slice(0, 100) || ''
173
174
  }));
175
+ const engine = typeof historyData.engine === 'string' && historyData.engine ? historyData.engine : 'claude-code';
174
176
  return {
175
177
  sessionId: historyData.sessionId,
176
178
  startedAt: historyData.startedAt,
177
179
  lastActivityAt: historyData.lastActivityAt,
178
180
  totalTokens: historyData.totalTokens,
181
+ engine,
179
182
  movementCount: movements?.length || 0,
180
183
  title: firstPrompt.slice(0, 80) + (firstPrompt.length > 80 ? '...' : ''),
181
184
  movements: movementPreviews
@@ -1,11 +1,12 @@
1
1
  // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
2
 
3
+ import type { EtaProfile } from '../../cli/eta-estimator.js';
3
4
  import { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
4
5
  import { getEffortLevel, getModel } from '../settings.js';
5
6
  import type { HandlerContext } from './handler-context.js';
6
- import { buildOutputHistory, setupSessionListeners } from './session-handlers.js';
7
- import type { SessionRegistry } from './session-registry.js';
8
- import { replayTabEventsSince } from './tab-event-replay.js';
7
+ import { buildOutputHistory, resolveEngineForSession, setupSessionListeners } from './session-handlers.js';
8
+ import type { SessionRegistry, TabEngineOverride } from './session-registry.js';
9
+ import { type ReplayResult, replayTabEventsSince } from './tab-event-replay.js';
9
10
  import type { WSContext } from './types.js';
10
11
 
11
12
  /**
@@ -22,6 +23,138 @@ function extractLastSeenSeq(data: unknown): number | undefined {
22
23
  return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : undefined;
23
24
  }
24
25
 
26
+ /**
27
+ * When the session is mid-execution, expose the cached eta profile so the
28
+ * web's ComposingIndicator can render an ETA immediately on reconnect
29
+ * instead of waiting for the next movementStart (which won't fire until
30
+ * the user submits a fresh prompt).
31
+ */
32
+ function inflightEtaPayload(session: ImprovisationSessionManager): { etaProfile?: EtaProfile } {
33
+ if (session.isExecuting && session.etaProfile) return { etaProfile: session.etaProfile };
34
+ return {};
35
+ }
36
+
37
+ /**
38
+ * Build the full-snapshot data payload for a `tabInitialized` message.
39
+ *
40
+ * Used in three situations:
41
+ * 1. Cold init (no `lastSeenSeq`) — web has no prior state to merge with.
42
+ * 2. Cold reattach (existing session, no prior seq) — same shape.
43
+ * 3. Replay-gap recovery — `replayTabEventsSince` returned `hadGap`, so
44
+ * the web's incremental state is provably stale; we replace it.
45
+ *
46
+ * `replayGap` flags the recovery case so the web can branch: drop any
47
+ * already-rendered tab output and rebuild from `outputHistory` +
48
+ * `executionEvents` instead of merging on top of stale incremental state.
49
+ * Old web clients that don't know the flag still get the full snapshot and
50
+ * render correctly — `replayGap` is purely additive telemetry.
51
+ */
52
+ function buildFullSnapshotData(
53
+ session: ImprovisationSessionManager,
54
+ options: {
55
+ worktreePath?: string;
56
+ worktreeBranch?: string;
57
+ engineOverride?: TabEngineOverride;
58
+ replayGap?: boolean;
59
+ extra?: Record<string, unknown>;
60
+ } = {},
61
+ ): Record<string, unknown> {
62
+ const isExecuting = session.isExecuting;
63
+ return {
64
+ ...session.getSessionInfo(),
65
+ engine: resolveEngineForSession(session),
66
+ outputHistory: buildOutputHistory(session),
67
+ isExecuting,
68
+ ...(isExecuting ? { executionEvents: session.getExecutionEventLog() } : {}),
69
+ ...(isExecuting && session.executionStartTimestamp
70
+ ? { executionStartTimestamp: session.executionStartTimestamp }
71
+ : {}),
72
+ ...inflightEtaPayload(session),
73
+ ...(options.worktreePath
74
+ ? { worktreePath: options.worktreePath, worktreeBranch: options.worktreeBranch }
75
+ : {}),
76
+ ...(options.engineOverride ? { engineOverride: options.engineOverride } : {}),
77
+ ...(options.replayGap ? { replayGap: true } : {}),
78
+ ...(options.extra ?? {}),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Snapshot vs incremental decision based on the replay outcome.
84
+ *
85
+ * - `incremental`: the web should keep its current state and append the
86
+ * replayed events that already arrived (handled by `replayTabEventsSince`
87
+ * itself when there's no gap). We send `tabInitialized` with
88
+ * `resumedFromSeq: true`.
89
+ * - `snapshot`: the web should discard tab output and rebuild from a full
90
+ * snapshot. Triggered either by `lastSeenSeq === undefined` (cold start)
91
+ * or by `result.hadGap` (replay would silently skip events).
92
+ */
93
+ function decideRecoveryMode(
94
+ result: ReplayResult,
95
+ lastSeenSeq: number | undefined,
96
+ ): 'incremental' | 'snapshot' {
97
+ if (lastSeenSeq === undefined) return 'snapshot';
98
+ if (result.hadGap) return 'snapshot';
99
+ return 'incremental';
100
+ }
101
+
102
+ /**
103
+ * Send `tabInitialized` for a resume path (`tryResumeFromDisk` /
104
+ * `resumeHistoricalSession`). Picks the snapshot or incremental envelope
105
+ * shape based on `mode` and threads any extra fields the caller needs to
106
+ * carry (e.g. `resumeFailed`, worktree state, engine override).
107
+ *
108
+ * Extracted to keep the resume call sites flat — without this helper, each
109
+ * caller pushes the function over the project's cognitive-complexity gate.
110
+ */
111
+ function sendResumedTabInitialized(
112
+ ctx: HandlerContext,
113
+ ws: WSContext,
114
+ tabId: string,
115
+ session: ImprovisationSessionManager,
116
+ mode: 'incremental' | 'snapshot',
117
+ replay: ReplayResult,
118
+ options: {
119
+ worktreePath?: string;
120
+ worktreeBranch?: string;
121
+ engineOverride?: TabEngineOverride;
122
+ extra?: Record<string, unknown>;
123
+ } = {},
124
+ ): void {
125
+ const engine = resolveEngineForSession(session);
126
+ if (mode === 'snapshot') {
127
+ ctx.send(ws, {
128
+ type: 'tabInitialized',
129
+ tabId,
130
+ engine,
131
+ data: buildFullSnapshotData(session, {
132
+ worktreePath: options.worktreePath,
133
+ worktreeBranch: options.worktreeBranch,
134
+ engineOverride: options.engineOverride,
135
+ replayGap: replay.hadGap,
136
+ extra: options.extra,
137
+ }),
138
+ });
139
+ return;
140
+ }
141
+ ctx.send(ws, {
142
+ type: 'tabInitialized',
143
+ tabId,
144
+ engine,
145
+ data: {
146
+ ...session.getSessionInfo(),
147
+ engine,
148
+ resumedFromSeq: true,
149
+ ...(options.worktreePath
150
+ ? { worktreePath: options.worktreePath, worktreeBranch: options.worktreeBranch }
151
+ : {}),
152
+ ...(options.engineOverride ? { engineOverride: options.engineOverride } : {}),
153
+ ...(options.extra ?? {}),
154
+ },
155
+ });
156
+ }
157
+
25
158
  function tryResumeFromDisk(
26
159
  ctx: HandlerContext,
27
160
  ws: WSContext,
@@ -54,16 +187,17 @@ function tryResumeFromDisk(
54
187
  // BEFORE tabInitialized so they arrive in the right order. Web-side
55
188
  // handlers append; `tabInitialized` does NOT reset when `resumedFromSeq`
56
189
  // is set, preserving the replayed additions.
57
- replayTabEventsSince(ctx, ws, tabId, lastSeenSeq);
58
-
59
- ctx.send(ws, {
60
- type: 'tabInitialized',
61
- tabId,
62
- data: {
63
- ...diskSession.getSessionInfo(),
64
- ...(lastSeenSeq === undefined ? { outputHistory: buildOutputHistory(diskSession) } : { resumedFromSeq: true }),
65
- ...(worktreePath ? { worktreePath, worktreeBranch } : {}),
66
- }
190
+ //
191
+ // If `replayTabEventsSince` reports `hadGap`, no events were emitted and
192
+ // we fall back to a full-snapshot `tabInitialized` so the web replaces
193
+ // its (now-known-stale) incremental state instead of merging on top.
194
+ const replay = replayTabEventsSince(ctx, ws, tabId, lastSeenSeq);
195
+ const mode = decideRecoveryMode(replay, lastSeenSeq);
196
+
197
+ sendResumedTabInitialized(ctx, ws, tabId, diskSession, mode, replay, {
198
+ worktreePath,
199
+ worktreeBranch,
200
+ engineOverride: regTab?.engineOverride,
67
201
  });
68
202
  return true;
69
203
  } catch {
@@ -121,21 +255,32 @@ export async function initializeTab(ctx: HandlerContext, ws: WSContext, tabId: s
121
255
 
122
256
  registry.registerTab(tabId, sessionId, tabName || existingTab?.tabName);
123
257
  const registeredTab = registry.getTab(tabId);
124
- ctx.broadcastToAll({
258
+ const engine = resolveEngineForSession(session);
259
+ // Mirror terminal-handlers.ts: broadcastToOthers, not broadcastToAll. The
260
+ // requesting client already drove this initTab and will receive
261
+ // `tabInitialized` below — echoing `tabCreated` back risks racing the
262
+ // discovery handler during a flicker and producing a phantom tab.
263
+ ctx.broadcastToOthers(ws, {
125
264
  type: 'tabCreated',
126
- data: { tabId, tabName: registeredTab?.tabName || 'Chat', createdAt: registeredTab?.createdAt, order: registeredTab?.order, sessionInfo: session.getSessionInfo() }
265
+ engine,
266
+ data: { tabId, tabName: registeredTab?.tabName || 'Chat', createdAt: registeredTab?.createdAt, order: registeredTab?.order, engine, sessionInfo: session.getSessionInfo() }
127
267
  });
128
268
 
129
269
  // Fresh session (no disk/memory predecessor) has nothing to replay,
130
270
  // but we still pass lastSeenSeq through so the web flag is consistent.
131
- replayTabEventsSince(ctx, ws, tabId, lastSeenSeq);
271
+ // hadGap is impossible here (buffer is empty for a brand-new tab), but
272
+ // route through `decideRecoveryMode` for uniformity with the resume paths.
273
+ const replay = replayTabEventsSince(ctx, ws, tabId, lastSeenSeq);
274
+ const mode = decideRecoveryMode(replay, lastSeenSeq);
132
275
 
133
276
  ctx.send(ws, {
134
277
  type: 'tabInitialized',
135
278
  tabId,
279
+ engine,
136
280
  data: {
137
281
  ...session.getSessionInfo(),
138
- ...(lastSeenSeq !== undefined ? { resumedFromSeq: true } : {}),
282
+ ...(mode === 'incremental' ? { resumedFromSeq: true } : {}),
283
+ ...(replay.hadGap ? { replayGap: true } : {}),
139
284
  }
140
285
  });
141
286
  }
@@ -192,17 +337,14 @@ export async function resumeHistoricalSession(
192
337
 
193
338
  registry.registerTab(tabId, sessionId);
194
339
 
195
- replayTabEventsSince(ctx, ws, tabId, lastSeenSeq);
340
+ const replay = replayTabEventsSince(ctx, ws, tabId, lastSeenSeq);
341
+ const mode = decideRecoveryMode(replay, lastSeenSeq);
196
342
 
197
- ctx.send(ws, {
198
- type: 'tabInitialized',
199
- tabId,
200
- data: {
201
- ...session.getSessionInfo(),
202
- ...(lastSeenSeq === undefined ? { outputHistory: buildOutputHistory(session) } : { resumedFromSeq: true }),
343
+ sendResumedTabInitialized(ctx, ws, tabId, session, mode, replay, {
344
+ extra: {
203
345
  resumeFailed: isNewSession,
204
- originalSessionId: isNewSession ? historicalSessionId : undefined
205
- }
346
+ originalSessionId: isNewSession ? historicalSessionId : undefined,
347
+ },
206
348
  });
207
349
  }
208
350
 
@@ -231,11 +373,14 @@ function reattachSession(
231
373
  const worktreePath = ctx.gitDirectories.get(tabId);
232
374
  const worktreeBranch = ctx.gitBranches.get(tabId);
233
375
 
234
- // Fast path: the web already has local state (via Zustand), so just replay
235
- // anything newer than `lastSeenSeq` and tell the client to skip the
236
- // destructive reset in its tabInitialized handler.
237
- if (lastSeenSeq !== undefined) {
238
- replayTabEventsSince(ctx, ws, tabId, lastSeenSeq);
376
+ const inflightEta = inflightEtaPayload(session);
377
+ const replay = replayTabEventsSince(ctx, ws, tabId, lastSeenSeq);
378
+ const mode = decideRecoveryMode(replay, lastSeenSeq);
379
+
380
+ // Fast path: the web already has local state (via Zustand) AND the replay
381
+ // covered the gap cleanly — just replay the buffered events and tell the
382
+ // client to skip the destructive reset in its tabInitialized handler.
383
+ if (mode === 'incremental') {
239
384
  ctx.send(ws, {
240
385
  type: 'tabInitialized',
241
386
  tabId,
@@ -244,29 +389,27 @@ function reattachSession(
244
389
  resumedFromSeq: true,
245
390
  isExecuting: session.isExecuting,
246
391
  ...(session.isExecuting && session.executionStartTimestamp ? { executionStartTimestamp: session.executionStartTimestamp } : {}),
392
+ ...inflightEta,
247
393
  ...(worktreePath ? { worktreePath, worktreeBranch } : {}),
248
394
  }
249
395
  });
250
396
  return;
251
397
  }
252
398
 
253
- // Cold-start reattach (no prior seq): send the full snapshot so the web
254
- // can rebuild from scratch.
255
- const outputHistory = buildOutputHistory(session);
256
- const executionEvents = session.isExecuting
257
- ? session.getExecutionEventLog()
258
- : undefined;
259
-
399
+ // Snapshot path: either cold-start reattach (no prior seq) or replay-gap
400
+ // recovery (`hadGap=true`, no events sent). Both want a full snapshot so
401
+ // the web rebuilds from `outputHistory` + `executionEvents`. The
402
+ // `replayGap` flag distinguishes the two for telemetry — the wire
403
+ // payload shape is otherwise identical.
260
404
  ctx.send(ws, {
261
405
  type: 'tabInitialized',
262
406
  tabId,
263
- data: {
264
- ...session.getSessionInfo(),
265
- outputHistory,
266
- isExecuting: session.isExecuting,
267
- executionEvents,
268
- ...(session.isExecuting && session.executionStartTimestamp ? { executionStartTimestamp: session.executionStartTimestamp } : {}),
269
- ...(worktreePath ? { worktreePath, worktreeBranch } : {}),
270
- }
407
+ engine: resolveEngineForSession(session),
408
+ data: buildFullSnapshotData(session, {
409
+ worktreePath,
410
+ worktreeBranch,
411
+ engineOverride: regTab?.engineOverride,
412
+ replayGap: replay.hadGap,
413
+ }),
271
414
  });
272
415
  }
@@ -12,6 +12,19 @@
12
12
 
13
13
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
14
14
  import { join } from 'node:path'
15
+ import type { EngineId } from './types.js'
16
+
17
+ /**
18
+ * Per-tab engine override persisted alongside the tab record. Survives
19
+ * WebSocket disconnects + process restarts — the web client re-reads it on
20
+ * `tabInitialized` / `activeTabs` to restore the picker state after a
21
+ * reconnect.
22
+ */
23
+ export interface TabEngineOverride {
24
+ engine: EngineId
25
+ model: string
26
+ effortLevel: string
27
+ }
15
28
 
16
29
  export interface RegisteredTab {
17
30
  sessionId: string
@@ -29,6 +42,12 @@ export interface RegisteredTab {
29
42
  hasPersistedHistory?: boolean
30
43
  worktreePath?: string
31
44
  worktreeBranch?: string
45
+ /**
46
+ * Per-tab engine override. Absent when the tab uses the global defaults —
47
+ * authored from the web via `setTabEngine`, cleared by passing `null` to
48
+ * {@link SessionRegistry.updateTabEngineOverride}.
49
+ */
50
+ engineOverride?: TabEngineOverride
32
51
  }
33
52
 
34
53
  interface RegistryData {
@@ -241,6 +260,24 @@ export class SessionRegistry {
241
260
  }
242
261
  }
243
262
 
263
+ /**
264
+ * Update or clear the per-tab engine override. Pass `null` to remove the
265
+ * override and route future prompts through the global defaults. Writes
266
+ * through to disk so the override survives WS disconnects — that's the
267
+ * core persistence guarantee of IS-019.
268
+ */
269
+ updateTabEngineOverride(tabId: string, override: TabEngineOverride | null): void {
270
+ const tab = this.data.tabs[tabId]
271
+ if (!tab) return
272
+ if (override === null) {
273
+ delete tab.engineOverride
274
+ } else {
275
+ tab.engineOverride = { ...override }
276
+ }
277
+ tab.lastActivityAt = new Date().toISOString()
278
+ this.save()
279
+ }
280
+
244
281
  /**
245
282
  * Reorder tabs. Accepts an ordered array of tabIds and reassigns order values.
246
283
  */
@@ -3,22 +3,59 @@
3
3
  import { spawn } from 'node:child_process';
4
4
  import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
- import { getSettings, setEffortLevel, setModel } from '../settings.js';
6
+ import {
7
+ getSettings,
8
+ isEngineSwapEnabled,
9
+ setBouncerClassifier,
10
+ setEffortLevel,
11
+ setModel,
12
+ } from '../settings.js';
7
13
  import type { HandlerContext } from './handler-context.js';
8
14
  import type { WebSocketMessage, WSContext } from './types.js';
9
15
 
16
+ /**
17
+ * Return the stored settings with the resolved `engineSwap` boolean patched
18
+ * in, so web clients always see the effective flag value (env-var override,
19
+ * NODE_ENV default, etc.) rather than the raw — possibly `undefined` —
20
+ * stored field.
21
+ */
22
+ function getSettingsWithResolvedFlags() {
23
+ return { ...getSettings(), engineSwap: isEngineSwapEnabled() };
24
+ }
25
+
10
26
  export function handleGetSettings(ctx: HandlerContext, ws: WSContext): void {
11
- ctx.send(ws, { type: 'settings', data: getSettings() });
27
+ ctx.send(ws, { type: 'settings', data: getSettingsWithResolvedFlags() });
12
28
  }
13
29
 
14
- export function handleUpdateSettings(ctx: HandlerContext, _ws: WSContext, msg: WebSocketMessage): void {
30
+ export function handleUpdateSettings(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage): void {
15
31
  if (msg.data?.model !== undefined) {
16
32
  setModel(msg.data.model);
17
33
  }
18
34
  if (msg.data?.effortLevel !== undefined) {
19
35
  setEffortLevel(msg.data.effortLevel);
20
36
  }
21
- ctx.broadcastToAll({ type: 'settingsUpdated', data: getSettings() });
37
+ if (msg.data?.bouncerClassifier !== undefined) {
38
+ try {
39
+ setBouncerClassifier(msg.data.bouncerClassifier);
40
+ } catch (err) {
41
+ // Reject crafted payloads (non-eligible model, bad engine) — surface
42
+ // the reason to the requester and skip the broadcast so other clients
43
+ // keep showing the previous valid config.
44
+ const message = err instanceof Error ? err.message : String(err);
45
+ ctx.send(ws, {
46
+ type: 'error',
47
+ data: {
48
+ scope: 'bouncerClassifier',
49
+ message,
50
+ },
51
+ });
52
+ // Still echo the current settings back to the requester so the UI can
53
+ // revert its optimistic update.
54
+ ctx.send(ws, { type: 'settings', data: getSettingsWithResolvedFlags() });
55
+ return;
56
+ }
57
+ }
58
+ ctx.broadcastToAll({ type: 'settingsUpdated', data: getSettingsWithResolvedFlags() });
22
59
  }
23
60
 
24
61
  export async function generateNotificationSummary(
@@ -16,22 +16,30 @@
16
16
  */
17
17
 
18
18
  import type { HandlerContext } from './handler-context.js'
19
- import type { WebSocketResponse } from './types.js'
19
+ import type { EngineId, WebSocketResponse } from './types.js'
20
20
 
21
21
  type TabScopedEventType = WebSocketResponse['type']
22
22
 
23
23
  /**
24
24
  * Record + broadcast a tab-scoped event in one call. Returns the assigned
25
25
  * sequence number purely for logging/tests — callers rarely need it.
26
+ *
27
+ * `engine` is optional: when supplied (typically by session-driven movement
28
+ * events), it rides on the wire envelope so the web client can render
29
+ * engine-specific affordances. Buffer replay preserves it because the engine
30
+ * is part of the envelope produced for each broadcast.
26
31
  */
27
32
  export function broadcastTabEvent(
28
33
  ctx: HandlerContext,
29
34
  tabId: string,
30
35
  type: TabScopedEventType,
31
36
  data: unknown,
37
+ engine?: EngineId,
32
38
  ): number {
33
39
  const buffer = ctx.tabEventBuffers.getOrCreate(tabId)
34
40
  const seq = buffer.record(type, data)
35
- ctx.broadcastToAll({ type, tabId, data, seq })
41
+ const envelope: WebSocketResponse = { type, tabId, data, seq }
42
+ if (engine) envelope.engine = engine
43
+ ctx.broadcastToAll(envelope)
36
44
  return seq
37
45
  }