mstro-app 0.5.1 → 0.5.5

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 (240) 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/stall-assessor.d.ts +50 -0
  10. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  11. package/dist/server/cli/headless/stall-assessor.js +64 -9
  12. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  13. package/dist/server/cli/headless/tool-watchdog.d.ts +21 -0
  14. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  15. package/dist/server/cli/headless/tool-watchdog.js +19 -12
  16. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  17. package/dist/server/cli/improvisation-history-store.d.ts.map +1 -1
  18. package/dist/server/cli/improvisation-history-store.js +5 -1
  19. package/dist/server/cli/improvisation-history-store.js.map +1 -1
  20. package/dist/server/cli/improvisation-output-queue.d.ts +5 -1
  21. package/dist/server/cli/improvisation-output-queue.d.ts.map +1 -1
  22. package/dist/server/cli/improvisation-output-queue.js +30 -7
  23. package/dist/server/cli/improvisation-output-queue.js.map +1 -1
  24. package/dist/server/cli/improvisation-session-manager.d.ts +29 -0
  25. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  26. package/dist/server/cli/improvisation-session-manager.js +50 -1
  27. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  28. package/dist/server/cli/improvisation-types.d.ts +2 -0
  29. package/dist/server/cli/improvisation-types.d.ts.map +1 -1
  30. package/dist/server/cli/improvisation-types.js.map +1 -1
  31. package/dist/server/engines/EngineEvent.d.ts +126 -0
  32. package/dist/server/engines/EngineEvent.d.ts.map +1 -0
  33. package/dist/server/engines/EngineEvent.js +11 -0
  34. package/dist/server/engines/EngineEvent.js.map +1 -0
  35. package/dist/server/engines/claude/ClaudeCodeEngine.d.ts +47 -0
  36. package/dist/server/engines/claude/ClaudeCodeEngine.d.ts.map +1 -0
  37. package/dist/server/engines/claude/ClaudeCodeEngine.js +338 -0
  38. package/dist/server/engines/claude/ClaudeCodeEngine.js.map +1 -0
  39. package/dist/server/engines/factory.d.ts +21 -0
  40. package/dist/server/engines/factory.d.ts.map +1 -0
  41. package/dist/server/engines/factory.js +152 -0
  42. package/dist/server/engines/factory.js.map +1 -0
  43. package/dist/server/engines/opencode/OpenCodeEngine.d.ts +148 -0
  44. package/dist/server/engines/opencode/OpenCodeEngine.d.ts.map +1 -0
  45. package/dist/server/engines/opencode/OpenCodeEngine.js +630 -0
  46. package/dist/server/engines/opencode/OpenCodeEngine.js.map +1 -0
  47. package/dist/server/engines/opencode/OpenCodeServerManager.d.ts +172 -0
  48. package/dist/server/engines/opencode/OpenCodeServerManager.d.ts.map +1 -0
  49. package/dist/server/engines/opencode/OpenCodeServerManager.js +390 -0
  50. package/dist/server/engines/opencode/OpenCodeServerManager.js.map +1 -0
  51. package/dist/server/engines/opencode/model-catalog.d.ts +94 -0
  52. package/dist/server/engines/opencode/model-catalog.d.ts.map +1 -0
  53. package/dist/server/engines/opencode/model-catalog.js +141 -0
  54. package/dist/server/engines/opencode/model-catalog.js.map +1 -0
  55. package/dist/server/engines/types.d.ts +146 -0
  56. package/dist/server/engines/types.d.ts.map +1 -0
  57. package/dist/server/engines/types.js +4 -0
  58. package/dist/server/engines/types.js.map +1 -0
  59. package/dist/server/index.js +1 -1
  60. package/dist/server/index.js.map +1 -1
  61. package/dist/server/mcp/bouncer-haiku.d.ts +17 -4
  62. package/dist/server/mcp/bouncer-haiku.d.ts.map +1 -1
  63. package/dist/server/mcp/bouncer-haiku.js +8 -124
  64. package/dist/server/mcp/bouncer-haiku.js.map +1 -1
  65. package/dist/server/mcp/bouncer-integration.d.ts +45 -0
  66. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  67. package/dist/server/mcp/bouncer-integration.js +69 -5
  68. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  69. package/dist/server/mcp/classifier/BouncerClassifier.d.ts +34 -0
  70. package/dist/server/mcp/classifier/BouncerClassifier.d.ts.map +1 -0
  71. package/dist/server/mcp/classifier/BouncerClassifier.js +4 -0
  72. package/dist/server/mcp/classifier/BouncerClassifier.js.map +1 -0
  73. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts +17 -0
  74. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts.map +1 -0
  75. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js +142 -0
  76. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js.map +1 -0
  77. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts +68 -0
  78. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts.map +1 -0
  79. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js +182 -0
  80. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js.map +1 -0
  81. package/dist/server/mcp/classifier/factory.d.ts +70 -0
  82. package/dist/server/mcp/classifier/factory.d.ts.map +1 -0
  83. package/dist/server/mcp/classifier/factory.js +155 -0
  84. package/dist/server/mcp/classifier/factory.js.map +1 -0
  85. package/dist/server/services/plan/agent-resolver.d.ts +26 -0
  86. package/dist/server/services/plan/agent-resolver.d.ts.map +1 -0
  87. package/dist/server/services/plan/agent-resolver.js +102 -0
  88. package/dist/server/services/plan/agent-resolver.js.map +1 -0
  89. package/dist/server/services/plan/composer.d.ts.map +1 -1
  90. package/dist/server/services/plan/composer.js +59 -11
  91. package/dist/server/services/plan/composer.js.map +1 -1
  92. package/dist/server/services/plan/executor.d.ts.map +1 -1
  93. package/dist/server/services/plan/executor.js +3 -1
  94. package/dist/server/services/plan/executor.js.map +1 -1
  95. package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
  96. package/dist/server/services/plan/issue-prompt-builder.js +33 -1
  97. package/dist/server/services/plan/issue-prompt-builder.js.map +1 -1
  98. package/dist/server/services/plan/parser-core.d.ts.map +1 -1
  99. package/dist/server/services/plan/parser-core.js +1 -0
  100. package/dist/server/services/plan/parser-core.js.map +1 -1
  101. package/dist/server/services/plan/types.d.ts +1 -0
  102. package/dist/server/services/plan/types.d.ts.map +1 -1
  103. package/dist/server/services/settings.d.ts +76 -2
  104. package/dist/server/services/settings.d.ts.map +1 -1
  105. package/dist/server/services/settings.js +127 -4
  106. package/dist/server/services/settings.js.map +1 -1
  107. package/dist/server/services/websocket/git-branch-handlers.d.ts.map +1 -1
  108. package/dist/server/services/websocket/git-branch-handlers.js +19 -6
  109. package/dist/server/services/websocket/git-branch-handlers.js.map +1 -1
  110. package/dist/server/services/websocket/handler.d.ts +17 -1
  111. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  112. package/dist/server/services/websocket/handler.js +54 -2
  113. package/dist/server/services/websocket/handler.js.map +1 -1
  114. package/dist/server/services/websocket/quality-complexity.d.ts.map +1 -1
  115. package/dist/server/services/websocket/quality-complexity.js +78 -26
  116. package/dist/server/services/websocket/quality-complexity.js.map +1 -1
  117. package/dist/server/services/websocket/quality-eta.d.ts +47 -0
  118. package/dist/server/services/websocket/quality-eta.d.ts.map +1 -0
  119. package/dist/server/services/websocket/quality-eta.js +110 -0
  120. package/dist/server/services/websocket/quality-eta.js.map +1 -0
  121. package/dist/server/services/websocket/quality-grading.d.ts +27 -4
  122. package/dist/server/services/websocket/quality-grading.d.ts.map +1 -1
  123. package/dist/server/services/websocket/quality-grading.js +369 -201
  124. package/dist/server/services/websocket/quality-grading.js.map +1 -1
  125. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
  126. package/dist/server/services/websocket/quality-handlers.js +145 -7
  127. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  128. package/dist/server/services/websocket/quality-operations.d.ts +34 -0
  129. package/dist/server/services/websocket/quality-operations.d.ts.map +1 -0
  130. package/dist/server/services/websocket/quality-operations.js +47 -0
  131. package/dist/server/services/websocket/quality-operations.js.map +1 -0
  132. package/dist/server/services/websocket/quality-persistence.d.ts +9 -0
  133. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
  134. package/dist/server/services/websocket/quality-persistence.js +10 -0
  135. package/dist/server/services/websocket/quality-persistence.js.map +1 -1
  136. package/dist/server/services/websocket/quality-review-agent.d.ts +1 -1
  137. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  138. package/dist/server/services/websocket/quality-review-agent.js +105 -56
  139. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  140. package/dist/server/services/websocket/quality-service.d.ts +9 -1
  141. package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
  142. package/dist/server/services/websocket/quality-service.js +334 -14
  143. package/dist/server/services/websocket/quality-service.js.map +1 -1
  144. package/dist/server/services/websocket/quality-tools.d.ts +21 -0
  145. package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
  146. package/dist/server/services/websocket/quality-tools.js +49 -0
  147. package/dist/server/services/websocket/quality-tools.js.map +1 -1
  148. package/dist/server/services/websocket/quality-types.d.ts +35 -2
  149. package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
  150. package/dist/server/services/websocket/quality-types.js +1 -1
  151. package/dist/server/services/websocket/quality-types.js.map +1 -1
  152. package/dist/server/services/websocket/session-handlers.d.ts +3 -1
  153. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  154. package/dist/server/services/websocket/session-handlers.js +57 -9
  155. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  156. package/dist/server/services/websocket/session-history.js +3 -0
  157. package/dist/server/services/websocket/session-history.js.map +1 -1
  158. package/dist/server/services/websocket/session-initialization.d.ts.map +1 -1
  159. package/dist/server/services/websocket/session-initialization.js +158 -42
  160. package/dist/server/services/websocket/session-initialization.js.map +1 -1
  161. package/dist/server/services/websocket/session-registry.d.ts +25 -0
  162. package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
  163. package/dist/server/services/websocket/session-registry.js +19 -0
  164. package/dist/server/services/websocket/session-registry.js.map +1 -1
  165. package/dist/server/services/websocket/settings-handlers.d.ts +1 -1
  166. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -1
  167. package/dist/server/services/websocket/settings-handlers.js +35 -4
  168. package/dist/server/services/websocket/settings-handlers.js.map +1 -1
  169. package/dist/server/services/websocket/tab-broadcast.d.ts +7 -2
  170. package/dist/server/services/websocket/tab-broadcast.d.ts.map +1 -1
  171. package/dist/server/services/websocket/tab-broadcast.js +10 -2
  172. package/dist/server/services/websocket/tab-broadcast.js.map +1 -1
  173. package/dist/server/services/websocket/tab-event-buffer.d.ts +97 -8
  174. package/dist/server/services/websocket/tab-event-buffer.d.ts.map +1 -1
  175. package/dist/server/services/websocket/tab-event-buffer.js +138 -12
  176. package/dist/server/services/websocket/tab-event-buffer.js.map +1 -1
  177. package/dist/server/services/websocket/tab-event-replay.d.ts +29 -13
  178. package/dist/server/services/websocket/tab-event-replay.d.ts.map +1 -1
  179. package/dist/server/services/websocket/tab-event-replay.js +55 -2
  180. package/dist/server/services/websocket/tab-event-replay.js.map +1 -1
  181. package/dist/server/services/websocket/tab-handlers.d.ts +9 -1
  182. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
  183. package/dist/server/services/websocket/tab-handlers.js +47 -2
  184. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  185. package/dist/server/services/websocket/types.d.ts +28 -5
  186. package/dist/server/services/websocket/types.d.ts.map +1 -1
  187. package/dist/server/services/websocket/types.js +10 -4
  188. package/dist/server/services/websocket/types.js.map +1 -1
  189. package/package.json +5 -3
  190. package/server/cli/eta-estimator.ts +249 -0
  191. package/server/cli/headless/stall-assessor.ts +93 -0
  192. package/server/cli/headless/tool-watchdog.ts +21 -0
  193. package/server/cli/improvisation-history-store.ts +4 -1
  194. package/server/cli/improvisation-output-queue.ts +29 -7
  195. package/server/cli/improvisation-session-manager.ts +54 -1
  196. package/server/cli/improvisation-types.ts +2 -0
  197. package/server/engines/EngineEvent.ts +156 -0
  198. package/server/engines/claude/ClaudeCodeEngine.ts +404 -0
  199. package/server/engines/factory.ts +176 -0
  200. package/server/engines/opencode/OpenCodeEngine.ts +786 -0
  201. package/server/engines/opencode/OpenCodeServerManager.ts +577 -0
  202. package/server/engines/opencode/model-catalog.ts +217 -0
  203. package/server/engines/types.ts +173 -0
  204. package/server/index.ts +1 -1
  205. package/server/mcp/bouncer-haiku.ts +21 -145
  206. package/server/mcp/bouncer-integration.ts +107 -5
  207. package/server/mcp/classifier/BouncerClassifier.ts +40 -0
  208. package/server/mcp/classifier/ClaudeBouncerClassifier.ts +189 -0
  209. package/server/mcp/classifier/OpenCodeBouncerClassifier.ts +305 -0
  210. package/server/mcp/classifier/factory.ts +195 -0
  211. package/server/services/plan/agent-resolver.ts +115 -0
  212. package/server/services/plan/agents/code-review.md +38 -8
  213. package/server/services/plan/composer.ts +63 -11
  214. package/server/services/plan/executor.ts +3 -1
  215. package/server/services/plan/issue-prompt-builder.ts +39 -1
  216. package/server/services/plan/parser-core.ts +1 -0
  217. package/server/services/plan/types.ts +4 -0
  218. package/server/services/settings.ts +161 -4
  219. package/server/services/websocket/git-branch-handlers.ts +20 -6
  220. package/server/services/websocket/handler.ts +59 -2
  221. package/server/services/websocket/quality-complexity.ts +80 -26
  222. package/server/services/websocket/quality-eta.ts +155 -0
  223. package/server/services/websocket/quality-grading.ts +445 -222
  224. package/server/services/websocket/quality-handlers.ts +153 -7
  225. package/server/services/websocket/quality-operations.ts +72 -0
  226. package/server/services/websocket/quality-persistence.ts +17 -0
  227. package/server/services/websocket/quality-review-agent.ts +154 -64
  228. package/server/services/websocket/quality-service.ts +361 -13
  229. package/server/services/websocket/quality-tools.ts +51 -0
  230. package/server/services/websocket/quality-types.ts +41 -2
  231. package/server/services/websocket/session-handlers.ts +64 -10
  232. package/server/services/websocket/session-history.ts +3 -0
  233. package/server/services/websocket/session-initialization.ts +189 -46
  234. package/server/services/websocket/session-registry.ts +37 -0
  235. package/server/services/websocket/settings-handlers.ts +41 -4
  236. package/server/services/websocket/tab-broadcast.ts +10 -2
  237. package/server/services/websocket/tab-event-buffer.ts +143 -11
  238. package/server/services/websocket/tab-event-replay.ts +70 -3
  239. package/server/services/websocket/tab-handlers.ts +53 -5
  240. package/server/services/websocket/types.ts +37 -5
@@ -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);
@@ -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. */
@@ -16,7 +16,10 @@
16
16
  import { EventEmitter } from 'node:events';
17
17
  import { existsSync, readFileSync } from 'node:fs';
18
18
  import { join } from 'node:path';
19
+ import { createEngine } from '../engines/factory.js';
20
+ import type { EngineId } from '../engines/types.js';
19
21
  import { AnalyticsEvents, trackEvent } from '../services/analytics.js';
22
+ import { type EtaProfile, getEtaProfileCached } from './eta-estimator.js';
20
23
  import { herror } from './headless/headless-logger.js';
21
24
  import { cleanupAttachments, preparePromptAndAttachments } from './improvisation-attachments.js';
22
25
  import {
@@ -55,6 +58,12 @@ export class ImprovisationSessionManager extends EventEmitter {
55
58
  private history: SessionHistory;
56
59
  private currentRunner: import('./headless/index.js').HeadlessRunner | null = null;
57
60
  private options: ImprovisationOptions;
61
+ /**
62
+ * Coding-agent backend identifier. Routed through `createEngine` from
63
+ * `engines/factory.ts` so Epic 3 can swap in OpenCodeEngine by changing
64
+ * this value — the retry loop and headless runner remain unchanged.
65
+ */
66
+ private engineId: EngineId = 'claude-code';
58
67
  private pendingApproval?: {
59
68
  plan: unknown;
60
69
  resolve: (approved: boolean) => void;
@@ -73,6 +82,13 @@ export class ImprovisationSessionManager extends EventEmitter {
73
82
  private _currentUserPrompt: string = '';
74
83
  private _currentSequenceNumber: number = 0;
75
84
  private _hasPersistedToDisk: boolean = false;
85
+ /**
86
+ * Cached duration-quantile profile used by the web "Composing" indicator
87
+ * to render an ETA. Built lazily on first executePrompt and refreshed by
88
+ * the eta-estimator's TTL cache. Null means "not enough history yet" — the
89
+ * web falls back to elapsed-only display.
90
+ */
91
+ private _etaProfile: EtaProfile | null = null;
76
92
 
77
93
  static resumeFromHistory(workingDir: string, historicalSessionId: string, overrides?: Partial<ImprovisationOptions>): ImprovisationSessionManager {
78
94
  const historyDir = join(workingDir, '.mstro', 'history');
@@ -84,6 +100,7 @@ export class ImprovisationSessionManager extends EventEmitter {
84
100
  }
85
101
 
86
102
  const historyData = JSON.parse(readFileSync(historyPath, 'utf-8')) as SessionHistory;
103
+ if (!historyData.engine) historyData.engine = 'claude-code';
87
104
  const manager = new ImprovisationSessionManager({
88
105
  workingDir,
89
106
  sessionId: historyData.sessionId,
@@ -126,6 +143,11 @@ export class ImprovisationSessionManager extends EventEmitter {
126
143
  this.historyPath = paths.historyPath;
127
144
  ensureHistoryDir(this.improviseDir);
128
145
 
146
+ // Validate the engine is available via the factory. Epic 3 will extend
147
+ // `createEngine` to also return OpenCodeEngine; today this asserts that
148
+ // a known engine id was requested and surfaces config errors early.
149
+ createEngine(this.engineId);
150
+
129
151
  this.history = loadHistory(this.historyPath, this.sessionId);
130
152
  // History is persisted lazily on the first `persistHistory` call (see
131
153
  // `executePrompt`). Deferring the initial write keeps the Chat History
@@ -165,7 +187,8 @@ export class ImprovisationSessionManager extends EventEmitter {
165
187
  const sequenceNumber = this.history.movements.length + 1;
166
188
  this._currentUserPrompt = displayPrompt;
167
189
  this._currentSequenceNumber = sequenceNumber;
168
- this.emit('onMovementStart', sequenceNumber, displayPrompt, isAutoContinue);
190
+ await this.refreshEtaProfile();
191
+ this.emit('onMovementStart', sequenceNumber, displayPrompt, isAutoContinue, this._etaProfile);
169
192
  trackEvent(AnalyticsEvents.IMPROVISE_PROMPT_RECEIVED, {
170
193
  prompt_length: userPrompt.length,
171
194
  has_attachments: !!(attachments && attachments.length > 0),
@@ -499,6 +522,22 @@ export class ImprovisationSessionManager extends EventEmitter {
499
522
  });
500
523
  }
501
524
 
525
+ // ========== ETA profile ==========
526
+
527
+ /**
528
+ * Resolve the duration-quantile profile before announcing the movement
529
+ * so the web indicator can render an ETA from t=0. The cache amortizes
530
+ * I/O across prompts; only the very first prompt of a project pays the
531
+ * ~50–100ms scan cost. Failures degrade silently to "no ETA".
532
+ */
533
+ private async refreshEtaProfile(): Promise<void> {
534
+ try {
535
+ this._etaProfile = await getEtaProfileCached(this.improviseDir);
536
+ } catch {
537
+ this._etaProfile = null;
538
+ }
539
+ }
540
+
502
541
  // ========== History I/O ==========
503
542
 
504
543
  private persistHistory(): void {
@@ -588,10 +627,24 @@ export class ImprovisationSessionManager extends EventEmitter {
588
627
  return this._isExecuting;
589
628
  }
590
629
 
630
+ /**
631
+ * AI engine that produced this session. Read from SessionHistory (populated
632
+ * on load/creation). Defaults to 'claude-code' if the history record is
633
+ * missing the field (older sessions).
634
+ */
635
+ get engine(): string {
636
+ return this.history.engine || 'claude-code';
637
+ }
638
+
591
639
  get executionStartTimestamp(): number | undefined {
592
640
  return this._executionStartTimestamp;
593
641
  }
594
642
 
643
+ /** Most recently resolved ETA quantile profile, or null if none yet built. */
644
+ get etaProfile(): EtaProfile | null {
645
+ return this._etaProfile;
646
+ }
647
+
595
648
  getExecutionEventLog(): Array<{ type: string; data: unknown; timestamp: number }> {
596
649
  return this.executionEventLog;
597
650
  }
@@ -66,6 +66,8 @@ export interface SessionHistory {
66
66
  totalTokens: number;
67
67
  movements: MovementRecord[];
68
68
  claudeSessionId?: string;
69
+ /** AI engine that produced this session (e.g. 'claude-code', 'opencode'). Older histories default to 'claude-code' on read. */
70
+ engine: string;
69
71
  }
70
72
 
71
73
  /** Entry in the retry log for debugging recovery paths */
@@ -0,0 +1,156 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * EngineEvent — the engine-agnostic event stream produced by every
6
+ * CodingAgentEngine (see ./types.ts).
7
+ *
8
+ * Contract:
9
+ * - Every event has a `kind` discriminator, a `sessionId` (the engine's own
10
+ * session identifier — Claude Code session id, OpenCode session id, etc.),
11
+ * and a `timestamp` in Unix ms.
12
+ * - Payloads must carry enough information to populate OutputLine in the web
13
+ * client without the consumer knowing which engine produced them. Do not
14
+ * leak engine-specific fields (e.g. Claude tool_use_ids, OpenCode part ids)
15
+ * through typed event fields — keep them in `raw` when needed for debugging.
16
+ * - Ordering is guaranteed within a single session: for a given tool call,
17
+ * `tool.start` precedes `tool.end`; `usage.update` values are monotonic.
18
+ * - `session.idle` marks the end of a turn (assistant finished responding),
19
+ * not the end of the session. Multiple idle events per session are normal.
20
+ * - `engine.error` with `fatal: true` is terminal — the async iterator must
21
+ * complete after emitting it.
22
+ */
23
+
24
+ /** Identifier for which concrete engine produced an event. */
25
+ export type EngineId = 'claude-code' | 'opencode';
26
+
27
+ /** Fields shared by every engine event. */
28
+ interface EngineEventBase {
29
+ /** Engine-reported session id (e.g. Claude Code session id, OpenCode session id). */
30
+ sessionId: string;
31
+ /** Unix epoch milliseconds when the engine observed this event. */
32
+ timestamp: number;
33
+ /** Optional raw payload from the engine for debugging/audit. Must not be used for business logic. */
34
+ raw?: unknown;
35
+ }
36
+
37
+ /** Streaming assistant text (user-visible output). */
38
+ export interface MessageDeltaEvent extends EngineEventBase {
39
+ kind: 'message.delta';
40
+ /** Incremental chunk of assistant-visible text. Consumers concatenate. */
41
+ text: string;
42
+ }
43
+
44
+ /** Streaming assistant thinking/reasoning text (collapsed by default in UI). */
45
+ export interface MessageThinkingEvent extends EngineEventBase {
46
+ kind: 'message.thinking';
47
+ /** Incremental chunk of thinking text. */
48
+ text: string;
49
+ }
50
+
51
+ /** A tool invocation has started. */
52
+ export interface ToolStartEvent extends EngineEventBase {
53
+ kind: 'tool.start';
54
+ /** Engine-agnostic tool call id (unique within the session). */
55
+ toolCallId: string;
56
+ /** Name of the tool (e.g. "Read", "Bash"). */
57
+ toolName: string;
58
+ /**
59
+ * Arguments passed to the tool. May be partial at start — some engines
60
+ * stream arguments. Consumers should treat this as best-effort until
61
+ * `tool.end` arrives with the authoritative input.
62
+ */
63
+ input: Record<string, unknown>;
64
+ }
65
+
66
+ /** A tool invocation has completed (successfully or with an error). */
67
+ export interface ToolEndEvent extends EngineEventBase {
68
+ kind: 'tool.end';
69
+ toolCallId: string;
70
+ toolName: string;
71
+ /** Authoritative tool input as executed. */
72
+ input: Record<string, unknown>;
73
+ /** Serialized tool result (stdout, file contents, JSON, etc.). */
74
+ result: string;
75
+ /** True if the tool returned an error. */
76
+ isError: boolean;
77
+ /** Wall-clock duration in ms between tool.start and tool.end. */
78
+ durationMs: number;
79
+ }
80
+
81
+ /**
82
+ * The engine is asking whether a tool call should proceed. Consumed by
83
+ * the Bouncer in Epic 4 which must resolve the request via the engine's
84
+ * matching permission-response channel.
85
+ */
86
+ export interface PermissionRequestEvent extends EngineEventBase {
87
+ kind: 'permission.request';
88
+ /** Opaque id the engine will expect echoed back in a decision. */
89
+ requestId: string;
90
+ toolName: string;
91
+ /** Tool arguments to be classified. */
92
+ input: Record<string, unknown>;
93
+ /** Engine-provided reason string, if any. */
94
+ reason?: string;
95
+ }
96
+
97
+ /**
98
+ * Session returned to idle — the assistant finished its current turn.
99
+ * Not the end of the session; a new prompt may still be sent.
100
+ */
101
+ export interface SessionIdleEvent extends EngineEventBase {
102
+ kind: 'session.idle';
103
+ /** Engine's stop reason if known (e.g. 'end_turn', 'max_tokens'). */
104
+ stopReason?: string;
105
+ }
106
+
107
+ /**
108
+ * Running token counts. Values are cumulative across the session
109
+ * (not per-turn) and must be monotonically non-decreasing.
110
+ */
111
+ export interface UsageUpdateEvent extends EngineEventBase {
112
+ kind: 'usage.update';
113
+ inputTokens: number;
114
+ outputTokens: number;
115
+ cacheCreationTokens?: number;
116
+ cacheReadTokens?: number;
117
+ }
118
+
119
+ /**
120
+ * An engine-level error occurred. With `fatal: true`, the session is
121
+ * unrecoverable and the async iterator completes after this event.
122
+ */
123
+ export interface EngineErrorEvent extends EngineEventBase {
124
+ kind: 'engine.error';
125
+ /** Short error code for UI mapping (see ClaudeErrorCode in web/src/types/output.ts). */
126
+ code: string;
127
+ /** Human-readable message. */
128
+ message: string;
129
+ /** True if the session is unrecoverable and should be torn down. */
130
+ fatal: boolean;
131
+ }
132
+
133
+ /** Discriminated union of every event a CodingAgentEngine may emit. */
134
+ export type EngineEvent =
135
+ | MessageDeltaEvent
136
+ | MessageThinkingEvent
137
+ | ToolStartEvent
138
+ | ToolEndEvent
139
+ | PermissionRequestEvent
140
+ | SessionIdleEvent
141
+ | UsageUpdateEvent
142
+ | EngineErrorEvent;
143
+
144
+ /** Narrow helper — returns true for events that carry user-visible text. */
145
+ export function isMessageEvent(
146
+ event: EngineEvent,
147
+ ): event is MessageDeltaEvent | MessageThinkingEvent {
148
+ return event.kind === 'message.delta' || event.kind === 'message.thinking';
149
+ }
150
+
151
+ /** Narrow helper — returns true for the tool lifecycle events. */
152
+ export function isToolEvent(
153
+ event: EngineEvent,
154
+ ): event is ToolStartEvent | ToolEndEvent {
155
+ return event.kind === 'tool.start' || event.kind === 'tool.end';
156
+ }