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
@@ -63,38 +63,52 @@ async function redirectToWorktreeIfBranchCheckedOut(
63
63
 
64
64
  export async function handleGitCheckout(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, rootWorkingDir: string): Promise<void> {
65
65
  try {
66
- const { branch, create, startPoint } = msg.data || {};
66
+ const { branch, create, startPoint, worktreePath } = msg.data || {};
67
67
  if (!branch) {
68
68
  ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Branch name is required' } });
69
69
  return;
70
70
  }
71
71
 
72
+ // `worktreePath` lets the caller target a specific working directory
73
+ // (typically the main repo) regardless of which tab is active. Used by
74
+ // the "Base branch" dropdown so checkout always lands on main, not on
75
+ // whichever worktree the user happened to be inspecting.
76
+ const targetDir = typeof worktreePath === 'string' && worktreePath.length > 0
77
+ ? worktreePath
78
+ : workingDir;
79
+
72
80
  // Skip the worktree redirect for `create` — a name collision there is a real user error.
73
- if (!create && await redirectToWorktreeIfBranchCheckedOut(ctx, ws, tabId, branch, workingDir, rootWorkingDir)) {
81
+ if (!create && await redirectToWorktreeIfBranchCheckedOut(ctx, ws, tabId, branch, targetDir, rootWorkingDir)) {
74
82
  return;
75
83
  }
76
84
 
77
- const statusResult = await executeGitCommand(['status', '--porcelain'], workingDir);
85
+ const statusResult = await executeGitCommand(['status', '--porcelain'], targetDir);
78
86
  if (statusResult.stdout.trim()) {
79
87
  ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Commit or stash changes before switching branches' } });
80
88
  return;
81
89
  }
82
90
 
83
- const prevResult = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], workingDir);
91
+ const prevResult = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], targetDir);
84
92
  const previous = prevResult.stdout.trim();
85
93
 
86
94
  const args = create
87
95
  ? ['checkout', '-b', branch, ...(startPoint ? [startPoint] : [])]
88
96
  : ['checkout', branch];
89
97
 
90
- const result = await executeGitCommand(args, workingDir);
98
+ const result = await executeGitCommand(args, targetDir);
91
99
  if (result.exitCode !== 0) {
92
100
  ctx.send(ws, { type: 'gitError', tabId, data: { error: result.stderr || 'Failed to checkout branch' } });
93
101
  return;
94
102
  }
95
103
 
96
104
  ctx.send(ws, { type: 'gitCheckedOut', tabId, data: { branch, previous } });
97
- // Re-fetch status after checkout - import handleGitStatus at call site
105
+ // Re-fetch status for the *tab's* dir (`workingDir`), not `targetDir`. When
106
+ // the caller targets a different directory via `worktreePath` (e.g. the
107
+ // main repo from a worktree-anchored tab), sending main-repo status keyed
108
+ // to the tab id would clobber the tab's worktree-scoped status display.
109
+ // The web side fires a fresh `gitStatus` + `gitWorktreeList` on the
110
+ // `gitCheckedOut` handler, so the main-repo branch update propagates via
111
+ // the worktree list refresh.
98
112
  const { handleGitStatus } = await import('./git-handlers.js');
99
113
  handleGitStatus(ctx, ws, tabId, workingDir);
100
114
  } catch (error: unknown) {
@@ -12,8 +12,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
12
12
  import { homedir } from 'node:os';
13
13
  import { dirname, join } from 'node:path';
14
14
  import type { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
15
+ import type { InstanceRegistry } from '../instances.js';
15
16
  import { captureException } from '../sentry.js';
16
17
  import { getPTYManager } from '../terminal/pty-manager.js';
18
+ import { resolvePendingQuestion } from './ask-user-question-bridge.js';
17
19
  import { AutocompleteService } from './autocomplete.js';
18
20
  import { FileDownloadHandler } from './file-download-handler.js';
19
21
  import { handleFileExplorerMessage, handleFileMessage } from './file-explorer-handlers.js';
@@ -30,7 +32,7 @@ import { generateNotificationSummary, handleGetSettings, handleUpdateSettings }
30
32
  import { handleListSkills } from './skill-handlers.js';
31
33
  import { SkillsWatcher } from './skill-watcher.js';
32
34
  import { TabEventBufferRegistry } from './tab-event-buffer.js';
33
- import { handleCreateTab, handleGetActiveTabs, handleMarkTabViewed, handleRemoveTab, handleReorderTabs, handleSyncTabMeta } from './tab-handlers.js';
35
+ import { handleCreateTab, handleGetActiveTabs, handleMarkTabViewed, handleRemoveTab, handleReorderTabs, handleSetTabEngine, handleSyncTabMeta } from './tab-handlers.js';
34
36
  import { cleanupTerminalSubscribers, handleTerminalMessage } from './terminal-handlers.js';
35
37
  import type { FrecencyData, WebSocketMessage, WebSocketResponse, WSContext } from './types.js';
36
38
 
@@ -55,11 +57,14 @@ export class WebSocketImproviseHandler implements HandlerContext {
55
57
  skillsWatcher: SkillsWatcher | null = null;
56
58
  tabEventBuffers: TabEventBufferRegistry = new TabEventBufferRegistry();
57
59
  msgIdTracker: MsgIdTracker = new MsgIdTracker();
60
+ private instanceRegistry: InstanceRegistry | null;
61
+ private shutdownInProgress = false;
58
62
 
59
- constructor() {
63
+ constructor(instanceRegistry: InstanceRegistry | null = null) {
60
64
  this.frecencyPath = join(homedir(), '.mstro', 'autocomplete-frecency.json');
61
65
  const frecencyData = this.loadFrecencyData();
62
66
  this.autocompleteService = new AutocompleteService(frecencyData);
67
+ this.instanceRegistry = instanceRegistry;
63
68
  process.on('exit', () => {
64
69
  if (this.frecencySaveTimer) {
65
70
  clearTimeout(this.frecencySaveTimer);
@@ -201,6 +206,9 @@ export class WebSocketImproviseHandler implements HandlerContext {
201
206
  return handleRemoveTab(this, ws, tabId, workingDir);
202
207
  case 'markTabViewed':
203
208
  return handleMarkTabViewed(this, ws, tabId, workingDir);
209
+ case 'setTabEngine':
210
+ if (permission === 'view') return;
211
+ return handleSetTabEngine(this, ws, msg, tabId, workingDir);
204
212
  case 'getSettings':
205
213
  return handleGetSettings(this, ws);
206
214
  case 'updateSettings':
@@ -208,6 +216,11 @@ export class WebSocketImproviseHandler implements HandlerContext {
208
216
  return handleUpdateSettings(this, ws, msg);
209
217
  case 'listSkills':
210
218
  return handleListSkills(this, ws, workingDir);
219
+ case 'shutdownInstance':
220
+ return this.handleShutdownInstance(ws, permission);
221
+ case 'askUserQuestionResponse':
222
+ if (permission === 'view') return;
223
+ return this.handleAskUserQuestionResponse(msg, tabId);
211
224
  }
212
225
 
213
226
  // Dispatch table lookup for domain handlers
@@ -382,4 +395,78 @@ export class WebSocketImproviseHandler implements HandlerContext {
382
395
  this.sessions.delete(sessionId);
383
396
  }
384
397
 
398
+ /**
399
+ * Resolve a pending AskUserQuestion call with the user's answers. The
400
+ * bouncer subprocess is awaiting on the bridge promise; calling
401
+ * `resolvePendingQuestion` releases it so Claude resumes with the answers.
402
+ * Stale toolUseIds are no-ops — likely the question already timed out or
403
+ * was cancelled, and a stale web client is replaying its submission.
404
+ */
405
+ private handleAskUserQuestionResponse(msg: WebSocketMessage, tabId: string): void {
406
+ const data = msg.data as { toolUseId?: unknown; answers?: unknown } | undefined;
407
+ const toolUseId = typeof data?.toolUseId === 'string' ? data.toolUseId : '';
408
+ const answersIn = data?.answers;
409
+ if (!toolUseId) return;
410
+
411
+ // Only accept a flat string-string map; coerce safely so a malformed
412
+ // payload doesn't crash the bridge.
413
+ const answers: Record<string, string> = {};
414
+ if (answersIn && typeof answersIn === 'object' && !Array.isArray(answersIn)) {
415
+ for (const [k, v] of Object.entries(answersIn as Record<string, unknown>)) {
416
+ if (typeof v === 'string') answers[k] = v;
417
+ }
418
+ }
419
+
420
+ void tabId; // tabId is informational; the toolUseId is the unique key
421
+ resolvePendingQuestion(toolUseId, answers);
422
+ }
423
+
424
+ /**
425
+ * Handle a `shutdownInstance` control message from a web client.
426
+ *
427
+ * Authorization: only the orchestra owner may shut down. The relay tags
428
+ * shared (view-only) users with `_permission: 'view'`; absence means the
429
+ * requester is the owner whose CLI this is. View-only requests are
430
+ * rejected with a `forbidden` error rather than silently dropped so the
431
+ * UI can surface "you're not the owner" to non-owners.
432
+ *
433
+ * Idempotency: a shutdown already in progress is acked (broadcast +
434
+ * exit timer were already scheduled) but does not stack a second timer.
435
+ */
436
+ private handleShutdownInstance(ws: WSContext, permission: 'view' | undefined): void {
437
+ if (permission === 'view') {
438
+ console.log('[WebSocketImproviseHandler] Rejecting shutdownInstance from view-only user');
439
+ this.send(ws, {
440
+ type: 'error',
441
+ data: {
442
+ code: 'forbidden',
443
+ message: 'Only the owner can shut down this instance.'
444
+ }
445
+ });
446
+ return;
447
+ }
448
+
449
+ if (this.shutdownInProgress) {
450
+ console.log('[WebSocketImproviseHandler] shutdownInstance already in progress — ignoring duplicate request');
451
+ return;
452
+ }
453
+ this.shutdownInProgress = true;
454
+
455
+ // The CLI knows the request came from the owner (the relay only forwards
456
+ // owner traffic without a `_permission` tag), but does not receive the
457
+ // owner's userId on the wire. Logged as 'owner' for the audit trail.
458
+ console.log('[WebSocketImproviseHandler] shutdownInstance requested by owner — broadcasting shuttingDown and exiting');
459
+
460
+ this.broadcastToAll({ type: 'shuttingDown', data: { reason: 'user-requested' } });
461
+
462
+ // Mirrors the HTTP /api/shutdown route's 100ms delay so the broadcast
463
+ // has a chance to flush before process.exit tears down the socket.
464
+ setTimeout(() => {
465
+ if (this.instanceRegistry) {
466
+ this.instanceRegistry.unregister();
467
+ }
468
+ process.exit(0);
469
+ }, 100);
470
+ }
471
+
385
472
  }
@@ -1,7 +1,7 @@
1
1
  // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
2
 
3
3
  import { extname, relative } from 'node:path';
4
- import { chunkFileList, filesByExt, runCommand, type SourceFile } from './quality-tools.js';
4
+ import { chunkFileList, filesByExt, isTestFile, runCommand, type SourceFile } from './quality-tools.js';
5
5
  import { biomeDiagToFinding, type Ecosystem, FUNCTION_LENGTH_THRESHOLD, isBiomeComplexityDiagnostic, isEslintComplexityRule, type QualityFinding } from './quality-types.js';
6
6
 
7
7
  const NODE_COMPLEXITY_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
@@ -16,6 +16,26 @@ interface FunctionInfo {
16
16
  file: string;
17
17
  startLine: number;
18
18
  lines: number;
19
+ /** Approximate cyclomatic complexity (count of decision points). */
20
+ branches: number;
21
+ }
22
+
23
+ /**
24
+ * Decision-point keywords that approximate cyclomatic complexity. We count
25
+ * occurrences as a cheap proxy — McCabe's exact metric requires AST parsing,
26
+ * but the keyword count is highly correlated and good enough to distinguish
27
+ * "long but linear" (a flat sequence of statements) from "long and branchy"
28
+ * (deeply nested control flow).
29
+ *
30
+ * The user's task 2 requirement: "a 1000 line file might be just fine, not
31
+ * a violation at all, while another 1000 line file might be a severe mix of
32
+ * concerns" — same applies to functions. A long config-builder with one
33
+ * return statement is fine; a long monster with 40 if-branches is not.
34
+ */
35
+ const BRANCH_KEYWORDS = /\b(?:if|else if|elif|for|while|case|catch|\?\s*\w|&&|\|\||\?\?)\b/g;
36
+
37
+ function countBranches(body: string): number {
38
+ return (body.match(BRANCH_KEYWORDS) || []).length;
19
39
  }
20
40
 
21
41
  const JS_FUNC_PATTERN = /^(\s*)(export\s+)?(async\s+)?function\s+(\w+)|^(\s*)(export\s+)?(const|let|var)\s+(\w+)\s*=\s*(async\s+)?\(|^(\s*)(public|private|protected)?\s*(async\s+)?(\w+)\s*\(/;
@@ -56,11 +76,15 @@ function extractJsFunctions(file: SourceFile): FunctionInfo[] {
56
76
  braceDepth += countBraceDeltas(lines[i]);
57
77
 
58
78
  if (currentFunc && braceDepth <= funcStartBraceDepth && i > currentFunc.startLine - 1) {
79
+ const startLine = currentFunc.startLine;
80
+ const endLine = i + 1;
81
+ const body = lines.slice(startLine - 1, endLine).join('\n');
59
82
  functions.push({
60
83
  name: currentFunc.name,
61
84
  file: file.relativePath,
62
- startLine: currentFunc.startLine,
63
- lines: i + 1 - currentFunc.startLine + 1,
85
+ startLine,
86
+ lines: endLine - startLine + 1,
87
+ branches: countBranches(body),
64
88
  });
65
89
  currentFunc = null;
66
90
  }
@@ -75,35 +99,29 @@ function extractPyFunctions(file: SourceFile): FunctionInfo[] {
75
99
  const defPattern = /^(\s*)(async\s+)?def\s+(\w+)/;
76
100
  let currentFunc: { name: string; startLine: number; indent: number } | null = null;
77
101
 
102
+ const recordFunction = (name: string, startLine: number, endLine: number) => {
103
+ const body = lines.slice(startLine - 1, endLine).join('\n');
104
+ functions.push({
105
+ name,
106
+ file: file.relativePath,
107
+ startLine,
108
+ lines: endLine - startLine + 1,
109
+ branches: countBranches(body),
110
+ });
111
+ };
112
+
78
113
  for (let i = 0; i < lines.length; i++) {
79
114
  const match = defPattern.exec(lines[i]);
80
115
  if (match) {
81
- if (currentFunc) {
82
- functions.push({
83
- name: currentFunc.name,
84
- file: file.relativePath,
85
- startLine: currentFunc.startLine,
86
- lines: i - currentFunc.startLine + 1,
87
- });
88
- }
116
+ if (currentFunc) recordFunction(currentFunc.name, currentFunc.startLine, i);
89
117
  currentFunc = { name: match[3], startLine: i + 1, indent: match[1].length };
90
118
  } else if (currentFunc && lines[i].trim() && !lines[i].startsWith(' '.repeat(currentFunc.indent + 1)) && !lines[i].startsWith('\t')) {
91
- functions.push({
92
- name: currentFunc.name,
93
- file: file.relativePath,
94
- startLine: currentFunc.startLine,
95
- lines: i - currentFunc.startLine + 1,
96
- });
119
+ recordFunction(currentFunc.name, currentFunc.startLine, i);
97
120
  currentFunc = null;
98
121
  }
99
122
  }
100
123
  if (currentFunc) {
101
- functions.push({
102
- name: currentFunc.name,
103
- file: file.relativePath,
104
- startLine: currentFunc.startLine,
105
- lines: lines.length - currentFunc.startLine + 1,
106
- });
124
+ recordFunction(currentFunc.name, currentFunc.startLine, lines.length);
107
125
  }
108
126
 
109
127
  return functions;
@@ -116,9 +134,37 @@ function extractFunctions(file: SourceFile): FunctionInfo[] {
116
134
  return [];
117
135
  }
118
136
 
137
+ /**
138
+ * Map a function's branch density (decision points per N lines) to a
139
+ * severity level for the function-length finding. Returns `null` to suppress
140
+ * the finding for a long but linear function — e.g., a config-builder with
141
+ * one return statement and 200 lines of property assignments.
142
+ *
143
+ * Heuristic: McCabe's cyclomatic complexity threshold is ~10. Above that,
144
+ * functions are hard to test. We grade severity by branches-per-50-lines so
145
+ * a 100-line function with 5 branches looks the same as a 50-line function
146
+ * with 5 branches (both ~industry "consider refactoring" zone).
147
+ *
148
+ * Functions absurdly long (>5x threshold) emit a finding regardless of
149
+ * branchiness — a 250-line function is too much to read in one sitting even
150
+ * if it's "linear."
151
+ */
152
+ function severityFromBranchiness(branches: number, lines: number): QualityFinding['severity'] | null {
153
+ const branchesPer50 = (branches * 50) / Math.max(1, lines);
154
+ const isAbsurd = lines > FUNCTION_LENGTH_THRESHOLD * 5;
155
+ if (branchesPer50 < 3 && !isAbsurd) return null; // Long but linear — not really a violation.
156
+ if (branchesPer50 < 6) return 'low';
157
+ if (branchesPer50 < 10) return 'medium';
158
+ return 'high';
159
+ }
160
+
119
161
  export function analyzeFunctionLength(files: SourceFile[]): { score: number; findings: QualityFinding[]; issueCount: number } {
120
162
  const allFunctions: FunctionInfo[] = [];
121
163
  for (const file of files) {
164
+ // Test files are exempt: a long `it()`/`describe()` body is normal and
165
+ // splitting it produces churn without improving readability. Linting
166
+ // and other quality checks still apply — only structural-length defers.
167
+ if (isTestFile(file.relativePath)) continue;
122
168
  allFunctions.push(...extractFunctions(file));
123
169
  }
124
170
 
@@ -133,13 +179,21 @@ export function analyzeFunctionLength(files: SourceFile[]): { score: number; fin
133
179
  totalScore += funcScore;
134
180
 
135
181
  if (func.lines > FUNCTION_LENGTH_THRESHOLD) {
182
+ const severity = severityFromBranchiness(func.branches, func.lines);
183
+ if (!severity) continue; // Long but linear — not flagged.
184
+
136
185
  findings.push({
137
- severity: func.lines > FUNCTION_LENGTH_THRESHOLD * 3 ? 'high' : func.lines > FUNCTION_LENGTH_THRESHOLD * 2 ? 'medium' : 'low',
186
+ severity,
138
187
  category: 'function-length',
139
188
  file: func.file,
140
189
  line: func.startLine,
141
- title: `${func.name}() has ${func.lines} lines (threshold: ${FUNCTION_LENGTH_THRESHOLD})`,
142
- description: `Function "${func.name}" exceeds the recommended length by ${func.lines - FUNCTION_LENGTH_THRESHOLD} lines.`,
190
+ title: `${func.name}() has ${func.lines} lines, ~${func.branches} branches`,
191
+ description:
192
+ `Function "${func.name}" exceeds the ${FUNCTION_LENGTH_THRESHOLD}-line threshold by ${func.lines - FUNCTION_LENGTH_THRESHOLD} lines ` +
193
+ `with approximately ${func.branches} decision points (cyclomatic complexity proxy). ` +
194
+ (severity === 'high'
195
+ ? 'High branchiness makes this hard to test and review — extract sub-functions or simplify control flow.'
196
+ : 'Long but with manageable branching — consider extracting helpers if the function does multiple things.'),
143
197
  });
144
198
  }
145
199
  }
@@ -0,0 +1,155 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+
3
+ /**
4
+ * Quality ETA — duration estimation for scan + AI review operations.
5
+ *
6
+ * Uses persisted history when available (most accurate), falls back to a
7
+ * simple heuristic derived from file count + line count when there is no
8
+ * history yet (cold start).
9
+ *
10
+ * Estimates are intentionally conservative: better to over-estimate by a bit
11
+ * and have the bar finish "early" than to under-estimate and have the bar
12
+ * sit at 100% while work continues — the latter destroys trust in the ETA.
13
+ */
14
+
15
+ import type { QualityHistoryEntry } from './quality-persistence.js';
16
+
17
+ // ── Heuristic constants ──────────────────────────────────────
18
+ //
19
+ // Numbers tuned against typical TypeScript codebases (mstro cli/server/web)
20
+ // observed in the wild — see history.json for the corpus. Treat them as
21
+ // reasonable defaults that the history-based path will quickly correct once
22
+ // real durations land in storage.
23
+
24
+ /** Per-file overhead for a CLI scan (linting, formatting, file/function-length checks). */
25
+ const SCAN_PER_FILE_MS = 250;
26
+ /** Per-1000-LOC overhead for a CLI scan, dominated by `tsc --noEmit` on TS projects. */
27
+ const SCAN_PER_KLOC_MS = 800;
28
+ /** Fixed overhead per scan: tool detection, file collection, score computation. */
29
+ const SCAN_FIXED_MS = 8_000;
30
+ /** Floor — the smallest "I'm doing something" we should ever show. */
31
+ const SCAN_MIN_MS = 5_000;
32
+
33
+ /** Per-file overhead for the AI code-review agent (Claude Read calls, validation). */
34
+ const REVIEW_PER_FILE_MS = 1_200;
35
+ /** Per-1000-LOC overhead for the AI code-review agent. */
36
+ const REVIEW_PER_KLOC_MS = 2_500;
37
+ /** Fixed overhead per review: Claude spawn, prompt building, persistence. */
38
+ const REVIEW_FIXED_MS = 25_000;
39
+ /** Floor for the review estimate. */
40
+ const REVIEW_MIN_MS = 30_000;
41
+
42
+ // ── History smoothing ────────────────────────────────────────
43
+ //
44
+ // Use the last few real durations for the same directory and weight them by
45
+ // recency. A single one-off slow run shouldn't dominate the estimate, but
46
+ // fresh data should outweigh stale data (codebase grew, machine got faster,
47
+ // etc.).
48
+ const MAX_HISTORY_SAMPLES = 5;
49
+
50
+ interface HistoryEntryWithTimings extends QualityHistoryEntry {
51
+ scanDurationMs?: number;
52
+ reviewDurationMs?: number;
53
+ }
54
+
55
+ function recentDurations(
56
+ history: HistoryEntryWithTimings[],
57
+ dirPath: string,
58
+ field: 'scanDurationMs' | 'reviewDurationMs',
59
+ ): number[] {
60
+ const values: number[] = [];
61
+ // Walk newest → oldest; only consider entries that actually touched this directory.
62
+ for (let i = history.length - 1; i >= 0 && values.length < MAX_HISTORY_SAMPLES; i--) {
63
+ const entry = history[i];
64
+ if (!entry.directories.some((d) => d.path === dirPath)) continue;
65
+ const v = entry[field];
66
+ if (typeof v === 'number' && v > 0) values.push(v);
67
+ }
68
+ return values;
69
+ }
70
+
71
+ /**
72
+ * Weighted average favouring newer samples. Weights are 1, 2, 3, 4, 5 for the
73
+ * 5 most-recent runs (newest first → highest weight) — a "this run mostly
74
+ * matters" curve that still smooths over a single anomaly.
75
+ */
76
+ function weightedRecentAverage(values: number[]): number | null {
77
+ if (values.length === 0) return null;
78
+ let weightSum = 0;
79
+ let valueSum = 0;
80
+ // values[0] is newest, so its weight is `values.length`.
81
+ for (let i = 0; i < values.length; i++) {
82
+ const w = values.length - i;
83
+ valueSum += values[i] * w;
84
+ weightSum += w;
85
+ }
86
+ return weightSum > 0 ? valueSum / weightSum : null;
87
+ }
88
+
89
+ // ── Public API ────────────────────────────────────────────────
90
+
91
+ export interface CodebaseSize {
92
+ /** Number of source files the scan/review will analyse. */
93
+ files: number;
94
+ /** Total lines across those files. */
95
+ lines: number;
96
+ }
97
+
98
+ /**
99
+ * Heuristic estimate for a CLI scan when we have no history. Combines a fixed
100
+ * floor with per-file + per-KLOC components — `tsc --noEmit` and lint scale
101
+ * with codebase size, so scaling on both files and lines maps reality better
102
+ * than scaling on either alone.
103
+ */
104
+ export function heuristicScanMs({ files, lines }: CodebaseSize): number {
105
+ const kloc = Math.max(0, lines) / 1_000;
106
+ const raw = SCAN_FIXED_MS + files * SCAN_PER_FILE_MS + kloc * SCAN_PER_KLOC_MS;
107
+ return Math.max(SCAN_MIN_MS, Math.round(raw));
108
+ }
109
+
110
+ /** Heuristic estimate for the AI review agent when we have no history. */
111
+ export function heuristicReviewMs({ files, lines }: CodebaseSize): number {
112
+ const kloc = Math.max(0, lines) / 1_000;
113
+ const raw = REVIEW_FIXED_MS + files * REVIEW_PER_FILE_MS + kloc * REVIEW_PER_KLOC_MS;
114
+ return Math.max(REVIEW_MIN_MS, Math.round(raw));
115
+ }
116
+
117
+ /**
118
+ * Best-available ETA for a CLI scan. Prefers a weighted average of recent
119
+ * durations for this exact directory; falls back to the heuristic when no
120
+ * timing history exists.
121
+ */
122
+ export function estimateScanMs(
123
+ size: CodebaseSize,
124
+ history: HistoryEntryWithTimings[],
125
+ dirPath: string,
126
+ ): number {
127
+ const recent = recentDurations(history, dirPath, 'scanDurationMs');
128
+ const fromHistory = weightedRecentAverage(recent);
129
+ if (fromHistory !== null) return Math.max(SCAN_MIN_MS, Math.round(fromHistory));
130
+ return heuristicScanMs(size);
131
+ }
132
+
133
+ /** Best-available ETA for the AI code-review agent. Same fallback shape as `estimateScanMs`. */
134
+ export function estimateReviewMs(
135
+ size: CodebaseSize,
136
+ history: HistoryEntryWithTimings[],
137
+ dirPath: string,
138
+ ): number {
139
+ const recent = recentDurations(history, dirPath, 'reviewDurationMs');
140
+ const fromHistory = weightedRecentAverage(recent);
141
+ if (fromHistory !== null) return Math.max(REVIEW_MIN_MS, Math.round(fromHistory));
142
+ return heuristicReviewMs(size);
143
+ }
144
+
145
+ /**
146
+ * Estimate codebase size from a directory using the same fast traversal the
147
+ * scan itself uses. Pulled out as a thin wrapper so the handler can call it
148
+ * before kicking off the scan to compute an initial ETA.
149
+ */
150
+ export async function estimateCodebaseSize(dirPath: string): Promise<CodebaseSize> {
151
+ const { collectSourceFiles } = await import('./quality-tools.js');
152
+ const files = await collectSourceFiles(dirPath, dirPath);
153
+ const lines = files.reduce((sum, f) => sum + f.lines, 0);
154
+ return { files: files.length, lines };
155
+ }