mstro-app 0.4.51 → 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 (223) 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-branch-handlers.d.ts +1 -1
  119. package/dist/server/services/websocket/git-branch-handlers.d.ts.map +1 -1
  120. package/dist/server/services/websocket/git-branch-handlers.js +21 -1
  121. package/dist/server/services/websocket/git-branch-handlers.js.map +1 -1
  122. package/dist/server/services/websocket/git-handlers.js +1 -1
  123. package/dist/server/services/websocket/git-handlers.js.map +1 -1
  124. package/dist/server/services/websocket/git-worktree-handlers.d.ts +2 -0
  125. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -1
  126. package/dist/server/services/websocket/git-worktree-handlers.js +30 -4
  127. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
  128. package/dist/server/services/websocket/handler-context.d.ts +15 -0
  129. package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
  130. package/dist/server/services/websocket/handler.d.ts +7 -0
  131. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  132. package/dist/server/services/websocket/handler.js +73 -11
  133. package/dist/server/services/websocket/handler.js.map +1 -1
  134. package/dist/server/services/websocket/msg-id-tracker.d.ts +21 -0
  135. package/dist/server/services/websocket/msg-id-tracker.d.ts.map +1 -0
  136. package/dist/server/services/websocket/msg-id-tracker.js +77 -0
  137. package/dist/server/services/websocket/msg-id-tracker.js.map +1 -0
  138. package/dist/server/services/websocket/quality-handlers.js +15 -3
  139. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  140. package/dist/server/services/websocket/quality-review-agent.js +2 -2
  141. package/dist/server/services/websocket/session-handlers.d.ts +48 -2
  142. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  143. package/dist/server/services/websocket/session-handlers.js +204 -65
  144. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  145. package/dist/server/services/websocket/session-initialization.d.ts +2 -2
  146. package/dist/server/services/websocket/session-initialization.d.ts.map +1 -1
  147. package/dist/server/services/websocket/session-initialization.js +75 -17
  148. package/dist/server/services/websocket/session-initialization.js.map +1 -1
  149. package/dist/server/services/websocket/session-registry.d.ts +29 -1
  150. package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
  151. package/dist/server/services/websocket/session-registry.js +53 -4
  152. package/dist/server/services/websocket/session-registry.js.map +1 -1
  153. package/dist/server/services/websocket/tab-broadcast.d.ts +24 -0
  154. package/dist/server/services/websocket/tab-broadcast.d.ts.map +1 -0
  155. package/dist/server/services/websocket/tab-broadcast.js +13 -0
  156. package/dist/server/services/websocket/tab-broadcast.js.map +1 -0
  157. package/dist/server/services/websocket/tab-event-buffer.d.ts +103 -0
  158. package/dist/server/services/websocket/tab-event-buffer.d.ts.map +1 -0
  159. package/dist/server/services/websocket/tab-event-buffer.js +107 -0
  160. package/dist/server/services/websocket/tab-event-buffer.js.map +1 -0
  161. package/dist/server/services/websocket/tab-event-replay.d.ts +20 -0
  162. package/dist/server/services/websocket/tab-event-replay.d.ts.map +1 -0
  163. package/dist/server/services/websocket/tab-event-replay.js +21 -0
  164. package/dist/server/services/websocket/tab-event-replay.js.map +1 -0
  165. package/dist/server/services/websocket/tab-handlers.d.ts +0 -1
  166. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
  167. package/dist/server/services/websocket/tab-handlers.js +2 -9
  168. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  169. package/dist/server/services/websocket/types.d.ts +15 -6
  170. package/dist/server/services/websocket/types.d.ts.map +1 -1
  171. package/dist/server/services/websocket/types.js +6 -4
  172. package/dist/server/services/websocket/types.js.map +1 -1
  173. package/package.json +1 -1
  174. package/server/README.md +1 -1
  175. package/server/cli/headless/claude-invoker-stall.ts +7 -2
  176. package/server/cli/headless/claude-invoker.ts +1 -1
  177. package/server/cli/headless/runner.ts +67 -72
  178. package/server/cli/headless/stall-assessor.ts +9 -4
  179. package/server/cli/headless/types.ts +1 -1
  180. package/server/cli/improvisation-history-store.ts +62 -0
  181. package/server/cli/improvisation-movements.ts +120 -0
  182. package/server/cli/improvisation-output-queue.ts +42 -0
  183. package/server/cli/improvisation-retry.ts +25 -600
  184. package/server/cli/improvisation-session-manager.ts +74 -160
  185. package/server/cli/retry/retry-best-result.ts +70 -0
  186. package/server/cli/retry/retry-context-loss.ts +87 -0
  187. package/server/cli/retry/retry-premature-completion.ts +113 -0
  188. package/server/cli/retry/retry-recovery-strategies.ts +247 -0
  189. package/server/cli/retry/retry-resume-strategy.ts +33 -0
  190. package/server/cli/retry/retry-runner-factory.ts +70 -0
  191. package/server/cli/retry/retry-tool-results.ts +31 -0
  192. package/server/cli/retry/retry-types.ts +32 -0
  193. package/server/index.ts +37 -123
  194. package/server/server-setup.ts +126 -1
  195. package/server/services/plan/agents/assess-stall.md +11 -4
  196. package/server/services/plan/board-config.ts +122 -0
  197. package/server/services/plan/composer.ts +7 -5
  198. package/server/services/plan/executor.ts +214 -467
  199. package/server/services/plan/issue-loader.ts +64 -0
  200. package/server/services/plan/issue-writer.ts +137 -0
  201. package/server/services/plan/output-manager.ts +2 -1
  202. package/server/services/plan/progress-log.ts +92 -0
  203. package/server/services/plan/prompt-builder.ts +73 -35
  204. package/server/services/plan/readiness-planner.ts +50 -0
  205. package/server/services/plan/review-gate.ts +102 -2
  206. package/server/services/platform.ts +163 -58
  207. package/server/services/websocket/file-download-handler.ts +191 -0
  208. package/server/services/websocket/git-branch-handlers.ts +28 -1
  209. package/server/services/websocket/git-handlers.ts +1 -1
  210. package/server/services/websocket/git-worktree-handlers.ts +31 -4
  211. package/server/services/websocket/handler-context.ts +15 -0
  212. package/server/services/websocket/handler.ts +76 -12
  213. package/server/services/websocket/msg-id-tracker.ts +84 -0
  214. package/server/services/websocket/quality-handlers.ts +16 -3
  215. package/server/services/websocket/quality-review-agent.ts +2 -2
  216. package/server/services/websocket/session-handlers.ts +213 -68
  217. package/server/services/websocket/session-initialization.ts +83 -19
  218. package/server/services/websocket/session-registry.ts +61 -4
  219. package/server/services/websocket/tab-broadcast.ts +38 -0
  220. package/server/services/websocket/tab-event-buffer.ts +159 -0
  221. package/server/services/websocket/tab-event-replay.ts +42 -0
  222. package/server/services/websocket/tab-handlers.ts +2 -9
  223. package/server/services/websocket/types.ts +17 -4
@@ -90,9 +90,17 @@ export class PlatformConnection {
90
90
  this.startedAt = new Date().toISOString()
91
91
  }
92
92
 
93
- private async maybeRefreshToken(): Promise<void> {
93
+ /**
94
+ * Refresh the device token if it's older than the refresh interval.
95
+ * Returns `true` if the token is (still) valid after this call, `false`
96
+ * if refresh was attempted and rejected with an auth error — in which
97
+ * case the caller should surface an auth-expired signal to the web
98
+ * rather than silently reusing a dead token.
99
+ */
100
+ private async maybeRefreshToken(): Promise<boolean> {
94
101
  const creds = getCredentials()
95
- if (!creds || !shouldRefreshToken(creds)) return
102
+ if (!creds) return false
103
+ if (!shouldRefreshToken(creds)) return true
96
104
 
97
105
  try {
98
106
  const response = await fetch(`${this.platformUrl}/api/auth/device/refresh`, {
@@ -109,14 +117,85 @@ export class PlatformConnection {
109
117
  token: data.accessToken,
110
118
  lastRefreshedAt: new Date().toISOString()
111
119
  })
112
- } else {
113
- console.warn('[Platform] Token refresh failed, will retry later')
120
+ return true
121
+ }
122
+ if (response.status === 401 || response.status === 403) {
123
+ console.warn(`[Platform] Token refresh failed — auth is expired (${response.status}). Run \`mstro login --force\`.`)
124
+ this.notifyAuthExpired()
125
+ return false
114
126
  }
127
+ console.warn(`[Platform] Token refresh failed with status ${response.status}, will retry later`)
128
+ return true
115
129
  } catch (err) {
116
130
  console.warn('[Platform] Token refresh error:', err)
131
+ return true
117
132
  }
118
133
  }
119
134
 
135
+ /**
136
+ * Verify the current token against the platform. A rejection (401/403)
137
+ * means the token is permanently invalid (revoked, signing-key rotation,
138
+ * account deleted); the caller should stop looping reconnects and tell
139
+ * the user to run `mstro login --force`.
140
+ *
141
+ * Returns `true` when the token is valid or the verification endpoint
142
+ * is unreachable (we prefer false negatives to false positives — a
143
+ * network blip shouldn't force a re-login).
144
+ */
145
+ private async verifyToken(): Promise<boolean> {
146
+ const creds = getCredentials()
147
+ if (!creds?.token) return false
148
+ try {
149
+ const response = await fetch(`${this.platformUrl}/api/auth/device/verify`, {
150
+ method: 'POST',
151
+ headers: { 'Authorization': `Bearer ${creds.token}` },
152
+ })
153
+ if (response.status === 401 || response.status === 403) {
154
+ console.warn(`[Platform] Token verify rejected (${response.status}) — auth is expired.`)
155
+ return false
156
+ }
157
+ return true
158
+ } catch {
159
+ // Network error: treat as "probably valid" so a flaky connection
160
+ // doesn't force users to re-login. The WebSocket open itself will
161
+ // catch a truly bad token via the 4001 path.
162
+ return true
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Surface an auth-expired condition to any paired web clients.
168
+ *
169
+ * Two cooperating paths deliver this signal — either alone is enough,
170
+ * both together cover every timing edge:
171
+ *
172
+ * 1. **CLI-initiated (this method):** we detected a 401 from the
173
+ * `/refresh` or `/verify` endpoint *while the relay socket is
174
+ * still open*. `this.send` pushes the message upstream so the
175
+ * server relays it to paired webs before we intentionally close.
176
+ * A no-op if the socket is already closed.
177
+ *
178
+ * 2. **Server-initiated:** when the platform closes a CLI socket
179
+ * with 4001 or 4008, `handleAuthClose` in `clientHandlers.ts`
180
+ * broadcasts the same `clientAuthExpired` to paired webs. This
181
+ * covers the cases where the CLI never had a chance to detect
182
+ * the rejection itself (e.g. token revoked while the socket was
183
+ * idle, server-side token rotation).
184
+ *
185
+ * IMPORTANT: never call `this.callbacks.onRelayedMessage` here —
186
+ * that callback feeds INCOMING web→CLI requests into the local handler,
187
+ * which would treat `clientAuthExpired` as an unknown inbound request.
188
+ */
189
+ private notifyAuthExpired(): void {
190
+ this.send({
191
+ type: 'clientAuthExpired',
192
+ data: {
193
+ connectionId: this.connectionId,
194
+ message: 'The CLI\'s device token is invalid — run `mstro login --force` on the machine.',
195
+ },
196
+ })
197
+ }
198
+
120
199
  private startTokenRefreshCheck(): void {
121
200
  this.tokenRefreshInterval = setInterval(() => {
122
201
  this.maybeRefreshToken()
@@ -162,39 +241,15 @@ export class PlatformConnection {
162
241
 
163
242
  connect(): void {
164
243
  this.isIntentionallyClosed = false
165
- const name = basename(this.workingDirectory)
166
- const machineHostname = hostname()
167
- const clientId = getClientId()
168
- const machineId = getMachineIdentifier()
169
- const nodeVersion = process.version
170
- const osType = type().toLowerCase()
171
- const cpuArch = arch()
172
-
173
- const credentials = getCredentials()
174
- const authToken = credentials?.token
175
244
 
245
+ const authToken = getCredentials()?.token
176
246
  if (!authToken) {
177
247
  console.error('\n❌ Not logged in. Run `mstro login` first.\n')
178
248
  this.callbacks.onError?.('Not logged in - run `mstro login` first')
179
249
  return
180
250
  }
181
251
 
182
- const params = new URLSearchParams({
183
- name,
184
- workingDirectory: this.workingDirectory,
185
- machineHostname,
186
- clientId,
187
- machineId,
188
- nodeVersion,
189
- osType,
190
- cpuArch,
191
- cliVersion: CLI_VERSION,
192
- capabilities: JSON.stringify({}),
193
- startedAt: this.startedAt,
194
- })
195
-
196
- const wsUrl = `${this.platformUrl.replace(/^http/, 'ws')}/ws/client?${params}`
197
-
252
+ const wsUrl = this.buildConnectionUrl()
198
253
  try {
199
254
  this.ws = new WebSocketImpl(wsUrl)
200
255
  } catch (err) {
@@ -205,7 +260,47 @@ export class PlatformConnection {
205
260
  return
206
261
  }
207
262
 
208
- const connectionTimeout = setTimeout(() => {
263
+ const connectionTimeout = this.startConnectionTimeout()
264
+ this.attachSocketHandlers(this.ws, authToken, connectionTimeout)
265
+ this.maybeVerifyTokenInParallel()
266
+ }
267
+
268
+ /**
269
+ * Best-effort token verification, fired in parallel with the socket
270
+ * open so a slow verify endpoint never delays reconnect.
271
+ *
272
+ * Only runs when the token is stale enough that we'd be about to
273
+ * refresh anyway — keeps the hot path free of an extra network call.
274
+ * A truly-revoked token that slips past this check still hits 4001
275
+ * on the WebSocket, which also triggers `notifyAuthExpired`.
276
+ */
277
+ private maybeVerifyTokenInParallel(): void {
278
+ const creds = getCredentials()
279
+ if (!creds || !shouldRefreshToken(creds)) return
280
+ this.verifyToken().then((valid) => {
281
+ if (!valid) this.notifyAuthExpired()
282
+ }).catch(() => { /* network error — rely on 4001 close path */ })
283
+ }
284
+
285
+ private buildConnectionUrl(): string {
286
+ const params = new URLSearchParams({
287
+ name: basename(this.workingDirectory),
288
+ workingDirectory: this.workingDirectory,
289
+ machineHostname: hostname(),
290
+ clientId: getClientId(),
291
+ machineId: getMachineIdentifier(),
292
+ nodeVersion: process.version,
293
+ osType: type().toLowerCase(),
294
+ cpuArch: arch(),
295
+ cliVersion: CLI_VERSION,
296
+ capabilities: JSON.stringify({}),
297
+ startedAt: this.startedAt,
298
+ })
299
+ return `${this.platformUrl.replace(/^http/, 'ws')}/ws/client?${params}`
300
+ }
301
+
302
+ private startConnectionTimeout(): ReturnType<typeof setTimeout> {
303
+ return setTimeout(() => {
209
304
  const state = this.ws?.readyState
210
305
  if (this.ws && (state === 0 || state === undefined)) {
211
306
  console.error('\n❌ Connection timeout. The platform may have rejected your credentials.')
@@ -214,55 +309,65 @@ export class PlatformConnection {
214
309
  this.callbacks.onError?.('Connection timeout - run `mstro login --force`')
215
310
  }
216
311
  }, 10000)
312
+ }
217
313
 
218
- this.ws.onopen = () => {
314
+ private attachSocketHandlers(
315
+ ws: WebSocket,
316
+ authToken: string,
317
+ connectionTimeout: ReturnType<typeof setTimeout>,
318
+ ): void {
319
+ ws.onopen = () => {
219
320
  clearTimeout(connectionTimeout)
220
- this.ws!.send(JSON.stringify({ type: 'auth', token: authToken }))
321
+ ws.send(JSON.stringify({ type: 'auth', token: authToken }))
221
322
  this.maybeRefreshToken()
222
323
  this.startTokenRefreshCheck()
223
324
  this.reconnectAttempts = 0
224
325
  trackEvent(AnalyticsEvents.PLATFORM_CONNECTED)
225
326
  }
226
327
 
227
- this.ws.onmessage = (event) => {
328
+ ws.onmessage = (event) => {
228
329
  try {
229
- const message = JSON.parse(event.data.toString())
230
- this.handleMessage(message)
330
+ this.handleMessage(JSON.parse(event.data.toString()))
231
331
  } catch (err) {
232
332
  console.error('Failed to parse platform message:', err)
233
333
  }
234
334
  }
235
335
 
236
- this.ws.onclose = (event) => {
336
+ ws.onclose = (event) => {
237
337
  clearTimeout(connectionTimeout)
238
- this.stopHeartbeat()
239
- this.isConnected = false
240
-
241
- if (!this.isIntentionallyClosed) {
242
- const isAuthFailure = event.code === 4001 ||
243
- event.reason?.includes('Unauthorized') ||
244
- (event.code === 1006 && !this.everConnected)
245
-
246
- if (isAuthFailure) {
247
- console.error('\n❌ Authentication failed. Your device token may be invalid or expired.')
248
- console.error(' Run `mstro login --force` to re-authenticate.\n')
249
- this.callbacks.onError?.('Authentication failed - run `mstro login --force`')
250
- return
251
- }
252
-
253
- console.log('Disconnected, reconnecting...')
254
- this.callbacks.onDisconnected?.()
255
- trackEvent(AnalyticsEvents.PLATFORM_DISCONNECTED)
256
- this.scheduleReconnect()
257
- }
338
+ this.handleSocketClose(event)
258
339
  }
259
340
 
260
- this.ws.onerror = () => {
341
+ ws.onerror = () => {
261
342
  clearTimeout(connectionTimeout)
262
343
  // onclose will be called after this
263
344
  }
264
345
  }
265
346
 
347
+ private handleSocketClose(event: CloseEvent): void {
348
+ this.stopHeartbeat()
349
+ this.isConnected = false
350
+
351
+ if (this.isIntentionallyClosed) return
352
+
353
+ const isAuthFailure = event.code === 4001 ||
354
+ event.reason?.includes('Unauthorized') ||
355
+ (event.code === 1006 && !this.everConnected)
356
+
357
+ if (isAuthFailure) {
358
+ console.error('\n❌ Authentication failed. Your device token may be invalid or expired.')
359
+ console.error(' Run `mstro login --force` to re-authenticate.\n')
360
+ this.notifyAuthExpired()
361
+ this.callbacks.onError?.('Authentication failed - run `mstro login --force`')
362
+ return
363
+ }
364
+
365
+ console.log('Disconnected, reconnecting...')
366
+ this.callbacks.onDisconnected?.()
367
+ trackEvent(AnalyticsEvents.PLATFORM_DISCONNECTED)
368
+ this.scheduleReconnect()
369
+ }
370
+
266
371
  private handleMessage(message: Record<string, unknown>): void {
267
372
  switch (message.type) {
268
373
  case 'paired':
@@ -0,0 +1,191 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Chunked File Download Handler
6
+ *
7
+ * Streams files to the web client as a sequence of WebSocket chunk messages.
8
+ * Mirrors the chunked upload path in reverse. Used for large binary files
9
+ * (images, PDFs) where a single base64-in-JSON `fileContent` message would
10
+ * exceed the relay's per-message cap or block the event loop during encoding.
11
+ *
12
+ * Chunk sizing: we emit 192 KB of raw bytes per chunk, which base64-encodes
13
+ * to ~256 KB — comfortably under the relay's 16 MB per-message limit even
14
+ * with JSON envelope overhead.
15
+ */
16
+
17
+ import { createReadStream, existsSync, type ReadStream, statSync } from 'node:fs';
18
+ import { basename, resolve, sep } from 'node:path';
19
+ import { isBinaryFile, isPathInSafeLocation } from './file-utils.js';
20
+ import type { WebSocketResponse, WSContext } from './types.js';
21
+
22
+ const CHUNK_RAW_BYTES = 192 * 1024; // ~256 KB once base64-encoded
23
+ const MAX_DOWNLOAD_SIZE = 50 * 1024 * 1024;
24
+ const IDLE_TIMEOUT_MS = 120_000;
25
+
26
+ interface DownloadState {
27
+ downloadId: string;
28
+ stream: ReadStream;
29
+ cancelled: boolean;
30
+ lastActivity: number;
31
+ }
32
+
33
+ function getMimeType(fullPath: string): string {
34
+ const ext = fullPath.toLowerCase().split('.').pop() || '';
35
+ if (ext === 'svg') return 'image/svg+xml';
36
+ if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg';
37
+ if (ext === 'pdf') return 'application/pdf';
38
+ if (['png', 'gif', 'webp', 'bmp', 'tiff', 'ico'].includes(ext)) return `image/${ext}`;
39
+ return 'application/octet-stream';
40
+ }
41
+
42
+ export class FileDownloadHandler {
43
+ private active = new Map<string, DownloadState>();
44
+ private cleanupInterval: ReturnType<typeof setInterval>;
45
+
46
+ constructor(private workingDir: string) {
47
+ this.cleanupInterval = setInterval(() => this.cleanupStale(), 30_000);
48
+ }
49
+
50
+ handleDownloadStart(
51
+ ws: WSContext,
52
+ send: (ws: WSContext, response: WebSocketResponse) => void,
53
+ tabId: string,
54
+ data: { downloadId: string; filePath: string },
55
+ ): void {
56
+ const { downloadId, filePath } = data;
57
+ const sendError = (error: string) => send(ws, {
58
+ type: 'fileDownloadError' as WebSocketResponse['type'],
59
+ tabId,
60
+ data: { downloadId, error },
61
+ });
62
+
63
+ try {
64
+ const fullPath = filePath.startsWith('/') ? filePath : resolve(this.workingDir, filePath);
65
+ const normalized = resolve(fullPath);
66
+ const normalizedWorkingDir = resolve(this.workingDir);
67
+
68
+ // Sandbox: must be under workingDir OR a known safe location (matches readFileContent)
69
+ const withinDir = normalized === normalizedWorkingDir
70
+ || normalized.startsWith(normalizedWorkingDir + sep);
71
+ if (!withinDir && !isPathInSafeLocation(normalized)) {
72
+ sendError('Access denied: path outside allowed locations');
73
+ return;
74
+ }
75
+ if (!existsSync(normalized)) {
76
+ sendError('File not found');
77
+ return;
78
+ }
79
+
80
+ const stats = statSync(normalized);
81
+ if (stats.isDirectory()) {
82
+ sendError('Path is a directory');
83
+ return;
84
+ }
85
+ if (stats.size > MAX_DOWNLOAD_SIZE) {
86
+ sendError(`File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_DOWNLOAD_SIZE / 1024 / 1024}MB limit`);
87
+ return;
88
+ }
89
+
90
+ const totalChunks = Math.max(1, Math.ceil(stats.size / CHUNK_RAW_BYTES));
91
+ const fileName = basename(normalized);
92
+ const isImg = isBinaryFile(normalized);
93
+
94
+ send(ws, {
95
+ type: 'fileDownloadReady' as WebSocketResponse['type'],
96
+ tabId,
97
+ data: {
98
+ downloadId,
99
+ filePath,
100
+ fileName,
101
+ size: stats.size,
102
+ mimeType: getMimeType(normalized),
103
+ isImage: isImg,
104
+ totalChunks,
105
+ modifiedAt: stats.mtime.toISOString(),
106
+ },
107
+ });
108
+
109
+ const stream = createReadStream(normalized, { highWaterMark: CHUNK_RAW_BYTES });
110
+ const state: DownloadState = {
111
+ downloadId,
112
+ stream,
113
+ cancelled: false,
114
+ lastActivity: Date.now(),
115
+ };
116
+ this.active.set(downloadId, state);
117
+
118
+ let chunkIndex = 0;
119
+ stream.on('data', (buf: Buffer | string) => {
120
+ if (state.cancelled) return;
121
+ // Node may deliver buffers larger than highWaterMark on stream edges;
122
+ // slice back down so each WS frame stays predictable.
123
+ const chunk = typeof buf === 'string' ? Buffer.from(buf) : buf;
124
+ for (let off = 0; off < chunk.length; off += CHUNK_RAW_BYTES) {
125
+ const slice = chunk.subarray(off, Math.min(off + CHUNK_RAW_BYTES, chunk.length));
126
+ send(ws, {
127
+ type: 'fileDownloadChunk' as WebSocketResponse['type'],
128
+ tabId,
129
+ data: {
130
+ downloadId,
131
+ chunkIndex: chunkIndex++,
132
+ content: slice.toString('base64'),
133
+ },
134
+ });
135
+ }
136
+ state.lastActivity = Date.now();
137
+ });
138
+
139
+ stream.on('end', () => {
140
+ if (state.cancelled) return;
141
+ send(ws, {
142
+ type: 'fileDownloadComplete' as WebSocketResponse['type'],
143
+ tabId,
144
+ data: { downloadId },
145
+ });
146
+ this.active.delete(downloadId);
147
+ });
148
+
149
+ stream.on('error', (err) => {
150
+ sendError(`Read failed: ${err.message}`);
151
+ this.active.delete(downloadId);
152
+ });
153
+ } catch (err) {
154
+ sendError(err instanceof Error ? err.message : String(err));
155
+ }
156
+ }
157
+
158
+ handleDownloadCancel(
159
+ _ws: WSContext,
160
+ _send: (ws: WSContext, response: WebSocketResponse) => void,
161
+ _tabId: string,
162
+ data: { downloadId: string },
163
+ ): void {
164
+ const state = this.active.get(data.downloadId);
165
+ if (!state) return;
166
+ state.cancelled = true;
167
+ state.stream.destroy();
168
+ this.active.delete(data.downloadId);
169
+ }
170
+
171
+ private cleanupStale(): void {
172
+ const now = Date.now();
173
+ for (const [id, state] of this.active) {
174
+ if (now - state.lastActivity > IDLE_TIMEOUT_MS) {
175
+ console.warn(`[FileDownloadHandler] Download ${id} idle, cleaning up`);
176
+ state.cancelled = true;
177
+ state.stream.destroy();
178
+ this.active.delete(id);
179
+ }
180
+ }
181
+ }
182
+
183
+ destroy(): void {
184
+ clearInterval(this.cleanupInterval);
185
+ for (const state of this.active.values()) {
186
+ state.cancelled = true;
187
+ state.stream.destroy();
188
+ }
189
+ this.active.clear();
190
+ }
191
+ }
@@ -2,6 +2,7 @@
2
2
  // Licensed under the MIT License. See LICENSE file for details.
3
3
 
4
4
  import { executeGitCommand, sendGitError } from './git-utils.js';
5
+ import { findWorktreePathForBranch, handleTabWorktreeSwitch } from './git-worktree-handlers.js';
5
6
  import type { HandlerContext } from './handler-context.js';
6
7
  import type { GitBranchEntry, WebSocketMessage, WSContext } from './types.js';
7
8
 
@@ -40,7 +41,28 @@ export async function handleGitListBranches(ctx: HandlerContext, ws: WSContext,
40
41
  }
41
42
  }
42
43
 
43
- export async function handleGitCheckout(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
44
+ /**
45
+ * If `branch` is already checked out in a worktree other than `workingDir`, redirect the tab
46
+ * there via the worktree-switch flow and return true. Git refuses `checkout` in this case, so
47
+ * treating it as a view change is both correct and the only thing that works.
48
+ */
49
+ async function redirectToWorktreeIfBranchCheckedOut(
50
+ ctx: HandlerContext, ws: WSContext,
51
+ tabId: string, branch: string, workingDir: string, rootWorkingDir: string,
52
+ ): Promise<boolean> {
53
+ const wtListResult = await executeGitCommand(['worktree', 'list', '--porcelain'], workingDir);
54
+ if (wtListResult.exitCode !== 0) return false;
55
+ const existingWt = findWorktreePathForBranch(wtListResult.stdout, branch);
56
+ if (!existingWt || existingWt === workingDir) return false;
57
+ await handleTabWorktreeSwitch(
58
+ ctx, ws,
59
+ { type: 'tabWorktreeSwitch', tabId, data: { tabId, worktreePath: existingWt } },
60
+ tabId, rootWorkingDir,
61
+ );
62
+ return true;
63
+ }
64
+
65
+ export async function handleGitCheckout(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, rootWorkingDir: string): Promise<void> {
44
66
  try {
45
67
  const { branch, create, startPoint } = msg.data || {};
46
68
  if (!branch) {
@@ -48,6 +70,11 @@ export async function handleGitCheckout(ctx: HandlerContext, ws: WSContext, msg:
48
70
  return;
49
71
  }
50
72
 
73
+ // Skip the worktree redirect for `create` — a name collision there is a real user error.
74
+ if (!create && await redirectToWorktreeIfBranchCheckedOut(ctx, ws, tabId, branch, workingDir, rootWorkingDir)) {
75
+ return;
76
+ }
77
+
51
78
  const statusResult = await executeGitCommand(['status', '--porcelain'], workingDir);
52
79
  if (statusResult.stdout.trim()) {
53
80
  ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Commit or stash changes before switching branches' } });
@@ -53,7 +53,7 @@ export async function handleGitMessage(ctx: HandlerContext, ws: WSContext, msg:
53
53
  gitDiscoverRepos: () => handleGitDiscoverRepos(ctx, ws, tabId, workingDir),
54
54
  gitSetDirectory: () => handleGitSetDirectory(ctx, ws, msg, tabId, workingDir),
55
55
  gitListBranches: () => handleGitListBranches(ctx, ws, tabId, gitDir),
56
- gitCheckout: () => handleGitCheckout(ctx, ws, msg, tabId, gitDir),
56
+ gitCheckout: () => handleGitCheckout(ctx, ws, msg, tabId, gitDir, workingDir),
57
57
  gitCreateBranch: () => handleGitCreateBranch(ctx, ws, msg, tabId, gitDir),
58
58
  gitDeleteBranch: () => handleGitDeleteBranch(ctx, ws, msg, tabId, gitDir),
59
59
  gitDiff: () => handleGitDiff(ctx, ws, msg, tabId, gitDir),
@@ -6,6 +6,7 @@ import { dirname, join } from 'node:path';
6
6
  import { resolvePmDir } from '../plan/parser.js';
7
7
  import type { Workspace } from '../plan/types.js';
8
8
  import { executeGitCommand, handleGitStatus, spawnWithOutput } from './git-handlers.js';
9
+ import { handleGitLog } from './git-log-handlers.js';
9
10
  import type { HandlerContext } from './handler-context.js';
10
11
  import type { WebSocketMessage, WorktreeInfo, WSContext } from './types.js';
11
12
 
@@ -201,7 +202,7 @@ async function handleGitWorktreeRemove(ctx: HandlerContext, ws: WSContext, msg:
201
202
  }
202
203
  }
203
204
 
204
- async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
205
+ export async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
205
206
  try {
206
207
  const { tabId: targetTabId, worktreePath } = msg.data || {};
207
208
  const resolvedTabId = targetTabId || tabId;
@@ -214,7 +215,7 @@ async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg:
214
215
  persistBoardWorktree(workingDir, resolvedTabId, null, null);
215
216
  }
216
217
  ctx.send(ws, { type: 'tabWorktreeSwitched', tabId: resolvedTabId, data: { tabId: resolvedTabId, worktreePath: null, branch: null } });
217
- handleGitStatus(ctx, ws, resolvedTabId, workingDir);
218
+ refreshScopeAfterWorktreeSwitch(ctx, ws, resolvedTabId, workingDir);
218
219
  return;
219
220
  }
220
221
 
@@ -229,12 +230,38 @@ async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg:
229
230
  }
230
231
 
231
232
  ctx.send(ws, { type: 'tabWorktreeSwitched', tabId: resolvedTabId, data: { tabId: resolvedTabId, worktreePath, branch } });
232
- handleGitStatus(ctx, ws, resolvedTabId, worktreePath);
233
+ refreshScopeAfterWorktreeSwitch(ctx, ws, resolvedTabId, worktreePath);
233
234
  } catch (error: unknown) {
234
235
  ctx.send(ws, { type: 'gitError', tabId, data: { error: error instanceof Error ? error.message : String(error) } });
235
236
  }
236
237
  }
237
238
 
239
+ /**
240
+ * After a worktree switch, re-fetch everything that's worktree-specific so
241
+ * the client sees a complete, consistent view of the newly-selected workspace
242
+ * from a single `tabWorktreeSwitched` event. Keeping the refresh on the
243
+ * server side means the client has one signal to react to instead of having
244
+ * to orchestrate status/log/... fetches itself.
245
+ *
246
+ * Branches and the worktree list are NOT re-fetched: they're repo-wide, not
247
+ * worktree-specific.
248
+ *
249
+ * Fire-and-forget: the switch itself has already been acknowledged via
250
+ * `tabWorktreeSwitched`. `handleGitStatus` and `handleGitLog` each own their
251
+ * own error handling (they send `gitError` scoped to the correct tabId).
252
+ * Awaiting here would let any unexpected throw escape into the caller's
253
+ * outer try/catch and produce a misleading `gitError` on the original
254
+ * dispatch tabId after success has already been signalled.
255
+ */
256
+ function refreshScopeAfterWorktreeSwitch(ctx: HandlerContext, ws: WSContext, tabId: string, gitDir: string): void {
257
+ (async () => {
258
+ await handleGitStatus(ctx, ws, tabId, gitDir);
259
+ await handleGitLog(ctx, ws, { type: 'gitLog', tabId, data: { limit: 20 } }, tabId, gitDir);
260
+ })().catch((error: unknown) => {
261
+ console.error('[handleTabWorktreeSwitch] scope refresh failed:', error);
262
+ });
263
+ }
264
+
238
265
  async function pushWithUpstreamRetry(
239
266
  worktreePath: string,
240
267
  pushRemote: string,
@@ -445,7 +472,7 @@ async function cleanupAfterMerge(
445
472
  return { warnings, removedWorktreePath };
446
473
  }
447
474
 
448
- function findWorktreePathForBranch(porcelainOutput: string, branchName: string): string | null {
475
+ export function findWorktreePathForBranch(porcelainOutput: string, branchName: string): string | null {
449
476
  let currentWtPath = '';
450
477
  const fullRef = `refs/heads/${branchName}`;
451
478
  for (const line of porcelainOutput.split('\n')) {
@@ -6,8 +6,10 @@ import type { ImprovisationSessionManager } from '../../cli/improvisation-sessio
6
6
  import type { AutocompleteService } from './autocomplete.js';
7
7
  import type { FileUploadHandler } from './file-upload-handler.js';
8
8
  import type { GitHeadWatcher } from './git-head-watcher.js';
9
+ import type { MsgIdTracker } from './msg-id-tracker.js';
9
10
  import type { SessionRegistry } from './session-registry.js';
10
11
  import type { SkillsWatcher } from './skill-watcher.js';
12
+ import type { TabEventBufferRegistry } from './tab-event-buffer.js';
11
13
  import type { WebSocketResponse, WSContext } from './types.js';
12
14
 
13
15
  export interface UsageReport {
@@ -37,6 +39,19 @@ export interface HandlerContext {
37
39
  fileUploadHandler: FileUploadHandler | null;
38
40
  gitHeadWatcher: GitHeadWatcher | null;
39
41
  skillsWatcher: SkillsWatcher | null;
42
+ /**
43
+ * Per-tab replay buffer for tab-scoped broadcasts. Populated by
44
+ * `broadcastTabEvent` (see `tab-broadcast.ts`) so a web client rejoining a
45
+ * tab after a reconnect can request replay of anything it missed during
46
+ * the transport gap. See `tab-event-buffer.ts`.
47
+ */
48
+ tabEventBuffers: TabEventBufferRegistry;
49
+ /**
50
+ * Idempotency tracker for `execute` `msgId`s. Lets the web replay the
51
+ * same prompt across reconnects without causing double execution — the
52
+ * CLI still acks, but skips running the prompt a second time.
53
+ */
54
+ msgIdTracker: MsgIdTracker;
40
55
 
41
56
  // Registry access
42
57
  getRegistry(workingDir: string): SessionRegistry;