mstro-app 0.4.52 → 0.5.0

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 (214) hide show
  1. package/README.md +10 -5
  2. package/bin/mstro.js +1 -1
  3. package/dist/server/cli/headless/claude-invoker-stall.d.ts.map +1 -1
  4. package/dist/server/cli/headless/claude-invoker-stall.js +7 -2
  5. package/dist/server/cli/headless/claude-invoker-stall.js.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +1 -1
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  9. package/dist/server/cli/headless/runner.js +63 -67
  10. package/dist/server/cli/headless/runner.js.map +1 -1
  11. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  12. package/dist/server/cli/headless/stall-assessor.js +9 -4
  13. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  14. package/dist/server/cli/improvisation-history-store.d.ts +16 -0
  15. package/dist/server/cli/improvisation-history-store.d.ts.map +1 -0
  16. package/dist/server/cli/improvisation-history-store.js +52 -0
  17. package/dist/server/cli/improvisation-history-store.js.map +1 -0
  18. package/dist/server/cli/improvisation-movements.d.ts +31 -0
  19. package/dist/server/cli/improvisation-movements.d.ts.map +1 -0
  20. package/dist/server/cli/improvisation-movements.js +93 -0
  21. package/dist/server/cli/improvisation-movements.js.map +1 -0
  22. package/dist/server/cli/improvisation-output-queue.d.ts +13 -0
  23. package/dist/server/cli/improvisation-output-queue.d.ts.map +1 -0
  24. package/dist/server/cli/improvisation-output-queue.js +40 -0
  25. package/dist/server/cli/improvisation-output-queue.js.map +1 -0
  26. package/dist/server/cli/improvisation-retry.d.ts +21 -51
  27. package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
  28. package/dist/server/cli/improvisation-retry.js +18 -433
  29. package/dist/server/cli/improvisation-retry.js.map +1 -1
  30. package/dist/server/cli/improvisation-session-manager.d.ts +10 -8
  31. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  32. package/dist/server/cli/improvisation-session-manager.js +53 -148
  33. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  34. package/dist/server/cli/retry/retry-best-result.d.ts +4 -0
  35. package/dist/server/cli/retry/retry-best-result.d.ts.map +1 -0
  36. package/dist/server/cli/retry/retry-best-result.js +61 -0
  37. package/dist/server/cli/retry/retry-best-result.js.map +1 -0
  38. package/dist/server/cli/retry/retry-context-loss.d.ts +6 -0
  39. package/dist/server/cli/retry/retry-context-loss.d.ts.map +1 -0
  40. package/dist/server/cli/retry/retry-context-loss.js +68 -0
  41. package/dist/server/cli/retry/retry-context-loss.js.map +1 -0
  42. package/dist/server/cli/retry/retry-premature-completion.d.ts +5 -0
  43. package/dist/server/cli/retry/retry-premature-completion.d.ts.map +1 -0
  44. package/dist/server/cli/retry/retry-premature-completion.js +81 -0
  45. package/dist/server/cli/retry/retry-premature-completion.js.map +1 -0
  46. package/dist/server/cli/retry/retry-recovery-strategies.d.ts +13 -0
  47. package/dist/server/cli/retry/retry-recovery-strategies.d.ts.map +1 -0
  48. package/dist/server/cli/retry/retry-recovery-strategies.js +166 -0
  49. package/dist/server/cli/retry/retry-recovery-strategies.js.map +1 -0
  50. package/dist/server/cli/retry/retry-resume-strategy.d.ts +12 -0
  51. package/dist/server/cli/retry/retry-resume-strategy.d.ts.map +1 -0
  52. package/dist/server/cli/retry/retry-resume-strategy.js +22 -0
  53. package/dist/server/cli/retry/retry-resume-strategy.js.map +1 -0
  54. package/dist/server/cli/retry/retry-runner-factory.d.ts +11 -0
  55. package/dist/server/cli/retry/retry-runner-factory.d.ts.map +1 -0
  56. package/dist/server/cli/retry/retry-runner-factory.js +60 -0
  57. package/dist/server/cli/retry/retry-runner-factory.js.map +1 -0
  58. package/dist/server/cli/retry/retry-tool-results.d.ts +9 -0
  59. package/dist/server/cli/retry/retry-tool-results.d.ts.map +1 -0
  60. package/dist/server/cli/retry/retry-tool-results.js +24 -0
  61. package/dist/server/cli/retry/retry-tool-results.js.map +1 -0
  62. package/dist/server/cli/retry/retry-types.d.ts +30 -0
  63. package/dist/server/cli/retry/retry-types.d.ts.map +1 -0
  64. package/dist/server/cli/retry/retry-types.js +4 -0
  65. package/dist/server/cli/retry/retry-types.js.map +1 -0
  66. package/dist/server/index.js +21 -109
  67. package/dist/server/index.js.map +1 -1
  68. package/dist/server/server-setup.d.ts +16 -1
  69. package/dist/server/server-setup.d.ts.map +1 -1
  70. package/dist/server/server-setup.js +107 -0
  71. package/dist/server/server-setup.js.map +1 -1
  72. package/dist/server/services/plan/board-config.d.ts +21 -0
  73. package/dist/server/services/plan/board-config.d.ts.map +1 -0
  74. package/dist/server/services/plan/board-config.js +112 -0
  75. package/dist/server/services/plan/board-config.js.map +1 -0
  76. package/dist/server/services/plan/composer.d.ts +1 -1
  77. package/dist/server/services/plan/composer.d.ts.map +1 -1
  78. package/dist/server/services/plan/composer.js +7 -5
  79. package/dist/server/services/plan/composer.js.map +1 -1
  80. package/dist/server/services/plan/executor.d.ts +48 -48
  81. package/dist/server/services/plan/executor.d.ts.map +1 -1
  82. package/dist/server/services/plan/executor.js +157 -455
  83. package/dist/server/services/plan/executor.js.map +1 -1
  84. package/dist/server/services/plan/issue-loader.d.ts +16 -0
  85. package/dist/server/services/plan/issue-loader.d.ts.map +1 -0
  86. package/dist/server/services/plan/issue-loader.js +46 -0
  87. package/dist/server/services/plan/issue-loader.js.map +1 -0
  88. package/dist/server/services/plan/issue-writer.d.ts +34 -0
  89. package/dist/server/services/plan/issue-writer.d.ts.map +1 -0
  90. package/dist/server/services/plan/issue-writer.js +110 -0
  91. package/dist/server/services/plan/issue-writer.js.map +1 -0
  92. package/dist/server/services/plan/output-manager.d.ts.map +1 -1
  93. package/dist/server/services/plan/output-manager.js +2 -1
  94. package/dist/server/services/plan/output-manager.js.map +1 -1
  95. package/dist/server/services/plan/progress-log.d.ts +11 -0
  96. package/dist/server/services/plan/progress-log.d.ts.map +1 -0
  97. package/dist/server/services/plan/progress-log.js +81 -0
  98. package/dist/server/services/plan/progress-log.js.map +1 -0
  99. package/dist/server/services/plan/prompt-builder.d.ts.map +1 -1
  100. package/dist/server/services/plan/prompt-builder.js +48 -31
  101. package/dist/server/services/plan/prompt-builder.js.map +1 -1
  102. package/dist/server/services/plan/readiness-planner.d.ts +15 -0
  103. package/dist/server/services/plan/readiness-planner.d.ts.map +1 -0
  104. package/dist/server/services/plan/readiness-planner.js +41 -0
  105. package/dist/server/services/plan/readiness-planner.js.map +1 -0
  106. package/dist/server/services/plan/review-gate.d.ts +31 -0
  107. package/dist/server/services/plan/review-gate.d.ts.map +1 -1
  108. package/dist/server/services/plan/review-gate.js +52 -2
  109. package/dist/server/services/plan/review-gate.js.map +1 -1
  110. package/dist/server/services/platform.d.ts +56 -0
  111. package/dist/server/services/platform.d.ts.map +1 -1
  112. package/dist/server/services/platform.js +154 -52
  113. package/dist/server/services/platform.js.map +1 -1
  114. package/dist/server/services/websocket/file-download-handler.d.ts +17 -0
  115. package/dist/server/services/websocket/file-download-handler.d.ts.map +1 -0
  116. package/dist/server/services/websocket/file-download-handler.js +165 -0
  117. package/dist/server/services/websocket/file-download-handler.js.map +1 -0
  118. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -1
  119. package/dist/server/services/websocket/git-worktree-handlers.js +28 -2
  120. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
  121. package/dist/server/services/websocket/handler-context.d.ts +15 -0
  122. package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
  123. package/dist/server/services/websocket/handler.d.ts +7 -0
  124. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  125. package/dist/server/services/websocket/handler.js +73 -11
  126. package/dist/server/services/websocket/handler.js.map +1 -1
  127. package/dist/server/services/websocket/msg-id-tracker.d.ts +21 -0
  128. package/dist/server/services/websocket/msg-id-tracker.d.ts.map +1 -0
  129. package/dist/server/services/websocket/msg-id-tracker.js +77 -0
  130. package/dist/server/services/websocket/msg-id-tracker.js.map +1 -0
  131. package/dist/server/services/websocket/quality-handlers.js +15 -3
  132. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  133. package/dist/server/services/websocket/quality-review-agent.js +2 -2
  134. package/dist/server/services/websocket/session-handlers.d.ts +48 -2
  135. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  136. package/dist/server/services/websocket/session-handlers.js +204 -65
  137. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  138. package/dist/server/services/websocket/session-initialization.d.ts +2 -2
  139. package/dist/server/services/websocket/session-initialization.d.ts.map +1 -1
  140. package/dist/server/services/websocket/session-initialization.js +75 -17
  141. package/dist/server/services/websocket/session-initialization.js.map +1 -1
  142. package/dist/server/services/websocket/session-registry.d.ts +29 -1
  143. package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
  144. package/dist/server/services/websocket/session-registry.js +53 -4
  145. package/dist/server/services/websocket/session-registry.js.map +1 -1
  146. package/dist/server/services/websocket/tab-broadcast.d.ts +24 -0
  147. package/dist/server/services/websocket/tab-broadcast.d.ts.map +1 -0
  148. package/dist/server/services/websocket/tab-broadcast.js +13 -0
  149. package/dist/server/services/websocket/tab-broadcast.js.map +1 -0
  150. package/dist/server/services/websocket/tab-event-buffer.d.ts +103 -0
  151. package/dist/server/services/websocket/tab-event-buffer.d.ts.map +1 -0
  152. package/dist/server/services/websocket/tab-event-buffer.js +107 -0
  153. package/dist/server/services/websocket/tab-event-buffer.js.map +1 -0
  154. package/dist/server/services/websocket/tab-event-replay.d.ts +20 -0
  155. package/dist/server/services/websocket/tab-event-replay.d.ts.map +1 -0
  156. package/dist/server/services/websocket/tab-event-replay.js +21 -0
  157. package/dist/server/services/websocket/tab-event-replay.js.map +1 -0
  158. package/dist/server/services/websocket/tab-handlers.d.ts +0 -1
  159. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
  160. package/dist/server/services/websocket/tab-handlers.js +2 -9
  161. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  162. package/dist/server/services/websocket/types.d.ts +15 -6
  163. package/dist/server/services/websocket/types.d.ts.map +1 -1
  164. package/dist/server/services/websocket/types.js +6 -4
  165. package/dist/server/services/websocket/types.js.map +1 -1
  166. package/package.json +1 -1
  167. package/server/README.md +1 -1
  168. package/server/cli/headless/claude-invoker-stall.ts +7 -2
  169. package/server/cli/headless/claude-invoker.ts +1 -1
  170. package/server/cli/headless/runner.ts +67 -72
  171. package/server/cli/headless/stall-assessor.ts +9 -4
  172. package/server/cli/headless/types.ts +1 -1
  173. package/server/cli/improvisation-history-store.ts +62 -0
  174. package/server/cli/improvisation-movements.ts +120 -0
  175. package/server/cli/improvisation-output-queue.ts +42 -0
  176. package/server/cli/improvisation-retry.ts +25 -600
  177. package/server/cli/improvisation-session-manager.ts +74 -160
  178. package/server/cli/retry/retry-best-result.ts +70 -0
  179. package/server/cli/retry/retry-context-loss.ts +87 -0
  180. package/server/cli/retry/retry-premature-completion.ts +113 -0
  181. package/server/cli/retry/retry-recovery-strategies.ts +247 -0
  182. package/server/cli/retry/retry-resume-strategy.ts +33 -0
  183. package/server/cli/retry/retry-runner-factory.ts +70 -0
  184. package/server/cli/retry/retry-tool-results.ts +31 -0
  185. package/server/cli/retry/retry-types.ts +32 -0
  186. package/server/index.ts +37 -123
  187. package/server/server-setup.ts +126 -1
  188. package/server/services/plan/agents/assess-stall.md +11 -4
  189. package/server/services/plan/board-config.ts +122 -0
  190. package/server/services/plan/composer.ts +7 -5
  191. package/server/services/plan/executor.ts +214 -467
  192. package/server/services/plan/issue-loader.ts +64 -0
  193. package/server/services/plan/issue-writer.ts +137 -0
  194. package/server/services/plan/output-manager.ts +2 -1
  195. package/server/services/plan/progress-log.ts +92 -0
  196. package/server/services/plan/prompt-builder.ts +73 -35
  197. package/server/services/plan/readiness-planner.ts +50 -0
  198. package/server/services/plan/review-gate.ts +102 -2
  199. package/server/services/platform.ts +163 -58
  200. package/server/services/websocket/file-download-handler.ts +191 -0
  201. package/server/services/websocket/git-worktree-handlers.ts +29 -2
  202. package/server/services/websocket/handler-context.ts +15 -0
  203. package/server/services/websocket/handler.ts +76 -12
  204. package/server/services/websocket/msg-id-tracker.ts +84 -0
  205. package/server/services/websocket/quality-handlers.ts +16 -3
  206. package/server/services/websocket/quality-review-agent.ts +2 -2
  207. package/server/services/websocket/session-handlers.ts +213 -68
  208. package/server/services/websocket/session-initialization.ts +83 -19
  209. package/server/services/websocket/session-registry.ts +61 -4
  210. package/server/services/websocket/tab-broadcast.ts +38 -0
  211. package/server/services/websocket/tab-event-buffer.ts +159 -0
  212. package/server/services/websocket/tab-event-replay.ts +42 -0
  213. package/server/services/websocket/tab-handlers.ts +2 -9
  214. package/server/services/websocket/types.ts +17 -4
@@ -14,20 +14,24 @@ import { homedir } from 'node:os';
14
14
  import { dirname, join } from 'node:path';
15
15
  import type { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
16
16
  import { captureException } from '../sentry.js';
17
+ import { getPTYManager } from '../terminal/pty-manager.js';
17
18
  import { AutocompleteService } from './autocomplete.js';
19
+ import { FileDownloadHandler } from './file-download-handler.js';
18
20
  import { handleFileExplorerMessage, handleFileMessage } from './file-explorer-handlers.js';
19
21
  import { FileUploadHandler } from './file-upload-handler.js';
20
22
  import { handleGitMessage } from './git-handlers.js';
21
23
  import { GitHeadWatcher } from './git-head-watcher.js';
22
24
  import type { HandlerContext, UsageReporter } from './handler-context.js';
25
+ import { MsgIdTracker } from './msg-id-tracker.js';
23
26
  import { handlePlanMessage } from './plan-handlers.js';
24
27
  import { handleQualityMessage } from './quality-handlers.js';
25
- import { handleHistoryMessage, handleSessionMessage, initializeTab, resumeHistoricalSession } from './session-handlers.js';
28
+ import { handleHistoryMessage, handleSessionMessage, initializeTab, restoreWorktreeFromRegistry, resumeHistoricalSession } from './session-handlers.js';
26
29
  import { SessionRegistry } from './session-registry.js';
27
30
  import { generateNotificationSummary, handleGetSettings, handleUpdateSettings } from './settings-handlers.js';
28
31
  import { handleListSkills } from './skill-handlers.js';
29
32
  import { SkillsWatcher } from './skill-watcher.js';
30
- import { handleCreateTab, handleGetActiveTabs, handleMarkTabViewed, handleRemoveTab, handleReorderTabs, handleSyncPromptText, handleSyncTabMeta } from './tab-handlers.js';
33
+ import { TabEventBufferRegistry } from './tab-event-buffer.js';
34
+ import { handleCreateTab, handleGetActiveTabs, handleMarkTabViewed, handleRemoveTab, handleReorderTabs, handleSyncTabMeta } from './tab-handlers.js';
31
35
  import { cleanupTerminalSubscribers, handleTerminalMessage } from './terminal-handlers.js';
32
36
  import type { FrecencyData, WebSocketMessage, WebSocketResponse, WSContext } from './types.js';
33
37
 
@@ -47,8 +51,11 @@ export class WebSocketImproviseHandler implements HandlerContext {
47
51
  terminalListenerCleanups: Map<string, () => void> = new Map();
48
52
  terminalSubscribers: Map<string, Set<WSContext>> = new Map();
49
53
  fileUploadHandler: FileUploadHandler | null = null;
54
+ fileDownloadHandler: FileDownloadHandler | null = null;
50
55
  gitHeadWatcher: GitHeadWatcher | null = null;
51
56
  skillsWatcher: SkillsWatcher | null = null;
57
+ tabEventBuffers: TabEventBufferRegistry = new TabEventBufferRegistry();
58
+ msgIdTracker: MsgIdTracker = new MsgIdTracker();
52
59
 
53
60
  constructor() {
54
61
  this.frecencyPath = join(homedir(), '.mstro', 'autocomplete-frecency.json');
@@ -146,7 +153,7 @@ export class WebSocketImproviseHandler implements HandlerContext {
146
153
  }
147
154
 
148
155
  /** Dispatch table mapping message types to domain handlers. Built once, looked up per message. */
149
- private static readonly DISPATCH: Record<string, 'session' | 'history' | 'file' | 'terminal' | 'fileExplorer' | 'git' | 'quality' | 'plan' | 'fileUpload'> = {
156
+ private static readonly DISPATCH: Record<string, 'session' | 'history' | 'file' | 'terminal' | 'fileExplorer' | 'git' | 'quality' | 'plan' | 'fileUpload' | 'fileDownload'> = {
150
157
  // Session
151
158
  execute: 'session', cancel: 'session', getHistory: 'session', new: 'session', approve: 'session', reject: 'session',
152
159
  // History
@@ -165,6 +172,8 @@ export class WebSocketImproviseHandler implements HandlerContext {
165
172
  planInit: 'plan', planGetState: 'plan', planListIssues: 'plan', planGetIssue: 'plan', planGetSprint: 'plan', planGetMilestone: 'plan', planCreateIssue: 'plan', planUpdateIssue: 'plan', planDeleteIssue: 'plan', planScaffold: 'plan', planPrompt: 'plan', planExecute: 'plan', planExecuteEpic: 'plan', planPause: 'plan', planStop: 'plan', planResume: 'plan', planCreateBoard: 'plan', planUpdateBoard: 'plan', planArchiveBoard: 'plan', planGetBoard: 'plan', planGetBoardState: 'plan', planReorderBoards: 'plan', planSetActiveBoard: 'plan', planGetBoardArtifacts: 'plan', planCreateSprint: 'plan', planActivateSprint: 'plan', planCompleteSprint: 'plan', planGetSprintArtifacts: 'plan', chatToBoard: 'plan',
166
173
  // File upload
167
174
  fileUploadStart: 'fileUpload', fileUploadChunk: 'fileUpload', fileUploadComplete: 'fileUpload', fileUploadCancel: 'fileUpload',
175
+ // File download (chunked streaming for large binaries)
176
+ fileDownloadStart: 'fileDownload', fileDownloadCancel: 'fileDownload',
168
177
  };
169
178
 
170
179
  private async dispatchMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'view'): Promise<void> {
@@ -174,10 +183,10 @@ export class WebSocketImproviseHandler implements HandlerContext {
174
183
  this.send(ws, { type: 'pong', tabId });
175
184
  return;
176
185
  case 'initTab':
177
- return void await initializeTab(this, ws, tabId, workingDir, msg.data?.tabName);
186
+ return void await initializeTab(this, ws, tabId, workingDir, msg.data?.tabName, msg.data);
178
187
  case 'resumeSession':
179
188
  if (!msg.data?.historicalSessionId) throw new Error('Historical session ID is required');
180
- return void await resumeHistoricalSession(this, ws, tabId, workingDir, msg.data.historicalSessionId);
189
+ return void await resumeHistoricalSession(this, ws, tabId, workingDir, msg.data.historicalSessionId, msg.data);
181
190
  case 'requestNotificationSummary':
182
191
  if (!msg.data?.prompt || !msg.data?.output) throw new Error('Prompt and output are required for notification summary');
183
192
  return void await generateNotificationSummary(this, ws, tabId, msg.data.prompt, msg.data.output, workingDir);
@@ -189,8 +198,6 @@ export class WebSocketImproviseHandler implements HandlerContext {
189
198
  return handleReorderTabs(this, ws, workingDir, msg.data?.tabOrder);
190
199
  case 'syncTabMeta':
191
200
  return handleSyncTabMeta(this, ws, msg, tabId, workingDir);
192
- case 'syncPromptText':
193
- return handleSyncPromptText(this, ws, msg, tabId);
194
201
  case 'removeTab':
195
202
  return handleRemoveTab(this, ws, tabId, workingDir);
196
203
  case 'markTabViewed':
@@ -208,6 +215,14 @@ export class WebSocketImproviseHandler implements HandlerContext {
208
215
  const domain = WebSocketImproviseHandler.DISPATCH[msg.type];
209
216
  if (!domain) throw new Error(`Unknown message type: ${msg.type}`);
210
217
 
218
+ // Hydrate worktree state from the registry before any domain handler
219
+ // reads it, so git/file/autocomplete ops route to the tab's worktree
220
+ // even when they arrive before the initTab handshake completes. The
221
+ // registry is authoritative; the in-memory Map is just a cache.
222
+ if (msg.tabId && !this.gitDirectories.has(tabId)) {
223
+ restoreWorktreeFromRegistry(this, this.getRegistry(workingDir), tabId);
224
+ }
225
+
211
226
  // Resolve effective working directory: use worktree path if tab is on a worktree
212
227
  const effectiveDir = this.gitDirectories.get(tabId) || workingDir;
213
228
 
@@ -221,6 +236,24 @@ export class WebSocketImproviseHandler implements HandlerContext {
221
236
  case 'quality': return handleQualityMessage(this, ws, msg, tabId, workingDir, permission);
222
237
  case 'plan': return handlePlanMessage(this, ws, msg, tabId, workingDir, permission);
223
238
  case 'fileUpload': return this.handleFileUploadMessage(ws, msg, tabId, workingDir, permission);
239
+ case 'fileDownload': return this.handleFileDownloadMessage(ws, msg, tabId, effectiveDir);
240
+ }
241
+ }
242
+
243
+ private handleFileDownloadMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
244
+ if (!this.fileDownloadHandler) {
245
+ this.fileDownloadHandler = new FileDownloadHandler(workingDir);
246
+ }
247
+ const handler = this.fileDownloadHandler;
248
+ const send = this.send.bind(this);
249
+
250
+ switch (msg.type) {
251
+ case 'fileDownloadStart':
252
+ handler.handleDownloadStart(ws, send, tabId, msg.data);
253
+ break;
254
+ case 'fileDownloadCancel':
255
+ handler.handleDownloadCancel(ws, send, tabId, msg.data);
256
+ break;
224
257
  }
225
258
  }
226
259
 
@@ -262,6 +295,10 @@ export class WebSocketImproviseHandler implements HandlerContext {
262
295
  this.fileUploadHandler.destroy();
263
296
  this.fileUploadHandler = null;
264
297
  }
298
+ if (this.fileDownloadHandler) {
299
+ this.fileDownloadHandler.destroy();
300
+ this.fileDownloadHandler = null;
301
+ }
265
302
  if (this.gitHeadWatcher) {
266
303
  this.gitHeadWatcher.stop();
267
304
  this.gitHeadWatcher = null;
@@ -270,18 +307,45 @@ export class WebSocketImproviseHandler implements HandlerContext {
270
307
  this.skillsWatcher.stop();
271
308
  this.skillsWatcher = null;
272
309
  }
310
+
311
+ // Close orphan PTYs when no web client is watching any more.
312
+ //
313
+ // Prior behavior: PTYs survived until the user typed `exit` or the
314
+ // CLI process died. A page refresh / browser tab close / view-switch
315
+ // would leak the pty, and after a few of these the user would have
316
+ // tens of zombie shells competing for I/O bandwidth, which manifests
317
+ // as the app feeling "unresponsive" — interactive operations starve
318
+ // because the relay socket is saturated streaming output for ptys
319
+ // that no UI is rendering.
320
+ //
321
+ // The active-session preservation in `cleanupConnectionResources`
322
+ // above is intentionally separate: improvise sessions can produce
323
+ // useful work while the browser is closed (Claude Code keeps running
324
+ // in the background); a shell process can't, by construction. So we
325
+ // preserve the former and reap the latter.
326
+ const ptyManager = getPTYManager();
327
+ const activeTerminals = ptyManager.getActiveTerminals();
328
+ if (activeTerminals.length > 0) {
329
+ console.log(`[handler] No web subscribers — closing ${activeTerminals.length} orphan PTY${activeTerminals.length === 1 ? '' : 's'}`);
330
+ ptyManager.closeAll();
331
+ }
273
332
  }
274
333
  }
275
334
 
276
335
  private cleanupConnectionResources(tabMap: Map<string, string>): void {
277
- // Destroy sessions owned by this connection
336
+ // Preserve actively-executing sessions across web reconnects. The runner
337
+ // is still producing output, and the new web connection will re-attach
338
+ // via session-handlers.ts::getSession (or initializeTab → reattachSession)
339
+ // which rebinds listeners and replays executionEventLog. Destroying the
340
+ // session here would orphan the runner and silently drop all streamed
341
+ // output for the rest of the prompt.
278
342
  const sessionIds = new Set(tabMap.values());
279
343
  for (const sessionId of sessionIds) {
280
344
  const session = this.sessions.get(sessionId);
281
- if (session) {
282
- session.destroy();
283
- this.sessions.delete(sessionId);
284
- }
345
+ if (!session) continue;
346
+ if (session.isExecuting) continue;
347
+ session.destroy();
348
+ this.sessions.delete(sessionId);
285
349
  }
286
350
  // Kill search processes owned by this connection's tabs
287
351
  for (const tabId of tabMap.keys()) {
@@ -0,0 +1,84 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Idempotency tracker for client-sent message IDs.
6
+ *
7
+ * The web client assigns a UUID `msgId` to every `execute` message and
8
+ * stores it in a persistent outbox until an `executeAck` arrives. If the
9
+ * web reconnects before the ack lands it replays the same `msgId`; without
10
+ * dedupe the CLI would run the prompt twice (burning tokens, confusing
11
+ * the user). This tracker gives handlers an atomic
12
+ * "first time we've seen this msgId?" check with a bounded TTL so old
13
+ * ids don't leak forever.
14
+ *
15
+ * Design notes:
16
+ * - TTL (not LRU) — web promises to stop replaying once it gets an ack
17
+ * or times out the outbox, so a fixed window covers the realistic
18
+ * replay horizon. 15 minutes matches `tab-event-buffer.ts`.
19
+ * - Per-tab partitioning so a tab removal can cheaply clear that tab's
20
+ * history without scanning the whole map.
21
+ * - Lazy eviction on every check — no timers, cheap in the hot path.
22
+ */
23
+
24
+ const DEFAULT_TTL_MS = 15 * 60 * 1000;
25
+ const DEFAULT_MAX_PER_TAB = 512;
26
+
27
+ interface TabEntry {
28
+ ids: Map<string, number>;
29
+ }
30
+
31
+ export class MsgIdTracker {
32
+ private readonly tabs = new Map<string, TabEntry>();
33
+
34
+ constructor(
35
+ private readonly ttlMs: number = DEFAULT_TTL_MS,
36
+ private readonly maxPerTab: number = DEFAULT_MAX_PER_TAB,
37
+ private readonly now: () => number = Date.now,
38
+ ) {}
39
+
40
+ /**
41
+ * Returns `true` if `msgId` is being recorded for the first time on
42
+ * `tabId`, `false` if it was already seen inside the TTL window. Callers
43
+ * should skip re-execution when this returns `false` but still re-ack
44
+ * so the client's outbox drains.
45
+ */
46
+ recordIfFirst(tabId: string, msgId: string): boolean {
47
+ const entry = this.tabs.get(tabId) ?? { ids: new Map<string, number>() };
48
+ this.evictExpired(entry);
49
+ if (entry.ids.has(msgId)) {
50
+ // Refresh the timestamp so a long stall still counts as "seen".
51
+ entry.ids.set(msgId, this.now());
52
+ return false;
53
+ }
54
+ entry.ids.set(msgId, this.now());
55
+ this.enforceSizeCap(entry);
56
+ this.tabs.set(tabId, entry);
57
+ return true;
58
+ }
59
+
60
+ /** Drop all msgIds for a removed tab. */
61
+ forget(tabId: string): void {
62
+ this.tabs.delete(tabId);
63
+ }
64
+
65
+ /** Test helper — visible for unit tests only. */
66
+ size(tabId: string): number {
67
+ return this.tabs.get(tabId)?.ids.size ?? 0;
68
+ }
69
+
70
+ private evictExpired(entry: TabEntry): void {
71
+ const cutoff = this.now() - this.ttlMs;
72
+ for (const [id, ts] of entry.ids) {
73
+ if (ts < cutoff) entry.ids.delete(id);
74
+ }
75
+ }
76
+
77
+ private enforceSizeCap(entry: TabEntry): void {
78
+ while (entry.ids.size > this.maxPerTab) {
79
+ const firstKey = entry.ids.keys().next().value;
80
+ if (firstKey === undefined) break;
81
+ entry.ids.delete(firstKey);
82
+ }
83
+ }
84
+ }
@@ -173,6 +173,15 @@ async function handleSaveDirectories(
173
173
  }
174
174
 
175
175
  persistence.saveConfig(directories);
176
+
177
+ // Broadcast the updated set so every paired web (on any Fly instance)
178
+ // reflects the change. This is how Quality subdirectory tabs stay in
179
+ // sync across devices — the type is listed in the cross-instance
180
+ // set in `server/src/relay/handlers/clientHandlers.ts`.
181
+ ctx.broadcastToAll({
182
+ type: 'qualityDirectoriesUpdated',
183
+ data: { directories },
184
+ });
176
185
  } catch (error) {
177
186
  ctx.send(ws, {
178
187
  type: 'qualityError',
@@ -245,9 +254,11 @@ async function handleScan(
245
254
 
246
255
  const resultData = { path: reportPath, results };
247
256
  try {
248
- ctx.send(ws, { type: 'qualityScanResults', data: resultData });
257
+ // Broadcast so every device sees the new scan — the Quality view on
258
+ // another device otherwise stays stuck on the previous scan results.
259
+ ctx.broadcastToAll({ type: 'qualityScanResults', data: resultData });
249
260
  } catch {
250
- // WebSocket closed — save as pending for delivery on reconnect
261
+ // Broadcast failed — save as pending for delivery on reconnect
251
262
  persistence.addPendingResult({
252
263
  type: 'scanResults',
253
264
  path: reportPath,
@@ -288,7 +299,9 @@ async function handleInstallTools(
288
299
 
289
300
  const { tools, ecosystem } = await installTools(dirPath, toolNames);
290
301
 
291
- ctx.send(ws, {
302
+ // Broadcast so every device sees the install result (status of tools
303
+ // changes orchestra-wide once installed on the machine).
304
+ ctx.broadcastToAll({
292
305
  type: 'qualityInstallComplete',
293
306
  data: { path: reportPath, tools, ecosystem },
294
307
  });
@@ -428,7 +428,7 @@ async function runVerificationPass(
428
428
  policy: 'STANDARD',
429
429
  stallWarningMs: 300_000,
430
430
  stallKillMs: 900_000,
431
- stallHardCapMs: 1_200_000,
431
+ stallHardCapMs: 3_600_000,
432
432
  toolUseCallback: makeToolCallback(send, 'Verifying: '),
433
433
  logLabel: 'code-review-verify',
434
434
  });
@@ -505,7 +505,7 @@ export async function handleCodeReview(
505
505
  policy: 'STANDARD',
506
506
  stallWarningMs: 300_000,
507
507
  stallKillMs: 1_200_000,
508
- stallHardCapMs: 1_800_000,
508
+ stallHardCapMs: 7_200_000,
509
509
  toolUseCallback: makeToolCallback(send),
510
510
  logLabel: 'code-review',
511
511
  });