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
@@ -13,15 +13,16 @@
13
13
  import { join, resolve } from 'node:path';
14
14
  import { validatePathWithinWorkingDir } from '../pathUtils.js';
15
15
  import type { HandlerContext } from './handler-context.js';
16
+ import { estimateCodebaseSize, estimateScanMs } from './quality-eta.js';
17
+ import { operationRegistry } from './quality-operations.js';
16
18
  import { QualityPersistence } from './quality-persistence.js';
17
19
  import { handleCodeReview } from './quality-review-agent.js';
18
- import { detectTools, installTools, runQualityScan } from './quality-service.js';
20
+ import { detectTools, installTools, QualityScanAbortedError, runQualityScan } from './quality-service.js';
19
21
  import type { WebSocketMessage, WSContext } from './types.js';
20
22
 
21
23
  // ── Shared state ──────────────────────────────────────────────
22
24
 
23
25
  const persistenceCache = new Map<string, QualityPersistence>();
24
- const activeReviews = new Set<string>();
25
26
 
26
27
  function getPersistence(workingDir: string): QualityPersistence {
27
28
  let persistence = persistenceCache.get(workingDir);
@@ -86,10 +87,32 @@ export function handleQualityMessage(
86
87
  if (error) { sendPathError(msg.data?.path || '.', error); return; }
87
88
  const reportPath = msg.data?.path || '.';
88
89
  const persistence = getPersistence(workingDir);
90
+ let controller: AbortController;
91
+ try {
92
+ controller = operationRegistry.start(workingDir, reportPath, 'reviewing');
93
+ } catch {
94
+ // Look up what's actually running so the user sees the right message
95
+ // — clicking "Run checks" during an in-flight AI review used to surface
96
+ // "A scan is already running" because the error was hardcoded to the
97
+ // *new* op kind rather than the existing one.
98
+ const runningKind = operationRegistry.getKind(workingDir, reportPath);
99
+ const error = runningKind === 'scanning'
100
+ ? 'A scan is already running for this directory.'
101
+ : 'An AI review is already running for this directory.';
102
+ ctx.send(ws, { type: 'qualityError', data: { path: reportPath, error } });
103
+ return;
104
+ }
89
105
  persistence.setActiveOperation(reportPath, 'reviewing');
90
- handleCodeReview(ctx, ws, reportPath, dirPath, workingDir, activeReviews, getPersistence)
91
- .finally(() => persistence.clearActiveOperation(reportPath));
106
+ // The review agent is responsible for emitting the first progress
107
+ // message with an ETA; the handler just wires up the controller +
108
+ // persistence cleanup.
109
+ handleCodeReview(ctx, ws, reportPath, dirPath, workingDir, getPersistence, controller.signal)
110
+ .finally(() => {
111
+ operationRegistry.finish(workingDir, reportPath);
112
+ persistence.clearActiveOperation(reportPath);
113
+ });
92
114
  },
115
+ qualityCancel: () => handleCancel(ctx, msg, workingDir),
93
116
  qualityLoadState: () => handleLoadState(ctx, ws, workingDir),
94
117
  qualityClearPending: () => {
95
118
  const persistence = getPersistence(workingDir);
@@ -137,6 +160,27 @@ async function handleLoadState(
137
160
  persistence.clearPendingResults();
138
161
  }
139
162
 
163
+ // Reconcile orphaned active operations: anything persisted to disk that
164
+ // has no live `AbortController` in the registry was interrupted by a CLI
165
+ // restart or crash. Surface as an error so the UI clears the spinner and
166
+ // remove from disk so the same op doesn't keep haunting future reconnects.
167
+ const orphans = state.activeOperations.filter(
168
+ (op) => !operationRegistry.has(workingDir, op.path),
169
+ );
170
+ if (orphans.length > 0) {
171
+ for (const op of orphans) {
172
+ persistence.clearActiveOperation(op.path);
173
+ ctx.send(ws, {
174
+ type: 'qualityError',
175
+ data: { path: op.path, error: 'Operation interrupted — please run again.' },
176
+ });
177
+ }
178
+ // Reload so the response reflects the cleared ops.
179
+ const refreshed = persistence.loadState();
180
+ ctx.send(ws, { type: 'qualityStateLoaded', data: refreshed });
181
+ return;
182
+ }
183
+
140
184
  ctx.send(ws, { type: 'qualityStateLoaded', data: state });
141
185
  } catch (error) {
142
186
  ctx.send(ws, {
@@ -146,6 +190,30 @@ async function handleLoadState(
146
190
  }
147
191
  }
148
192
 
193
+ function handleCancel(
194
+ ctx: HandlerContext,
195
+ msg: WebSocketMessage,
196
+ workingDir: string,
197
+ ): void {
198
+ const reportPath = msg.data?.path || '.';
199
+ const persistence = getPersistence(workingDir);
200
+ const wasRunning = operationRegistry.cancel(workingDir, reportPath);
201
+ // Always clear persistence — cancel for an orphan should still leave the
202
+ // disk clean so future reconnects don't re-emit the orphan reconciliation
203
+ // error.
204
+ persistence.clearActiveOperation(reportPath);
205
+ // Broadcast so every paired device sees the operation end (multi-device
206
+ // sync). If nothing was running we still emit so a stale spinner on a
207
+ // second device gets cleared by the `qualityError` handler.
208
+ ctx.broadcastToAll({
209
+ type: 'qualityError',
210
+ data: {
211
+ path: reportPath,
212
+ error: wasRunning ? 'Cancelled by user' : 'Operation already finished',
213
+ },
214
+ });
215
+ }
216
+
149
217
  async function handleSaveDirectories(
150
218
  ctx: HandlerContext,
151
219
  ws: WSContext,
@@ -230,18 +298,90 @@ async function handleScan(
230
298
  const reportPath = msg.data?.path || '.';
231
299
  const persistence = getPersistence(workingDir);
232
300
 
301
+ let controller: AbortController;
302
+ try {
303
+ controller = operationRegistry.start(workingDir, reportPath, 'scanning');
304
+ } catch {
305
+ // Same reasoning as the qualityCodeReview handler above — surface the
306
+ // *running* op kind, not the requested one.
307
+ const runningKind = operationRegistry.getKind(workingDir, reportPath);
308
+ const error = runningKind === 'reviewing'
309
+ ? 'An AI review is already running for this directory.'
310
+ : 'A scan is already running for this directory.';
311
+ ctx.send(ws, { type: 'qualityError', data: { path: reportPath, error } });
312
+ return;
313
+ }
314
+
315
+ const scanStartedAt = Date.now();
316
+ // Pre-compute a size + ETA so the very first progress event carries an
317
+ // estimate. We deliberately do this *before* `setActiveOperation` so a
318
+ // freshly-clicked scan shows numbers immediately rather than after a
319
+ // file-collection round-trip.
320
+ let etaMs: number | undefined;
321
+ try {
322
+ const size = await estimateCodebaseSize(dirPath);
323
+ etaMs = estimateScanMs(size, persistence.loadHistory(), reportPath);
324
+ } catch {
325
+ // Falling back to no ETA is fine — the UI will simply hide the remaining-time chip.
326
+ }
327
+
233
328
  try {
234
329
  persistence.setActiveOperation(reportPath, 'scanning');
235
330
 
331
+ // Emit a "Detecting tools" frame *before* the long detect call so the
332
+ // user sees motion immediately on click — `detectTools` spawns one
333
+ // child process per ecosystem tool and can sit silent for several
334
+ // seconds on a cold cache.
335
+ ctx.send(ws, {
336
+ type: 'qualityScanProgress',
337
+ data: {
338
+ path: reportPath,
339
+ progress: { step: 'Detecting tools', current: 0, total: 8, etaMs, startedAt: scanStartedAt },
340
+ },
341
+ });
236
342
  const { tools: detectedTools } = await detectTools(dirPath);
237
343
  const installedToolNames = detectedTools.filter((t) => t.installed).map((t) => t.name);
238
344
 
239
- const results = await runQualityScan(dirPath, (progress) => {
345
+ // Heartbeat keeps the progress UI showing elapsed time during long
346
+ // sub-steps (lint/format/build) where `runQualityScan` doesn't get a
347
+ // chance to call the progress callback again.
348
+ let lastProgress: { step: string; current: number; total: number } = {
349
+ step: 'Detecting tools',
350
+ current: 0,
351
+ total: 8,
352
+ };
353
+ const heartbeat = setInterval(() => {
354
+ const elapsedSec = Math.round((Date.now() - scanStartedAt) / 1000);
240
355
  ctx.send(ws, {
241
356
  type: 'qualityScanProgress',
242
- data: { path: reportPath, progress },
357
+ data: {
358
+ path: reportPath,
359
+ progress: { ...lastProgress, etaMs, startedAt: scanStartedAt, detail: `${elapsedSec}s elapsed` },
360
+ },
243
361
  });
244
- }, installedToolNames);
362
+ }, 5_000);
363
+
364
+ let results: Awaited<ReturnType<typeof runQualityScan>>;
365
+ try {
366
+ results = await runQualityScan(dirPath, (progress) => {
367
+ lastProgress = progress;
368
+ ctx.send(ws, {
369
+ type: 'qualityScanProgress',
370
+ data: {
371
+ path: reportPath,
372
+ progress: { ...progress, etaMs, startedAt: scanStartedAt },
373
+ },
374
+ });
375
+ }, installedToolNames, controller.signal);
376
+ } finally {
377
+ clearInterval(heartbeat);
378
+ }
379
+
380
+ // Annotate the report with the wall-clock duration so subsequent scans
381
+ // of this directory have real history to base their ETA on. Same pattern
382
+ // applies for the AI review duration written by the review agent.
383
+ const scanDurationMs = Date.now() - scanStartedAt;
384
+ results.scanDurationMs = scanDurationMs;
245
385
 
246
386
  // Persist before sending — results survive if WebSocket drops
247
387
  try {
@@ -266,11 +406,17 @@ async function handleScan(
266
406
  });
267
407
  }
268
408
  } catch (error) {
409
+ if (error instanceof QualityScanAbortedError) {
410
+ // Cancellation already broadcast a `qualityError` from `handleCancel`.
411
+ // Don't send a second error message.
412
+ return;
413
+ }
269
414
  ctx.send(ws, {
270
415
  type: 'qualityError',
271
416
  data: { path: reportPath, error: error instanceof Error ? error.message : String(error) },
272
417
  });
273
418
  } finally {
419
+ operationRegistry.finish(workingDir, reportPath);
274
420
  persistence.clearActiveOperation(reportPath);
275
421
  }
276
422
  }
@@ -0,0 +1,72 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+
3
+ /**
4
+ * Quality Operation Registry — in-process tracking of in-flight scans and
5
+ * code reviews so they can be cancelled (or detected as orphaned) without
6
+ * depending on durable state.
7
+ *
8
+ * Persistence (`.mstro/quality/active-ops.json`) survives a CLI restart but
9
+ * has no live `AbortController` to cancel; this registry holds the controllers
10
+ * for the current process. The two layers are deliberately decoupled — when a
11
+ * persisted op exists with no registry entry, callers know the op is orphaned.
12
+ */
13
+
14
+ export type OperationKind = 'scanning' | 'reviewing';
15
+
16
+ interface RegisteredOperation {
17
+ controller: AbortController;
18
+ kind: OperationKind;
19
+ startedAt: number;
20
+ }
21
+
22
+ export class OperationRegistry {
23
+ private readonly ops = new Map<string, RegisteredOperation>();
24
+
25
+ private key(workingDir: string, path: string): string {
26
+ return `${workingDir}::${path}`;
27
+ }
28
+
29
+ /**
30
+ * Reserve an operation slot and return its `AbortController`. Throws when
31
+ * the same `(workingDir, path)` pair is already in flight — callers should
32
+ * surface this to the user as "already running" rather than silently
33
+ * starting a second worker.
34
+ */
35
+ start(workingDir: string, path: string, kind: OperationKind): AbortController {
36
+ const k = this.key(workingDir, path);
37
+ if (this.ops.has(k)) {
38
+ throw new Error(`Operation already in flight: ${kind} ${path}`);
39
+ }
40
+ const controller = new AbortController();
41
+ this.ops.set(k, { controller, kind, startedAt: Date.now() });
42
+ return controller;
43
+ }
44
+
45
+ /**
46
+ * Cancel an in-flight operation. Returns `true` when an op was found and
47
+ * aborted, `false` when nothing was registered (the caller should still
48
+ * clear persistence — the op is either already finished or was orphaned).
49
+ */
50
+ cancel(workingDir: string, path: string): boolean {
51
+ const k = this.key(workingDir, path);
52
+ const op = this.ops.get(k);
53
+ if (!op) return false;
54
+ op.controller.abort();
55
+ return true;
56
+ }
57
+
58
+ finish(workingDir: string, path: string): void {
59
+ this.ops.delete(this.key(workingDir, path));
60
+ }
61
+
62
+ has(workingDir: string, path: string): boolean {
63
+ return this.ops.has(this.key(workingDir, path));
64
+ }
65
+
66
+ /** Kind of the live operation, or null when nothing is registered. */
67
+ getKind(workingDir: string, path: string): OperationKind | null {
68
+ return this.ops.get(this.key(workingDir, path))?.kind ?? null;
69
+ }
70
+ }
71
+
72
+ export const operationRegistry = new OperationRegistry();
@@ -52,6 +52,15 @@ export interface QualityHistoryEntry {
52
52
  maintainability: { score: number; grade: string };
53
53
  };
54
54
  directories: HistoryDirectoryEntry[];
55
+ /**
56
+ * Wall-clock duration of the CLI scan that produced this entry, in
57
+ * milliseconds. Older entries persisted before the ETA rollout will not
58
+ * include this field, in which case the ETA estimator falls back to the
59
+ * heuristic until enough samples accumulate.
60
+ */
61
+ scanDurationMs?: number;
62
+ /** Wall-clock duration of the AI review pass, when one ran for this entry. */
63
+ reviewDurationMs?: number;
55
64
  }
56
65
 
57
66
  interface QualityHistory {
@@ -257,6 +266,12 @@ export class QualityPersistence {
257
266
  if (categoryScores) lastEntry.categoryScores = categoryScores;
258
267
  if (issueDensity !== undefined) lastEntry.issueDensity = issueDensity;
259
268
  if (dimensionScores) lastEntry.dimensionScores = dimensionScores;
269
+ // Carry through whichever duration is present on this report. We
270
+ // overwrite — the latest scan/review is the most accurate sample for
271
+ // this directory. (Multi-dir merges within the 60s window write each
272
+ // dir's duration in turn; the final entry reflects the last write.)
273
+ if (typeof results.scanDurationMs === 'number') lastEntry.scanDurationMs = results.scanDurationMs;
274
+ if (typeof results.reviewDurationMs === 'number') lastEntry.reviewDurationMs = results.reviewDurationMs;
260
275
  } else {
261
276
  history.push({
262
277
  timestamp: now.toISOString(),
@@ -266,6 +281,8 @@ export class QualityPersistence {
266
281
  categoryScores,
267
282
  dimensionScores,
268
283
  directories: [dirEntry],
284
+ scanDurationMs: results.scanDurationMs,
285
+ reviewDurationMs: results.reviewDurationMs,
269
286
  });
270
287
  }
271
288
 
@@ -12,6 +12,7 @@ import { ResilientRunner } from '../../cli/headless/resilient-runner.js';
12
12
  import type { ToolUseEvent } from '../../cli/headless/types.js';
13
13
  import { loadSkillPrompt } from '../plan/agent-loader.js';
14
14
  import type { HandlerContext } from './handler-context.js';
15
+ import { estimateCodebaseSize, estimateReviewMs } from './quality-eta.js';
15
16
  import type { QualityPersistence } from './quality-persistence.js';
16
17
  import { recomputeWithAiReview } from './quality-service.js';
17
18
  import type { WSContext } from './types.js';
@@ -368,10 +369,37 @@ function createCodeReviewProgressTracker() {
368
369
 
369
370
  type ProgressSender = (message: string) => void;
370
371
 
371
- function makeProgressSender(ctx: HandlerContext, ws: WSContext, reportPath: string): ProgressSender {
372
+ interface ProgressMeta {
373
+ /** Total estimated wall-clock duration for the review, in ms. */
374
+ etaMs?: number;
375
+ /** Server-side timestamp of when the review started, ms since epoch. */
376
+ startedAt?: number;
377
+ }
378
+
379
+ /**
380
+ * Build a progress sender that reads its meta lazily — `etaMs` lands a few
381
+ * hundred ms after the review starts (we do file collection in parallel),
382
+ * and we want the first "Starting AI code review..." event to fire
383
+ * immediately rather than waiting on it. So progress events fire with
384
+ * whatever meta is set at *send* time, not at *closure* time.
385
+ */
386
+ function makeProgressSender(
387
+ ctx: HandlerContext,
388
+ ws: WSContext,
389
+ reportPath: string,
390
+ metaRef: { current: ProgressMeta },
391
+ ): ProgressSender {
372
392
  return (message: string) => {
373
393
  try {
374
- ctx.send(ws, { type: 'qualityCodeReviewProgress', data: { path: reportPath, message } });
394
+ ctx.send(ws, {
395
+ type: 'qualityCodeReviewProgress',
396
+ data: {
397
+ path: reportPath,
398
+ message,
399
+ etaMs: metaRef.current.etaMs,
400
+ startedAt: metaRef.current.startedAt,
401
+ },
402
+ });
375
403
  } catch {
376
404
  // WebSocket closed — progress lost but operation continues
377
405
  }
@@ -416,6 +444,7 @@ async function runVerificationPass(
416
444
  dirPath: string,
417
445
  findings: CodeReviewFinding[],
418
446
  send: ProgressSender,
447
+ signal?: AbortSignal,
419
448
  ): Promise<CodeReviewFinding[]> {
420
449
  send(`Verifying ${findings.length} findings against actual code...`);
421
450
  const stopHeartbeat = startHeartbeat(send, 'Verification');
@@ -430,9 +459,11 @@ async function runVerificationPass(
430
459
  stallHardCapMs: 3_600_000,
431
460
  toolUseCallback: makeToolCallback(send, 'Verifying: '),
432
461
  logLabel: 'code-review-verify',
462
+ abortSignal: signal,
433
463
  });
434
464
 
435
465
  const verifyResult = await verificationRunner.run();
466
+ if (signal?.aborted) return findings;
436
467
  const verdicts = parseVerificationResponse(verifyResult.assistantResponse || '');
437
468
 
438
469
  if (verdicts.length === 0) return findings;
@@ -452,6 +483,7 @@ function persistReviewResults(
452
483
  reportPath: string,
453
484
  getPersistence: (dir: string) => QualityPersistence,
454
485
  workingDir: string,
486
+ reviewDurationMs: number,
455
487
  ): import('./quality-service.js').QualityResults | null {
456
488
  const persistence = getPersistence(workingDir);
457
489
  const existingReport = persistence.loadReport(reportPath);
@@ -465,7 +497,7 @@ function persistReviewResults(
465
497
 
466
498
  let updatedResults: import('./quality-service.js').QualityResults;
467
499
  updatedResults = recomputeWithAiReview(existingReport, reviewResult.findings);
468
- updatedResults = { ...updatedResults, codeReview: findings };
500
+ updatedResults = { ...updatedResults, codeReview: findings, reviewDurationMs };
469
501
 
470
502
  persistence.saveReport(reportPath, updatedResults);
471
503
  persistence.appendHistory(updatedResults, reportPath);
@@ -475,29 +507,14 @@ function persistReviewResults(
475
507
 
476
508
  // ── Handler ───────────────────────────────────────────────────
477
509
 
478
- export async function handleCodeReview(
479
- ctx: HandlerContext,
480
- ws: WSContext,
481
- reportPath: string,
510
+ async function runInitialReview(
482
511
  dirPath: string,
483
- workingDir: string,
484
- activeReviews: Set<string>,
485
- getPersistence: (dir: string) => QualityPersistence,
486
- ): Promise<void> {
487
- if (activeReviews.has(dirPath)) {
488
- ctx.send(ws, { type: 'qualityError', data: { path: reportPath, error: 'A code review is already running for this directory.' } });
489
- return;
490
- }
491
-
492
- activeReviews.add(dirPath);
493
- const send = makeProgressSender(ctx, ws, reportPath);
494
-
512
+ cliFindings: ReturnType<typeof loadCliFindings>,
513
+ send: ProgressSender,
514
+ signal: AbortSignal,
515
+ ): Promise<CodeReviewResult> {
516
+ const stopReviewHeartbeat = startHeartbeat(send, 'AI code review');
495
517
  try {
496
- send('Starting AI code review...');
497
- const cliFindings = loadCliFindings(getPersistence, workingDir, reportPath);
498
-
499
- // ── Pass 1: Initial AI code review ──────────────────────
500
- const stopReviewHeartbeat = startHeartbeat(send, 'AI code review');
501
518
  const runner = new ResilientRunner({
502
519
  workingDir: dirPath,
503
520
  prompt: buildCodeReviewPrompt(dirPath, cliFindings),
@@ -507,61 +524,134 @@ export async function handleCodeReview(
507
524
  stallHardCapMs: 7_200_000,
508
525
  toolUseCallback: makeToolCallback(send),
509
526
  logLabel: 'code-review',
527
+ abortSignal: signal,
510
528
  });
511
-
512
529
  send('Claude is analyzing your codebase...');
513
530
  const result = await runner.run();
531
+ return parseCodeReviewResponse(result.assistantResponse || '');
532
+ } finally {
514
533
  stopReviewHeartbeat();
515
- const reviewResult = parseCodeReviewResponse(result.assistantResponse || '');
516
-
517
- // ── Phase 3: Deterministic post-validation ──────────────
518
- send(`Validating ${reviewResult.findings.length} findings against codebase...`);
519
- const validation = validateFindings(reviewResult.findings, dirPath);
520
- if (validation.stats.failed > 0) {
521
- send(`Filtered ${validation.stats.failed} finding(s) with invalid references`);
522
- }
523
-
524
- // ── Phase 2: LLM verification pass ──────────────────────
525
- let finalFindings = validation.validated;
526
- if (finalFindings.length > 0) {
527
- try {
528
- finalFindings = await runVerificationPass(dirPath, finalFindings, send);
529
- } catch {
530
- send('Verification pass skipped (timeout or error)');
531
- }
532
- }
534
+ }
535
+ }
533
536
 
534
- // ── Persist and send results ─────────────────────────────
535
- send('Generating review report...');
536
- const verifiedReviewResult: CodeReviewResult = { ...reviewResult, findings: finalFindings };
537
+ async function refineFindings(
538
+ reviewResult: CodeReviewResult,
539
+ dirPath: string,
540
+ send: ProgressSender,
541
+ signal: AbortSignal,
542
+ ): Promise<CodeReviewFinding[]> {
543
+ send(`Validating ${reviewResult.findings.length} findings against codebase...`);
544
+ const validation = validateFindings(reviewResult.findings, dirPath);
545
+ if (validation.stats.failed > 0) {
546
+ send(`Filtered ${validation.stats.failed} finding(s) with invalid references`);
547
+ }
537
548
 
538
- let updatedResults: import('./quality-service.js').QualityResults | null = null;
549
+ let finalFindings = validation.validated;
550
+ if (finalFindings.length > 0 && !signal.aborted) {
539
551
  try {
540
- updatedResults = persistReviewResults(verifiedReviewResult, reportPath, getPersistence, workingDir);
552
+ finalFindings = await runVerificationPass(dirPath, finalFindings, send, signal);
541
553
  } catch {
542
- // Persistence failure should not break the review flow
554
+ send('Verification pass skipped (timeout or error)');
543
555
  }
556
+ }
557
+ return finalFindings;
558
+ }
544
559
 
545
- const resultData = { path: reportPath, findings: verifiedReviewResult.findings, summary: verifiedReviewResult.summary, results: updatedResults };
546
- try {
547
- ctx.send(ws, { type: 'qualityCodeReview', data: resultData });
548
- } catch {
549
- // WebSocket closed — save as pending for delivery on reconnect
550
- const persistence = getPersistence(workingDir);
551
- persistence.addPendingResult({
552
- type: 'codeReview',
553
- path: reportPath,
554
- data: resultData as unknown as Record<string, unknown>,
555
- completedAt: new Date().toISOString(),
556
- });
557
- }
560
+ function emitReviewResult(
561
+ ctx: HandlerContext,
562
+ ws: WSContext,
563
+ reportPath: string,
564
+ workingDir: string,
565
+ verifiedReviewResult: CodeReviewResult,
566
+ getPersistence: (dir: string) => QualityPersistence,
567
+ reviewDurationMs: number,
568
+ ): void {
569
+ let updatedResults: import('./quality-service.js').QualityResults | null = null;
570
+ try {
571
+ updatedResults = persistReviewResults(verifiedReviewResult, reportPath, getPersistence, workingDir, reviewDurationMs);
572
+ } catch {
573
+ // Persistence failure should not break the review flow
574
+ }
575
+
576
+ const resultData = { path: reportPath, findings: verifiedReviewResult.findings, summary: verifiedReviewResult.summary, results: updatedResults };
577
+ try {
578
+ ctx.send(ws, { type: 'qualityCodeReview', data: resultData });
579
+ } catch {
580
+ // WebSocket closed — save as pending for delivery on reconnect
581
+ getPersistence(workingDir).addPendingResult({
582
+ type: 'codeReview',
583
+ path: reportPath,
584
+ data: resultData as unknown as Record<string, unknown>,
585
+ completedAt: new Date().toISOString(),
586
+ });
587
+ }
588
+ }
589
+
590
+ async function computeReviewEta(
591
+ dirPath: string,
592
+ getPersistence: (dir: string) => QualityPersistence,
593
+ workingDir: string,
594
+ reportPath: string,
595
+ ): Promise<number | undefined> {
596
+ try {
597
+ const size = await estimateCodebaseSize(dirPath);
598
+ return estimateReviewMs(size, getPersistence(workingDir).loadHistory(), reportPath);
599
+ } catch {
600
+ return undefined;
601
+ }
602
+ }
603
+
604
+ export async function handleCodeReview(
605
+ ctx: HandlerContext,
606
+ ws: WSContext,
607
+ reportPath: string,
608
+ dirPath: string,
609
+ workingDir: string,
610
+ getPersistence: (dir: string) => QualityPersistence,
611
+ signal: AbortSignal,
612
+ ): Promise<void> {
613
+ const startedAt = Date.now();
614
+ // Mutable meta so progress messages can fire immediately, even before the
615
+ // ETA is computed in the background. This is what makes "AI is reviewing"
616
+ // appear instantly when scan completes — we don't want to wait on a
617
+ // file-collection round-trip before the first progress event lands.
618
+ const metaRef: { current: ProgressMeta } = { current: { startedAt } };
619
+ const send = makeProgressSender(ctx, ws, reportPath, metaRef);
620
+
621
+ // Kick the ETA computation off in parallel — it'll patch metaRef when it
622
+ // finishes, and any later progress event will pick up the value.
623
+ computeReviewEta(dirPath, getPersistence, workingDir, reportPath)
624
+ .then((etaMs) => { if (etaMs !== undefined) metaRef.current = { ...metaRef.current, etaMs }; })
625
+ .catch(() => { /* leave etaMs unset — UI just hides the chip */ });
626
+
627
+ try {
628
+ send('Starting AI code review...');
629
+ const cliFindings = loadCliFindings(getPersistence, workingDir, reportPath);
630
+
631
+ const reviewResult = await runInitialReview(dirPath, cliFindings, send, signal);
632
+ if (signal.aborted) return;
633
+
634
+ const finalFindings = await refineFindings(reviewResult, dirPath, send, signal);
635
+ if (signal.aborted) return;
636
+
637
+ send('Generating review report...');
638
+ emitReviewResult(
639
+ ctx,
640
+ ws,
641
+ reportPath,
642
+ workingDir,
643
+ { ...reviewResult, findings: finalFindings },
644
+ getPersistence,
645
+ Date.now() - startedAt,
646
+ );
558
647
  } catch (error) {
648
+ // Suppress error emission for cancellations — `handleCancel` already
649
+ // broadcast a `qualityError` with the user-facing reason.
650
+ if (signal.aborted) return;
559
651
  try {
560
652
  ctx.send(ws, { type: 'qualityError', data: { path: reportPath, error: error instanceof Error ? error.message : String(error) } });
561
653
  } catch {
562
654
  // WebSocket closed — error lost but operation tracked via activeOps
563
655
  }
564
- } finally {
565
- activeReviews.delete(dirPath);
566
656
  }
567
657
  }