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
@@ -21,6 +21,13 @@ export interface RegisteredTab {
21
21
  lastActivityAt: string
22
22
  order: number
23
23
  hasUnviewedCompletion: boolean
24
+ /**
25
+ * True once the session's history file has existed on disk (first prompt
26
+ * ran, or the tab was resumed from an existing file). Guards `sweepGhostTabs`
27
+ * so brand-new tabs that haven't had a first prompt are not confused with
28
+ * tabs whose history file was deleted.
29
+ */
30
+ hasPersistedHistory?: boolean
24
31
  worktreePath?: string
25
32
  worktreeBranch?: string
26
33
  }
@@ -31,6 +38,7 @@ interface RegistryData {
31
38
 
32
39
  export class SessionRegistry {
33
40
  private registryPath: string
41
+ private historyDir: string
34
42
  private data: RegistryData
35
43
 
36
44
  constructor(workingDir: string) {
@@ -39,7 +47,9 @@ export class SessionRegistry {
39
47
  mkdirSync(mstroDir, { recursive: true })
40
48
  }
41
49
  this.registryPath = join(mstroDir, 'session-registry.json')
50
+ this.historyDir = join(mstroDir, 'history')
42
51
  this.data = this.load()
52
+ this.sweepGhostTabs()
43
53
  }
44
54
 
45
55
  private load(): RegistryData {
@@ -68,6 +78,36 @@ export class SessionRegistry {
68
78
  return { tabs: {} }
69
79
  }
70
80
 
81
+ /**
82
+ * Drop registry entries whose backing history file no longer exists.
83
+ *
84
+ * The history file may have been deleted via `clearHistory`, pruned by the
85
+ * user, or lost on disk. Without a sweep, `getActiveTabs` returns the tab
86
+ * to the web, the web tries to `initTab` it, `resumeFromHistory` throws,
87
+ * and the tab re-creates as an empty new session — confusing the user with
88
+ * a "restored" tab that lost its content.
89
+ *
90
+ * Only sweeps tabs that were previously persisted (`hasPersistedHistory`).
91
+ * Tabs that have never had a first prompt have no file on disk by design;
92
+ * removing them here would wipe a freshly opened tab after a CLI restart.
93
+ */
94
+ private sweepGhostTabs(): void {
95
+ const removed: string[] = []
96
+ for (const [tabId, tab] of Object.entries(this.data.tabs)) {
97
+ if (tab.hasPersistedHistory !== true) continue
98
+ const timestamp = tab.sessionId.replace('improv-', '')
99
+ const historyPath = join(this.historyDir, `${timestamp}.json`)
100
+ if (!existsSync(historyPath)) {
101
+ removed.push(tabId)
102
+ delete this.data.tabs[tabId]
103
+ }
104
+ }
105
+ if (removed.length > 0) {
106
+ console.log(`[SessionRegistry] Swept ${removed.length} ghost tab(s) whose history file was missing`)
107
+ this.save()
108
+ }
109
+ }
110
+
71
111
  private save(): void {
72
112
  try {
73
113
  writeFileSync(this.registryPath, JSON.stringify(this.data, null, 2))
@@ -86,13 +126,16 @@ export class SessionRegistry {
86
126
 
87
127
  registerTab(tabId: string, sessionId: string, tabName?: string): void {
88
128
  const now = new Date().toISOString()
129
+ const existing = this.data.tabs[tabId]
130
+ const sessionChanged = existing?.sessionId !== sessionId
89
131
  this.data.tabs[tabId] = {
90
132
  sessionId,
91
133
  tabName: tabName || `Chat ${this.getNextChatNumber()}`,
92
- createdAt: this.data.tabs[tabId]?.createdAt || now,
134
+ createdAt: existing?.createdAt || now,
93
135
  lastActivityAt: now,
94
- order: this.data.tabs[tabId]?.order ?? this.getNextOrder(),
95
- hasUnviewedCompletion: this.data.tabs[tabId]?.hasUnviewedCompletion ?? false,
136
+ order: existing?.order ?? this.getNextOrder(),
137
+ hasUnviewedCompletion: existing?.hasUnviewedCompletion ?? false,
138
+ hasPersistedHistory: sessionChanged ? false : existing?.hasPersistedHistory,
96
139
  }
97
140
  this.save()
98
141
  }
@@ -145,12 +188,26 @@ export class SessionRegistry {
145
188
  }
146
189
 
147
190
  /**
148
- * Update session ID for a tab (e.g., when "new session" is started)
191
+ * Update session ID for a tab (e.g., when "new session" is started).
192
+ * Resets `hasPersistedHistory` since the new session has no file on disk yet.
149
193
  */
150
194
  updateTabSession(tabId: string, sessionId: string): void {
151
195
  if (this.data.tabs[tabId]) {
152
196
  this.data.tabs[tabId].sessionId = sessionId
153
197
  this.data.tabs[tabId].lastActivityAt = new Date().toISOString()
198
+ this.data.tabs[tabId].hasPersistedHistory = false
199
+ this.save()
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Mark a tab as having persisted its history to disk. Called after the
205
+ * first `persistHistory` or when an existing file is resumed.
206
+ */
207
+ markTabPersisted(tabId: string): void {
208
+ const tab = this.data.tabs[tabId]
209
+ if (tab && !tab.hasPersistedHistory) {
210
+ tab.hasPersistedHistory = true
154
211
  this.save()
155
212
  }
156
213
  }
@@ -0,0 +1,38 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Tab-scoped broadcast helper.
6
+ *
7
+ * Session and plan handlers emit streaming events that (a) fan out to all
8
+ * paired web clients and (b) need to survive transport reconnects. Instead of
9
+ * every call site knowing about both concerns, route through
10
+ * `broadcastTabEvent`: it assigns the next monotonic `seq` via the tab's
11
+ * event buffer and broadcasts the wire message with that seq attached.
12
+ *
13
+ * Receiving webs record the seq and ask for replay starting from their
14
+ * highest-seen seq the next time they `initTab` / `resumeSession`. See
15
+ * `tab-event-buffer.ts` for the buffer itself and
16
+ * `session-initialization.ts` for the replay path.
17
+ */
18
+
19
+ import type { HandlerContext } from './handler-context.js'
20
+ import type { WebSocketResponse } from './types.js'
21
+
22
+ type TabScopedEventType = WebSocketResponse['type']
23
+
24
+ /**
25
+ * Record + broadcast a tab-scoped event in one call. Returns the assigned
26
+ * sequence number purely for logging/tests — callers rarely need it.
27
+ */
28
+ export function broadcastTabEvent(
29
+ ctx: HandlerContext,
30
+ tabId: string,
31
+ type: TabScopedEventType,
32
+ data: unknown,
33
+ ): number {
34
+ const buffer = ctx.tabEventBuffers.getOrCreate(tabId)
35
+ const seq = buffer.record(type, data)
36
+ ctx.broadcastToAll({ type, tabId, data, seq })
37
+ return seq
38
+ }
@@ -0,0 +1,159 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Tab event buffer — monotonic, bounded replay log for tab-scoped broadcasts.
6
+ *
7
+ * ## Why it exists
8
+ *
9
+ * The platform relay only fans out broadcasts to webs currently paired to the
10
+ * CLI's connection key. During a CLI-side platform reconnect the CLI's key
11
+ * rotates (new `connectionId`) and any web whose transport was pointing at
12
+ * the old key sees no events until it completes its own reconnect handshake.
13
+ * The `executionEventLog` on the session manager covers in-flight execution,
14
+ * but smaller lifecycle events — `movementStart`, `movementComplete`,
15
+ * `tabStateChanged`, `sessionUpdate`, approve/reject acknowledgements — can
16
+ * land in that dark window and be lost.
17
+ *
18
+ * This buffer records every tab-scoped broadcast with a monotonic per-tab
19
+ * `seq`. When the web sends `initTab` / `resumeSession` with its
20
+ * `lastSeenSeq`, we replay anything newer before emitting `tabInitialized`.
21
+ *
22
+ * ## Design choices
23
+ *
24
+ * - **Bounded by both count and age** so a long-idle tab doesn't keep ancient
25
+ * events forever, and a chatty session doesn't balloon memory. The limits
26
+ * are intentionally generous: 1000 events + 15 minutes covers a typical
27
+ * reconnect window by orders of magnitude, and events are small objects.
28
+ * - **Per-tab registry, not per-session** because the web identifies replay
29
+ * targets by `tabId`, which is stable across `new` (sessionId rotates, tab
30
+ * doesn't).
31
+ * - **No sequencing gaps**: `nextSeq` strictly increments, even when the
32
+ * buffer drops old events. The web compares `seq > lastSeenSeq`, so stale
33
+ * numbering below the window is fine — everything the web hasn't seen yet
34
+ * has a larger seq.
35
+ */
36
+
37
+ export interface BufferedEvent {
38
+ /** Monotonic per-tab sequence (1-based). */
39
+ seq: number
40
+ /** Wire message type, e.g. `output`, `thinking`, `movementComplete`. */
41
+ type: string
42
+ /** Opaque payload for the wire message. */
43
+ data: unknown
44
+ /** `Date.now()` at record time. Used for age-based eviction. */
45
+ timestamp: number
46
+ }
47
+
48
+ /**
49
+ * Bounded replay log for a single tab.
50
+ *
51
+ * Size/age limits are parameterised for testability but defaulted to values
52
+ * that comfortably cover real-world reconnect windows.
53
+ */
54
+ export class TabEventBuffer {
55
+ private readonly events: BufferedEvent[] = []
56
+ private nextSeq = 1
57
+
58
+ constructor(
59
+ private readonly maxEvents: number = DEFAULT_MAX_EVENTS,
60
+ private readonly maxAgeMs: number = DEFAULT_MAX_AGE_MS,
61
+ private readonly now: () => number = Date.now,
62
+ ) {}
63
+
64
+ /**
65
+ * Append an event and return its assigned sequence number.
66
+ *
67
+ * Callers include the returned `seq` on the outgoing wire message so the
68
+ * web can record it and ask for replay starting after that seq on a
69
+ * subsequent reconnect.
70
+ */
71
+ record(type: string, data: unknown): number {
72
+ const seq = this.nextSeq++
73
+ this.events.push({ seq, type, data, timestamp: this.now() })
74
+ this.evict()
75
+ return seq
76
+ }
77
+
78
+ /**
79
+ * Return all still-buffered events with `seq > afterSeq`, in original
80
+ * order. Returns an empty array if nothing newer is buffered (either the
81
+ * web is caught up or the window has rolled past).
82
+ */
83
+ getSince(afterSeq: number): BufferedEvent[] {
84
+ this.evict()
85
+ const out: BufferedEvent[] = []
86
+ for (const event of this.events) {
87
+ if (event.seq > afterSeq) out.push(event)
88
+ }
89
+ return out
90
+ }
91
+
92
+ /** Current highest assigned seq (monotonic; not reset by eviction). */
93
+ currentSeq(): number {
94
+ return this.nextSeq - 1
95
+ }
96
+
97
+ /** Events currently held in memory. For tests. */
98
+ size(): number {
99
+ return this.events.length
100
+ }
101
+
102
+ /**
103
+ * Drop events older than `maxAgeMs` from the front, then enforce
104
+ * `maxEvents` by trimming the front further if needed. Eviction keeps the
105
+ * newest events — they're the ones the web is most likely to still need.
106
+ */
107
+ private evict(): void {
108
+ const cutoff = this.now() - this.maxAgeMs
109
+ while (this.events.length > 0 && this.events[0].timestamp < cutoff) {
110
+ this.events.shift()
111
+ }
112
+ while (this.events.length > this.maxEvents) {
113
+ this.events.shift()
114
+ }
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Registry of per-tab buffers. Kept as a thin collection so `HandlerContext`
120
+ * can expose one instance and every broadcast site looks up (or lazily
121
+ * creates) the tab's buffer with a single call.
122
+ */
123
+ export class TabEventBufferRegistry {
124
+ private readonly buffers = new Map<string, TabEventBuffer>()
125
+
126
+ constructor(
127
+ private readonly bufferFactory: () => TabEventBuffer = () => new TabEventBuffer(),
128
+ ) {}
129
+
130
+ /** Get the buffer for `tabId`, creating it on first touch. */
131
+ getOrCreate(tabId: string): TabEventBuffer {
132
+ let buffer = this.buffers.get(tabId)
133
+ if (!buffer) {
134
+ buffer = this.bufferFactory()
135
+ this.buffers.set(tabId, buffer)
136
+ }
137
+ return buffer
138
+ }
139
+
140
+ /** Get the buffer for `tabId` without creating it. */
141
+ get(tabId: string): TabEventBuffer | undefined {
142
+ return this.buffers.get(tabId)
143
+ }
144
+
145
+ /** Forget `tabId` entirely — called on `tabRemoved`. */
146
+ delete(tabId: string): void {
147
+ this.buffers.delete(tabId)
148
+ }
149
+
150
+ /** Drop all bookkeeping. Used for tests; no production caller expected. */
151
+ clear(): void {
152
+ this.buffers.clear()
153
+ }
154
+ }
155
+
156
+ /** 1000 events per tab covers typical reconnect windows comfortably. */
157
+ export const DEFAULT_MAX_EVENTS = 1000
158
+ /** 15 minutes of history is more than enough for the longest plausible web reconnect. */
159
+ export const DEFAULT_MAX_AGE_MS = 15 * 60 * 1000
@@ -0,0 +1,42 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Replay tab-scoped events missed during a transport gap.
6
+ *
7
+ * `session-initialization.ts` calls this right before sending `tabInitialized`
8
+ * so the web sees any events with `seq > lastSeenSeq` in-order before the
9
+ * usual initialization payload. Ordering matters: e.g. a `movementComplete`
10
+ * before `outputHistory` would render duplicate content.
11
+ *
12
+ * Delivery is targeted (`ctx.send`) rather than broadcast because only the
13
+ * rejoining web needs the replay; other connected webs already saw these
14
+ * events live.
15
+ */
16
+
17
+ import type { HandlerContext } from './handler-context.js'
18
+ import type { WebSocketResponse, WSContext } from './types.js'
19
+
20
+ /**
21
+ * Replay tab events with `seq > lastSeenSeq` to `ws`. Silently no-ops when
22
+ * the buffer is empty or `lastSeenSeq` is unset (full init, not a resume).
23
+ */
24
+ export function replayTabEventsSince(
25
+ ctx: HandlerContext,
26
+ ws: WSContext,
27
+ tabId: string,
28
+ lastSeenSeq: number | undefined,
29
+ ): void {
30
+ if (lastSeenSeq === undefined) return
31
+
32
+ const buffer = ctx.tabEventBuffers.get(tabId)
33
+ if (!buffer) return
34
+
35
+ const events = buffer.getSince(lastSeenSeq)
36
+ for (const event of events) {
37
+ // Types are checked at record time via `broadcastTabEvent`; the buffer
38
+ // stores them as strings but by construction they're always
39
+ // `WebSocketResponse['type']`. Narrow here without an extra runtime check.
40
+ ctx.send(ws, { type: event.type as WebSocketResponse['type'], tabId, data: event.data, seq: event.seq })
41
+ }
42
+ }
@@ -78,20 +78,13 @@ export function handleSyncTabMeta(ctx: HandlerContext, _ws: WSContext, msg: WebS
78
78
  }
79
79
  }
80
80
 
81
- export function handleSyncPromptText(ctx: HandlerContext, _ws: WSContext, msg: WebSocketMessage, tabId: string): void {
82
- if (typeof msg.data?.text !== 'string') return;
83
- ctx.broadcastToAll({
84
- type: 'promptTextSync',
85
- tabId,
86
- data: { tabId, text: msg.data.text }
87
- });
88
- }
89
-
90
81
  export function handleRemoveTab(ctx: HandlerContext, _ws: WSContext, tabId: string, workingDir: string): void {
91
82
  const registry = ctx.getRegistry(workingDir);
92
83
  registry.unregisterTab(tabId);
93
84
  ctx.gitDirectories.delete(tabId);
94
85
  ctx.gitBranches.delete(tabId);
86
+ ctx.tabEventBuffers.delete(tabId);
87
+ ctx.msgIdTracker.forget(tabId);
95
88
 
96
89
  ctx.broadcastToAll({
97
90
  type: 'tabRemoved',
@@ -38,7 +38,7 @@ const GitWorktreeMessages = ['gitWorktreeList', 'gitWorktreeCreate', 'gitWorktre
38
38
 
39
39
  const GitMergeMessages = ['gitMergePreview', 'gitWorktreeMerge', 'gitMergeAbort', 'gitMergeComplete'] as const;
40
40
 
41
- const SessionSyncMessages = ['getActiveTabs', 'createTab', 'reorderTabs', 'syncTabMeta', 'syncPromptText', 'removeTab', 'markTabViewed'] as const;
41
+ const SessionSyncMessages = ['getActiveTabs', 'createTab', 'reorderTabs', 'syncTabMeta', 'removeTab', 'markTabViewed'] as const;
42
42
 
43
43
  const SettingsMessages = ['getSettings', 'updateSettings'] as const;
44
44
 
@@ -46,6 +46,8 @@ const QualityMessages = ['qualityDetectTools', 'qualityScan', 'qualityInstallToo
46
46
 
47
47
  const FileUploadMessages = ['fileUploadStart', 'fileUploadChunk', 'fileUploadComplete', 'fileUploadCancel'] as const;
48
48
 
49
+ const FileDownloadMessages = ['fileDownloadStart', 'fileDownloadCancel'] as const;
50
+
49
51
  const PlanMessages = ['planInit', 'planGetState', 'planListIssues', 'planGetIssue', 'planGetSprint', 'planGetMilestone', 'planCreateIssue', 'planUpdateIssue', 'planDeleteIssue', 'planScaffold', 'planPrompt', 'planExecute', 'planExecuteEpic', 'planPause', 'planStop', 'planResume'] as const;
50
52
 
51
53
  const PlanBoardMessages = ['planCreateBoard', 'planUpdateBoard', 'planArchiveBoard', 'planRestoreBoard', 'planGetBoard', 'planGetBoardState', 'planReorderBoards', 'planSetActiveBoard', 'planGetBoardArtifacts'] as const;
@@ -68,6 +70,7 @@ type WebSocketMessageType =
68
70
  | typeof SettingsMessages[number]
69
71
  | typeof QualityMessages[number]
70
72
  | typeof FileUploadMessages[number]
73
+ | typeof FileDownloadMessages[number]
71
74
  | typeof PlanMessages[number]
72
75
  | typeof PlanBoardMessages[number]
73
76
  | typeof PlanSprintMessages[number]
@@ -83,7 +86,7 @@ export interface WebSocketMessage {
83
86
  _permission?: 'view';
84
87
  }
85
88
 
86
- const CoreResponseMessages = ['output', 'thinking', 'movementStart', 'movementComplete', 'movementError', 'sessionUpdate', 'history', 'sessions', 'sessionsCount', 'sessionDeleted', 'sessionData', 'historyCleared', 'searchResults', 'newSession', 'autocomplete', 'fileContent', 'error', 'pong', 'tabInitialized', 'approvalRequired', 'toolUse', 'streamingTokens', 'notificationSummary'] as const;
89
+ const CoreResponseMessages = ['output', 'thinking', 'movementStart', 'movementComplete', 'movementError', 'sessionUpdate', 'history', 'sessions', 'sessionsCount', 'sessionDeleted', 'sessionData', 'historyCleared', 'searchResults', 'newSession', 'autocomplete', 'fileContent', 'error', 'pong', 'tabInitialized', 'approvalRequired', 'toolUse', 'streamingTokens', 'notificationSummary', 'executeAck', 'clientOffline', 'clientAuthExpired'] as const;
87
90
 
88
91
  const TerminalResponseMessages = ['terminalOutput', 'terminalReady', 'terminalExit', 'terminalError', 'terminalList', 'terminalScrollback', 'terminalCreated', 'terminalClosed'] as const;
89
92
 
@@ -101,14 +104,16 @@ const GitWorktreeResponseMessages = ['gitWorktreeListResult', 'gitWorktreeCreate
101
104
 
102
105
  const GitMergeResponseMessages = ['gitMergePreviewResult', 'gitWorktreeMergeResult', 'gitMergeAborted', 'gitMergeCompleted'] as const;
103
106
 
104
- const SessionSyncResponseMessages = ['activeTabs', 'tabCreated', 'tabRemoved', 'tabRenamed', 'tabsReordered', 'promptTextSync', 'tabViewed', 'tabStateChanged'] as const;
107
+ const SessionSyncResponseMessages = ['activeTabs', 'tabCreated', 'tabRemoved', 'tabRenamed', 'tabsReordered', 'tabViewed', 'tabStateChanged'] as const;
105
108
 
106
109
  const SettingsResponseMessages = ['settings', 'settingsUpdated'] as const;
107
110
 
108
- const QualityResponseMessages = ['qualityToolsDetected', 'qualityScanProgress', 'qualityScanResults', 'qualityInstallProgress', 'qualityInstallComplete', 'qualityCodeReview', 'qualityCodeReviewProgress', 'qualityPostSession', 'qualityError', 'qualityStateLoaded'] as const;
111
+ const QualityResponseMessages = ['qualityToolsDetected', 'qualityScanProgress', 'qualityScanResults', 'qualityInstallProgress', 'qualityInstallComplete', 'qualityCodeReview', 'qualityCodeReviewProgress', 'qualityPostSession', 'qualityError', 'qualityStateLoaded', 'qualityDirectoriesUpdated'] as const;
109
112
 
110
113
  const FileUploadResponseMessages = ['fileUploadAck', 'fileUploadReady', 'fileUploadError'] as const;
111
114
 
115
+ const FileDownloadResponseMessages = ['fileDownloadReady', 'fileDownloadChunk', 'fileDownloadComplete', 'fileDownloadError'] as const;
116
+
112
117
  const PlanResponseMessages = ['planState', 'planIssueList', 'planIssue', 'planSprint', 'planMilestone', 'planNotFound', 'planStateUpdated', 'planIssueUpdated', 'planIssueCreated', 'planIssueDeleted', 'planScaffolded', 'planPromptStreaming', 'planPromptProgress', 'planPromptResponse', 'planExecutionStarted', 'planExecutionProgress', 'planExecutionOutput', 'planExecutionMetrics', 'planExecutionComplete', 'planExecutionError', 'planError'] as const;
113
118
 
114
119
  const PlanBoardResponseMessages = ['planBoardCreated', 'planBoardUpdated', 'planBoardArchived', 'planBoardState', 'planBoardArtifacts', 'planWorkspaceUpdated'] as const;
@@ -131,6 +136,7 @@ type WebSocketResponseType =
131
136
  | typeof SettingsResponseMessages[number]
132
137
  | typeof QualityResponseMessages[number]
133
138
  | typeof FileUploadResponseMessages[number]
139
+ | typeof FileDownloadResponseMessages[number]
134
140
  | typeof PlanResponseMessages[number]
135
141
  | typeof PlanBoardResponseMessages[number]
136
142
  | typeof PlanSprintResponseMessages[number]
@@ -142,6 +148,13 @@ export interface WebSocketResponse {
142
148
  terminalId?: string;
143
149
  // biome-ignore lint/suspicious/noExplicitAny: message envelope carries heterogeneous payloads — typed per-handler via destructuring
144
150
  data?: any;
151
+ /**
152
+ * Monotonic per-tab sequence number assigned by `tab-event-buffer.ts`.
153
+ * Stamped on tab-scoped streaming broadcasts so webs can ask for replay
154
+ * starting after their highest-seen seq on a reconnect (`initTab` /
155
+ * `resumeSession` with `lastSeenSeq` in the payload).
156
+ */
157
+ seq?: number;
145
158
  }
146
159
 
147
160
  export interface ConnectionData {