mstro-app 0.5.1 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/PRIVACY.md +9 -9
  2. package/README.md +71 -28
  3. package/bin/commands/config.js +1 -1
  4. package/bin/mstro.js +55 -4
  5. package/dist/server/cli/eta-estimator.d.ts +55 -0
  6. package/dist/server/cli/eta-estimator.d.ts.map +1 -0
  7. package/dist/server/cli/eta-estimator.js +222 -0
  8. package/dist/server/cli/eta-estimator.js.map +1 -0
  9. package/dist/server/cli/headless/stall-assessor.d.ts +50 -0
  10. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  11. package/dist/server/cli/headless/stall-assessor.js +64 -9
  12. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  13. package/dist/server/cli/headless/tool-watchdog.d.ts +21 -0
  14. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  15. package/dist/server/cli/headless/tool-watchdog.js +19 -12
  16. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  17. package/dist/server/cli/improvisation-history-store.d.ts.map +1 -1
  18. package/dist/server/cli/improvisation-history-store.js +5 -1
  19. package/dist/server/cli/improvisation-history-store.js.map +1 -1
  20. package/dist/server/cli/improvisation-output-queue.d.ts +5 -1
  21. package/dist/server/cli/improvisation-output-queue.d.ts.map +1 -1
  22. package/dist/server/cli/improvisation-output-queue.js +30 -7
  23. package/dist/server/cli/improvisation-output-queue.js.map +1 -1
  24. package/dist/server/cli/improvisation-session-manager.d.ts +29 -0
  25. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  26. package/dist/server/cli/improvisation-session-manager.js +50 -1
  27. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  28. package/dist/server/cli/improvisation-types.d.ts +2 -0
  29. package/dist/server/cli/improvisation-types.d.ts.map +1 -1
  30. package/dist/server/cli/improvisation-types.js.map +1 -1
  31. package/dist/server/engines/EngineEvent.d.ts +126 -0
  32. package/dist/server/engines/EngineEvent.d.ts.map +1 -0
  33. package/dist/server/engines/EngineEvent.js +11 -0
  34. package/dist/server/engines/EngineEvent.js.map +1 -0
  35. package/dist/server/engines/claude/ClaudeCodeEngine.d.ts +47 -0
  36. package/dist/server/engines/claude/ClaudeCodeEngine.d.ts.map +1 -0
  37. package/dist/server/engines/claude/ClaudeCodeEngine.js +338 -0
  38. package/dist/server/engines/claude/ClaudeCodeEngine.js.map +1 -0
  39. package/dist/server/engines/factory.d.ts +21 -0
  40. package/dist/server/engines/factory.d.ts.map +1 -0
  41. package/dist/server/engines/factory.js +152 -0
  42. package/dist/server/engines/factory.js.map +1 -0
  43. package/dist/server/engines/opencode/OpenCodeEngine.d.ts +148 -0
  44. package/dist/server/engines/opencode/OpenCodeEngine.d.ts.map +1 -0
  45. package/dist/server/engines/opencode/OpenCodeEngine.js +630 -0
  46. package/dist/server/engines/opencode/OpenCodeEngine.js.map +1 -0
  47. package/dist/server/engines/opencode/OpenCodeServerManager.d.ts +172 -0
  48. package/dist/server/engines/opencode/OpenCodeServerManager.d.ts.map +1 -0
  49. package/dist/server/engines/opencode/OpenCodeServerManager.js +390 -0
  50. package/dist/server/engines/opencode/OpenCodeServerManager.js.map +1 -0
  51. package/dist/server/engines/opencode/model-catalog.d.ts +94 -0
  52. package/dist/server/engines/opencode/model-catalog.d.ts.map +1 -0
  53. package/dist/server/engines/opencode/model-catalog.js +141 -0
  54. package/dist/server/engines/opencode/model-catalog.js.map +1 -0
  55. package/dist/server/engines/types.d.ts +146 -0
  56. package/dist/server/engines/types.d.ts.map +1 -0
  57. package/dist/server/engines/types.js +4 -0
  58. package/dist/server/engines/types.js.map +1 -0
  59. package/dist/server/index.js +1 -1
  60. package/dist/server/index.js.map +1 -1
  61. package/dist/server/mcp/bouncer-haiku.d.ts +17 -4
  62. package/dist/server/mcp/bouncer-haiku.d.ts.map +1 -1
  63. package/dist/server/mcp/bouncer-haiku.js +8 -124
  64. package/dist/server/mcp/bouncer-haiku.js.map +1 -1
  65. package/dist/server/mcp/bouncer-integration.d.ts +45 -0
  66. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  67. package/dist/server/mcp/bouncer-integration.js +69 -5
  68. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  69. package/dist/server/mcp/classifier/BouncerClassifier.d.ts +34 -0
  70. package/dist/server/mcp/classifier/BouncerClassifier.d.ts.map +1 -0
  71. package/dist/server/mcp/classifier/BouncerClassifier.js +4 -0
  72. package/dist/server/mcp/classifier/BouncerClassifier.js.map +1 -0
  73. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts +17 -0
  74. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts.map +1 -0
  75. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js +142 -0
  76. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js.map +1 -0
  77. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts +68 -0
  78. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts.map +1 -0
  79. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js +182 -0
  80. package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js.map +1 -0
  81. package/dist/server/mcp/classifier/factory.d.ts +70 -0
  82. package/dist/server/mcp/classifier/factory.d.ts.map +1 -0
  83. package/dist/server/mcp/classifier/factory.js +155 -0
  84. package/dist/server/mcp/classifier/factory.js.map +1 -0
  85. package/dist/server/services/plan/agent-resolver.d.ts +26 -0
  86. package/dist/server/services/plan/agent-resolver.d.ts.map +1 -0
  87. package/dist/server/services/plan/agent-resolver.js +102 -0
  88. package/dist/server/services/plan/agent-resolver.js.map +1 -0
  89. package/dist/server/services/plan/composer.d.ts.map +1 -1
  90. package/dist/server/services/plan/composer.js +59 -11
  91. package/dist/server/services/plan/composer.js.map +1 -1
  92. package/dist/server/services/plan/executor.d.ts.map +1 -1
  93. package/dist/server/services/plan/executor.js +3 -1
  94. package/dist/server/services/plan/executor.js.map +1 -1
  95. package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
  96. package/dist/server/services/plan/issue-prompt-builder.js +33 -1
  97. package/dist/server/services/plan/issue-prompt-builder.js.map +1 -1
  98. package/dist/server/services/plan/parser-core.d.ts.map +1 -1
  99. package/dist/server/services/plan/parser-core.js +1 -0
  100. package/dist/server/services/plan/parser-core.js.map +1 -1
  101. package/dist/server/services/plan/types.d.ts +1 -0
  102. package/dist/server/services/plan/types.d.ts.map +1 -1
  103. package/dist/server/services/settings.d.ts +76 -2
  104. package/dist/server/services/settings.d.ts.map +1 -1
  105. package/dist/server/services/settings.js +127 -4
  106. package/dist/server/services/settings.js.map +1 -1
  107. package/dist/server/services/websocket/git-branch-handlers.d.ts.map +1 -1
  108. package/dist/server/services/websocket/git-branch-handlers.js +19 -6
  109. package/dist/server/services/websocket/git-branch-handlers.js.map +1 -1
  110. package/dist/server/services/websocket/handler.d.ts +17 -1
  111. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  112. package/dist/server/services/websocket/handler.js +54 -2
  113. package/dist/server/services/websocket/handler.js.map +1 -1
  114. package/dist/server/services/websocket/quality-complexity.d.ts.map +1 -1
  115. package/dist/server/services/websocket/quality-complexity.js +78 -26
  116. package/dist/server/services/websocket/quality-complexity.js.map +1 -1
  117. package/dist/server/services/websocket/quality-eta.d.ts +47 -0
  118. package/dist/server/services/websocket/quality-eta.d.ts.map +1 -0
  119. package/dist/server/services/websocket/quality-eta.js +110 -0
  120. package/dist/server/services/websocket/quality-eta.js.map +1 -0
  121. package/dist/server/services/websocket/quality-grading.d.ts +27 -4
  122. package/dist/server/services/websocket/quality-grading.d.ts.map +1 -1
  123. package/dist/server/services/websocket/quality-grading.js +369 -201
  124. package/dist/server/services/websocket/quality-grading.js.map +1 -1
  125. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
  126. package/dist/server/services/websocket/quality-handlers.js +145 -7
  127. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  128. package/dist/server/services/websocket/quality-operations.d.ts +34 -0
  129. package/dist/server/services/websocket/quality-operations.d.ts.map +1 -0
  130. package/dist/server/services/websocket/quality-operations.js +47 -0
  131. package/dist/server/services/websocket/quality-operations.js.map +1 -0
  132. package/dist/server/services/websocket/quality-persistence.d.ts +9 -0
  133. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
  134. package/dist/server/services/websocket/quality-persistence.js +10 -0
  135. package/dist/server/services/websocket/quality-persistence.js.map +1 -1
  136. package/dist/server/services/websocket/quality-review-agent.d.ts +1 -1
  137. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  138. package/dist/server/services/websocket/quality-review-agent.js +105 -56
  139. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  140. package/dist/server/services/websocket/quality-service.d.ts +9 -1
  141. package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
  142. package/dist/server/services/websocket/quality-service.js +334 -14
  143. package/dist/server/services/websocket/quality-service.js.map +1 -1
  144. package/dist/server/services/websocket/quality-tools.d.ts +21 -0
  145. package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
  146. package/dist/server/services/websocket/quality-tools.js +49 -0
  147. package/dist/server/services/websocket/quality-tools.js.map +1 -1
  148. package/dist/server/services/websocket/quality-types.d.ts +35 -2
  149. package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
  150. package/dist/server/services/websocket/quality-types.js +1 -1
  151. package/dist/server/services/websocket/quality-types.js.map +1 -1
  152. package/dist/server/services/websocket/session-handlers.d.ts +3 -1
  153. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  154. package/dist/server/services/websocket/session-handlers.js +57 -9
  155. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  156. package/dist/server/services/websocket/session-history.js +3 -0
  157. package/dist/server/services/websocket/session-history.js.map +1 -1
  158. package/dist/server/services/websocket/session-initialization.d.ts.map +1 -1
  159. package/dist/server/services/websocket/session-initialization.js +158 -42
  160. package/dist/server/services/websocket/session-initialization.js.map +1 -1
  161. package/dist/server/services/websocket/session-registry.d.ts +25 -0
  162. package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
  163. package/dist/server/services/websocket/session-registry.js +19 -0
  164. package/dist/server/services/websocket/session-registry.js.map +1 -1
  165. package/dist/server/services/websocket/settings-handlers.d.ts +1 -1
  166. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -1
  167. package/dist/server/services/websocket/settings-handlers.js +35 -4
  168. package/dist/server/services/websocket/settings-handlers.js.map +1 -1
  169. package/dist/server/services/websocket/tab-broadcast.d.ts +7 -2
  170. package/dist/server/services/websocket/tab-broadcast.d.ts.map +1 -1
  171. package/dist/server/services/websocket/tab-broadcast.js +10 -2
  172. package/dist/server/services/websocket/tab-broadcast.js.map +1 -1
  173. package/dist/server/services/websocket/tab-event-buffer.d.ts +97 -8
  174. package/dist/server/services/websocket/tab-event-buffer.d.ts.map +1 -1
  175. package/dist/server/services/websocket/tab-event-buffer.js +138 -12
  176. package/dist/server/services/websocket/tab-event-buffer.js.map +1 -1
  177. package/dist/server/services/websocket/tab-event-replay.d.ts +29 -13
  178. package/dist/server/services/websocket/tab-event-replay.d.ts.map +1 -1
  179. package/dist/server/services/websocket/tab-event-replay.js +55 -2
  180. package/dist/server/services/websocket/tab-event-replay.js.map +1 -1
  181. package/dist/server/services/websocket/tab-handlers.d.ts +9 -1
  182. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
  183. package/dist/server/services/websocket/tab-handlers.js +47 -2
  184. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  185. package/dist/server/services/websocket/types.d.ts +28 -5
  186. package/dist/server/services/websocket/types.d.ts.map +1 -1
  187. package/dist/server/services/websocket/types.js +10 -4
  188. package/dist/server/services/websocket/types.js.map +1 -1
  189. package/package.json +5 -3
  190. package/server/cli/eta-estimator.ts +249 -0
  191. package/server/cli/headless/stall-assessor.ts +93 -0
  192. package/server/cli/headless/tool-watchdog.ts +21 -0
  193. package/server/cli/improvisation-history-store.ts +4 -1
  194. package/server/cli/improvisation-output-queue.ts +29 -7
  195. package/server/cli/improvisation-session-manager.ts +54 -1
  196. package/server/cli/improvisation-types.ts +2 -0
  197. package/server/engines/EngineEvent.ts +156 -0
  198. package/server/engines/claude/ClaudeCodeEngine.ts +404 -0
  199. package/server/engines/factory.ts +176 -0
  200. package/server/engines/opencode/OpenCodeEngine.ts +786 -0
  201. package/server/engines/opencode/OpenCodeServerManager.ts +577 -0
  202. package/server/engines/opencode/model-catalog.ts +217 -0
  203. package/server/engines/types.ts +173 -0
  204. package/server/index.ts +1 -1
  205. package/server/mcp/bouncer-haiku.ts +21 -145
  206. package/server/mcp/bouncer-integration.ts +107 -5
  207. package/server/mcp/classifier/BouncerClassifier.ts +40 -0
  208. package/server/mcp/classifier/ClaudeBouncerClassifier.ts +189 -0
  209. package/server/mcp/classifier/OpenCodeBouncerClassifier.ts +305 -0
  210. package/server/mcp/classifier/factory.ts +195 -0
  211. package/server/services/plan/agent-resolver.ts +115 -0
  212. package/server/services/plan/agents/code-review.md +38 -8
  213. package/server/services/plan/composer.ts +63 -11
  214. package/server/services/plan/executor.ts +3 -1
  215. package/server/services/plan/issue-prompt-builder.ts +39 -1
  216. package/server/services/plan/parser-core.ts +1 -0
  217. package/server/services/plan/types.ts +4 -0
  218. package/server/services/settings.ts +161 -4
  219. package/server/services/websocket/git-branch-handlers.ts +20 -6
  220. package/server/services/websocket/handler.ts +59 -2
  221. package/server/services/websocket/quality-complexity.ts +80 -26
  222. package/server/services/websocket/quality-eta.ts +155 -0
  223. package/server/services/websocket/quality-grading.ts +445 -222
  224. package/server/services/websocket/quality-handlers.ts +153 -7
  225. package/server/services/websocket/quality-operations.ts +72 -0
  226. package/server/services/websocket/quality-persistence.ts +17 -0
  227. package/server/services/websocket/quality-review-agent.ts +154 -64
  228. package/server/services/websocket/quality-service.ts +361 -13
  229. package/server/services/websocket/quality-tools.ts +51 -0
  230. package/server/services/websocket/quality-types.ts +41 -2
  231. package/server/services/websocket/session-handlers.ts +64 -10
  232. package/server/services/websocket/session-history.ts +3 -0
  233. package/server/services/websocket/session-initialization.ts +189 -46
  234. package/server/services/websocket/session-registry.ts +37 -0
  235. package/server/services/websocket/settings-handlers.ts +41 -4
  236. package/server/services/websocket/tab-broadcast.ts +10 -2
  237. package/server/services/websocket/tab-event-buffer.ts +143 -11
  238. package/server/services/websocket/tab-event-replay.ts +70 -3
  239. package/server/services/websocket/tab-handlers.ts +53 -5
  240. package/server/services/websocket/types.ts +37 -5
@@ -42,22 +42,59 @@ export interface BufferedEvent {
42
42
  data: unknown
43
43
  /** `Date.now()` at record time. Used for age-based eviction. */
44
44
  timestamp: number
45
+ /**
46
+ * Approximate serialized byte size of `data`. Computed once at record
47
+ * time so eviction can enforce a memory cap without re-stringifying on
48
+ * every check. Type and seq overhead is small; we only bill `data` here.
49
+ */
50
+ byteSize: number
45
51
  }
46
52
 
47
53
  /**
48
54
  * Bounded replay log for a single tab.
49
55
  *
50
- * Size/age limits are parameterised for testability but defaulted to values
51
- * that comfortably cover real-world reconnect windows.
56
+ * Size/age/byte limits are parameterised for testability but defaulted to
57
+ * values that comfortably cover real-world reconnect windows for long-running
58
+ * coding-agent tasks (multi-tool, multi-minute).
59
+ *
60
+ * ## Replay-gap detection
61
+ *
62
+ * The buffer tracks `evictedThroughSeq` — the highest seq that has ever been
63
+ * evicted (0 if nothing has been evicted). A web client whose `lastSeenSeq`
64
+ * is below this value has missed events the buffer can no longer supply, and
65
+ * an incremental replay would produce a silent gap. Callers should consult
66
+ * `hasGapSince` before relying on `getSince` for incremental replay; on a
67
+ * gap they should fall back to a full snapshot path (e.g. `outputHistory`).
68
+ *
69
+ * ## Eviction is FIFO with three caps
70
+ *
71
+ * Events are evicted from the front when ANY of these limits is exceeded:
72
+ * - count: `maxEvents` (default 10k)
73
+ * - age: `maxAgeMs` (default 60 min)
74
+ * - bytes: `maxTotalBytes` (default 32 MB)
75
+ *
76
+ * The byte cap is the safety belt against pathological events (e.g. a 50 MB
77
+ * grep result streamed as one event). Without it, count- and age-based caps
78
+ * still allow a single tab to hoard arbitrary memory.
52
79
  */
53
80
  export class TabEventBuffer {
54
81
  private readonly events: BufferedEvent[] = []
55
82
  private nextSeq = 1
83
+ /**
84
+ * Highest seq that has been evicted from the buffer. 0 means nothing has
85
+ * been evicted yet (buffer is operating within its window). Monotonically
86
+ * non-decreasing — eviction always happens from the front of the FIFO, in
87
+ * seq order, so the most recently evicted seq is always the highest.
88
+ */
89
+ private evictedThroughSeq = 0
90
+ /** Approximate sum of `byteSize` over still-resident events. */
91
+ private totalBytes = 0
56
92
 
57
93
  constructor(
58
94
  private readonly maxEvents: number = DEFAULT_MAX_EVENTS,
59
95
  private readonly maxAgeMs: number = DEFAULT_MAX_AGE_MS,
60
96
  private readonly now: () => number = Date.now,
97
+ private readonly maxTotalBytes: number = DEFAULT_MAX_TOTAL_BYTES,
61
98
  ) {}
62
99
 
63
100
  /**
@@ -69,7 +106,9 @@ export class TabEventBuffer {
69
106
  */
70
107
  record(type: string, data: unknown): number {
71
108
  const seq = this.nextSeq++
72
- this.events.push({ seq, type, data, timestamp: this.now() })
109
+ const byteSize = estimateByteSize(data)
110
+ this.events.push({ seq, type, data, timestamp: this.now(), byteSize })
111
+ this.totalBytes += byteSize
73
112
  this.evict()
74
113
  return seq
75
114
  }
@@ -78,6 +117,11 @@ export class TabEventBuffer {
78
117
  * Return all still-buffered events with `seq > afterSeq`, in original
79
118
  * order. Returns an empty array if nothing newer is buffered (either the
80
119
  * web is caught up or the window has rolled past).
120
+ *
121
+ * NOTE: This does not detect or signal replay gaps. Pair with
122
+ * `hasGapSince(afterSeq)` to know whether a returned array is a complete
123
+ * incremental replay or a partial one (events between `afterSeq` and the
124
+ * oldest surviving seq have been evicted and are no longer available).
81
125
  */
82
126
  getSince(afterSeq: number): BufferedEvent[] {
83
127
  this.evict()
@@ -88,6 +132,29 @@ export class TabEventBuffer {
88
132
  return out
89
133
  }
90
134
 
135
+ /**
136
+ * True when an incremental replay starting from `afterSeq` would silently
137
+ * skip events that the buffer has already evicted. Used by the replay
138
+ * orchestrator to decide whether to fall back to a full snapshot rather
139
+ * than emit a partial event stream the web can't reconstruct.
140
+ *
141
+ * `afterSeq < evictedThroughSeq` means the next event the caller expects
142
+ * (`afterSeq + 1`) is at or below the eviction frontier — that event has
143
+ * already been dropped from memory.
144
+ */
145
+ hasGapSince(afterSeq: number): boolean {
146
+ this.evict()
147
+ return afterSeq < this.evictedThroughSeq
148
+ }
149
+
150
+ /**
151
+ * Highest seq that has been evicted from this buffer; 0 if nothing has been
152
+ * evicted yet. Exposed for telemetry and gap-recovery decisions.
153
+ */
154
+ getEvictedThroughSeq(): number {
155
+ return this.evictedThroughSeq
156
+ }
157
+
91
158
  /** Current highest assigned seq (monotonic; not reset by eviction). */
92
159
  currentSeq(): number {
93
160
  return this.nextSeq - 1
@@ -98,20 +165,66 @@ export class TabEventBuffer {
98
165
  return this.events.length
99
166
  }
100
167
 
168
+ /** Approximate bytes held by `data` payloads currently in memory. For tests/telemetry. */
169
+ byteSize(): number {
170
+ return this.totalBytes
171
+ }
172
+
101
173
  /**
102
174
  * Drop events older than `maxAgeMs` from the front, then enforce
103
- * `maxEvents` by trimming the front further if needed. Eviction keeps the
104
- * newest events — they're the ones the web is most likely to still need.
175
+ * `maxEvents` and `maxTotalBytes` by trimming the front further if needed.
176
+ * Eviction keeps the newest events — they're the ones the web is most
177
+ * likely to still need.
178
+ *
179
+ * Each evicted seq advances `evictedThroughSeq` so callers can detect
180
+ * replay gaps. The FIFO ensures we always evict in seq order, so the last
181
+ * evicted seq is always the highest seen so far.
182
+ *
183
+ * The byte cap is enforced LAST so that count- and age-based eviction get
184
+ * a chance first; a chatty-but-small session evicts on age before it ever
185
+ * touches the byte cap, which keeps the usual case predictable.
105
186
  */
106
187
  private evict(): void {
107
188
  const cutoff = this.now() - this.maxAgeMs
108
189
  while (this.events.length > 0 && this.events[0].timestamp < cutoff) {
109
- this.events.shift()
190
+ this.popOldest()
110
191
  }
111
192
  while (this.events.length > this.maxEvents) {
112
- this.events.shift()
193
+ this.popOldest()
194
+ }
195
+ while (this.events.length > 0 && this.totalBytes > this.maxTotalBytes) {
196
+ this.popOldest()
113
197
  }
114
198
  }
199
+
200
+ private popOldest(): void {
201
+ const evicted = this.events.shift()
202
+ if (!evicted) return
203
+ this.evictedThroughSeq = evicted.seq
204
+ this.totalBytes -= evicted.byteSize
205
+ if (this.totalBytes < 0) this.totalBytes = 0
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Estimate `data`'s serialized byte size for the eviction byte cap. Uses
211
+ * `JSON.stringify` because that's what hits the wire; falls back to a small
212
+ * default on circular structures so we don't crash the broadcast path.
213
+ *
214
+ * `Buffer.byteLength` would give us UTF-8 bytes vs UTF-16 code units, but on
215
+ * Node `JSON.stringify(...).length` is close enough (within a small constant
216
+ * factor for ASCII-heavy payloads) and avoids an extra allocation.
217
+ */
218
+ function estimateByteSize(data: unknown): number {
219
+ if (data === undefined || data === null) return 0
220
+ try {
221
+ return JSON.stringify(data).length
222
+ } catch {
223
+ // Circular reference, BigInt, etc. — bill a small fixed cost so the
224
+ // byte cap still has SOME signal. We won't be able to wire-serialize
225
+ // this either, but that's a separate problem.
226
+ return 256
227
+ }
115
228
  }
116
229
 
117
230
  /**
@@ -152,7 +265,26 @@ export class TabEventBufferRegistry {
152
265
  }
153
266
  }
154
267
 
155
- /** 1000 events per tab covers typical reconnect windows comfortably. */
156
- export const DEFAULT_MAX_EVENTS = 1000
157
- /** 15 minutes of history is more than enough for the longest plausible web reconnect. */
158
- export const DEFAULT_MAX_AGE_MS = 15 * 60 * 1000
268
+ /**
269
+ * 10,000 events per tab.
270
+ *
271
+ * Sized for long-running coding-agent tasks (multi-tool, multi-minute) plus
272
+ * laptop sleep/wake reconnect windows. Worst-case observed: a 14-minute
273
+ * session with ~120 tool calls produces ~1.5–3k tab-scoped events; 10× that
274
+ * gives headroom for parallel agents and chatty improvisation. Memory
275
+ * footprint at ~500B/event = ~5MB per tab; the local-only single-tenant
276
+ * deployment makes this a non-issue.
277
+ */
278
+ export const DEFAULT_MAX_EVENTS = 10_000
279
+ /**
280
+ * 60 minutes of history. Covers laptop sleep/wake, long meetings between
281
+ * sessions, and the largest plausible reconnect window that a tab might
282
+ * legitimately want to recover incrementally instead of starting fresh.
283
+ */
284
+ export const DEFAULT_MAX_AGE_MS = 60 * 60 * 1000
285
+ /**
286
+ * 32 MB safety belt against pathological events (large grep results, full
287
+ * file reads streamed inline). Eviction by bytes guarantees a single tab
288
+ * can't hoard arbitrary memory regardless of count/age limits.
289
+ */
290
+ export const DEFAULT_MAX_TOTAL_BYTES = 32 * 1024 * 1024
@@ -13,23 +13,88 @@
13
13
  * events live.
14
14
  */
15
15
 
16
+ import { captureException } from '../sentry.js'
16
17
  import type { HandlerContext } from './handler-context.js'
17
18
  import type { WebSocketResponse, WSContext } from './types.js'
18
19
 
20
+ /** Result of a replay attempt — used by callers (and tests) for telemetry. */
21
+ export interface ReplayResult {
22
+ /** Number of events sent to the web during this replay. */
23
+ sentCount: number
24
+ /**
25
+ * True when the buffer had already evicted events that fell between the
26
+ * web's `lastSeenSeq` and the oldest surviving seq. The replay is partial;
27
+ * the web's incremental state is now provably stale and the caller should
28
+ * fall back to a full snapshot path (e.g. `outputHistory`).
29
+ */
30
+ hadGap: boolean
31
+ /**
32
+ * If `hadGap`, the highest seq that was evicted (so the gap range is
33
+ * `(lastSeenSeq + 1) .. evictedThroughSeq`). Undefined when no gap.
34
+ */
35
+ evictedThroughSeq?: number
36
+ /**
37
+ * If `hadGap`, the seq the web requested replay from. Echoed into
38
+ * telemetry so log entries are self-contained.
39
+ */
40
+ lastSeenSeq?: number
41
+ }
42
+
19
43
  /**
20
44
  * Replay tab events with `seq > lastSeenSeq` to `ws`. Silently no-ops when
21
45
  * the buffer is empty or `lastSeenSeq` is unset (full init, not a resume).
46
+ *
47
+ * Returns a `ReplayResult` so the caller can detect a partial replay (the
48
+ * buffer evicted events the web is asking about) and decide whether to send
49
+ * a recovery snapshot. This is the load-bearing telemetry surface for the
50
+ * "long-running task output disappears mid-stream" failure mode — a `hadGap`
51
+ * here is the smoking gun.
22
52
  */
23
53
  export function replayTabEventsSince(
24
54
  ctx: HandlerContext,
25
55
  ws: WSContext,
26
56
  tabId: string,
27
57
  lastSeenSeq: number | undefined,
28
- ): void {
29
- if (lastSeenSeq === undefined) return
58
+ ): ReplayResult {
59
+ if (lastSeenSeq === undefined) return { sentCount: 0, hadGap: false }
30
60
 
31
61
  const buffer = ctx.tabEventBuffers.get(tabId)
32
- if (!buffer) return
62
+ if (!buffer) return { sentCount: 0, hadGap: false }
63
+
64
+ const hadGap = buffer.hasGapSince(lastSeenSeq)
65
+ const evictedThroughSeq = hadGap ? buffer.getEvictedThroughSeq() : undefined
66
+
67
+ if (hadGap) {
68
+ // Replay is structurally incomplete. Surface a single, structured warning
69
+ // so we can grep/Sentry-search for the failure mode without spamming logs
70
+ // on every event.
71
+ const message =
72
+ `[tab-replay] gap detected for tab=${tabId}: web requested replay from seq=${lastSeenSeq}, ` +
73
+ `but buffer has evicted through seq=${evictedThroughSeq}. ` +
74
+ `Events (${lastSeenSeq + 1}..${evictedThroughSeq}) are unavailable; the web's ` +
75
+ `incremental state is stale and a full snapshot will be sent instead.`
76
+ console.warn(message)
77
+ try {
78
+ captureException(new Error('TabEventBuffer replay gap'), {
79
+ context: 'tab-event-replay',
80
+ tabId,
81
+ lastSeenSeq,
82
+ evictedThroughSeq,
83
+ bufferCurrentSeq: buffer.currentSeq(),
84
+ gapSize: (evictedThroughSeq ?? 0) - lastSeenSeq,
85
+ })
86
+ } catch {
87
+ // Sentry transport errors must not break the replay path.
88
+ }
89
+ // CRITICAL: do NOT emit partial events. If we did, the web would advance
90
+ // its `tabSeqs` past the (lastSeenSeq+1 .. evictedThroughSeq) range and
91
+ // the subsequent snapshot would land in a tab that thinks it's caught up
92
+ // — silently rendering only the post-gap tail. Returning early without
93
+ // events forces the caller (`session-initialization.ts`) into the
94
+ // snapshot-fallback branch, which sends a fresh `outputHistory` payload
95
+ // with `replayGap: true` so the web can replace its tab state cleanly.
96
+ return { sentCount: 0, hadGap: true, evictedThroughSeq, lastSeenSeq }
97
+ }
33
98
 
34
99
  const events = buffer.getSince(lastSeenSeq)
35
100
  for (const event of events) {
@@ -38,4 +103,6 @@ export function replayTabEventsSince(
38
103
  // `WebSocketResponse['type']`. Narrow here without an extra runtime check.
39
104
  ctx.send(ws, { type: event.type as WebSocketResponse['type'], tabId, data: event.data, seq: event.seq })
40
105
  }
106
+
107
+ return { sentCount: events.length, hadGap: false, lastSeenSeq }
41
108
  }
@@ -3,11 +3,12 @@
3
3
  import { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
4
4
  import { getEffortLevel, getModel } from '../settings.js';
5
5
  import type { HandlerContext } from './handler-context.js';
6
- import { buildOutputHistory, setupSessionListeners } from './session-handlers.js';
7
- import type { WebSocketMessage, WSContext } from './types.js';
6
+ import { buildOutputHistory, resolveEngineForSession, setupSessionListeners } from './session-handlers.js';
7
+ import type { TabEngineOverride } from './session-registry.js';
8
+ import { DEFAULT_ENGINE_ID, type EngineId, type WebSocketMessage, type WSContext } from './types.js';
8
9
 
9
10
  function buildActiveTabData(
10
- regTab: { tabName: string; createdAt: string; order: number; hasUnviewedCompletion?: boolean; sessionId: string },
11
+ regTab: { tabName: string; createdAt: string; order: number; hasUnviewedCompletion?: boolean; sessionId: string; engineOverride?: TabEngineOverride },
11
12
  session: ImprovisationSessionManager,
12
13
  worktreePath: string | undefined,
13
14
  worktreeBranch: string | undefined,
@@ -17,17 +18,19 @@ function buildActiveTabData(
17
18
  createdAt: regTab.createdAt,
18
19
  order: regTab.order,
19
20
  hasUnviewedCompletion: regTab.hasUnviewedCompletion,
21
+ engine: resolveEngineForSession(session),
20
22
  sessionInfo: session.getSessionInfo(),
21
23
  isExecuting: session.isExecuting,
22
24
  outputHistory: buildOutputHistory(session),
23
25
  executionEvents: session.isExecuting ? session.getExecutionEventLog() : undefined,
24
26
  ...(session.isExecuting && session.executionStartTimestamp ? { executionStartTimestamp: session.executionStartTimestamp } : {}),
25
27
  ...(worktreePath ? { worktreePath, worktreeBranch } : {}),
28
+ ...(regTab.engineOverride ? { engineOverride: regTab.engineOverride } : {}),
26
29
  };
27
30
  }
28
31
 
29
32
  function buildInactiveTabData(
30
- regTab: { tabName: string; createdAt: string; order: number; hasUnviewedCompletion?: boolean; sessionId: string },
33
+ regTab: { tabName: string; createdAt: string; order: number; hasUnviewedCompletion?: boolean; sessionId: string; engineOverride?: TabEngineOverride },
31
34
  worktreePath: string | undefined,
32
35
  worktreeBranch: string | undefined,
33
36
  ): Record<string, unknown> {
@@ -36,10 +39,12 @@ function buildInactiveTabData(
36
39
  createdAt: regTab.createdAt,
37
40
  order: regTab.order,
38
41
  hasUnviewedCompletion: regTab.hasUnviewedCompletion,
42
+ engine: DEFAULT_ENGINE_ID,
39
43
  sessionId: regTab.sessionId,
40
44
  isExecuting: false,
41
45
  outputHistory: [],
42
46
  ...(worktreePath ? { worktreePath, worktreeBranch } : {}),
47
+ ...(regTab.engineOverride ? { engineOverride: regTab.engineOverride } : {}),
43
48
  };
44
49
  }
45
50
 
@@ -101,6 +106,41 @@ export function handleMarkTabViewed(ctx: HandlerContext, _ws: WSContext, tabId:
101
106
  });
102
107
  }
103
108
 
109
+ /**
110
+ * Persist a per-tab engine override. `msg.data.override` is either a full
111
+ * `{ engine, model, effortLevel }` payload or `null` to clear the override.
112
+ * Persisted via the session registry so the override survives WebSocket
113
+ * disconnects — the core guarantee of IS-019. Broadcasts the change to all
114
+ * connected clients so multi-device sessions stay in sync.
115
+ */
116
+ export function handleSetTabEngine(ctx: HandlerContext, _ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
117
+ const raw = msg.data?.override;
118
+ let override: TabEngineOverride | null;
119
+ if (raw === null || raw === undefined) {
120
+ override = null;
121
+ } else if (
122
+ typeof raw === 'object' &&
123
+ (raw.engine === 'claude-code' || raw.engine === 'opencode') &&
124
+ typeof raw.model === 'string' && raw.model.length > 0 &&
125
+ typeof raw.effortLevel === 'string' && raw.effortLevel.length > 0
126
+ ) {
127
+ override = { engine: raw.engine, model: raw.model, effortLevel: raw.effortLevel };
128
+ } else {
129
+ // Malformed payload — ignore rather than crash. The client will re-emit
130
+ // from the canonical server-side value on the next reconnect.
131
+ return;
132
+ }
133
+
134
+ const registry = ctx.getRegistry(workingDir);
135
+ registry.updateTabEngineOverride(tabId, override);
136
+
137
+ ctx.broadcastToAll({
138
+ type: 'tabEngineOverride',
139
+ tabId,
140
+ data: { tabId, override },
141
+ });
142
+ }
143
+
104
144
  export async function handleCreateTab(ctx: HandlerContext, ws: WSContext, workingDir: string, tabName?: string, optimisticTabId?: string): Promise<void> {
105
145
  const registry = ctx.getRegistry(workingDir);
106
146
 
@@ -109,14 +149,18 @@ export async function handleCreateTab(ctx: HandlerContext, ws: WSContext, workin
109
149
  const existingSession = registry.getTabSession(tabId);
110
150
  if (existingSession) {
111
151
  const regTab = registry.getTab(tabId);
152
+ const existingSessionObj = ctx.sessions.get(existingSession);
153
+ const engine: EngineId = resolveEngineForSession(existingSessionObj);
112
154
  ctx.broadcastToAll({
113
155
  type: 'tabCreated',
156
+ engine,
114
157
  data: {
115
158
  tabId,
116
159
  tabName: regTab?.tabName || 'Chat',
117
160
  createdAt: regTab?.createdAt,
118
161
  order: regTab?.order,
119
- sessionInfo: ctx.sessions.get(existingSession)?.getSessionInfo(),
162
+ engine,
163
+ sessionInfo: existingSessionObj?.getSessionInfo(),
120
164
  }
121
165
  });
122
166
  return;
@@ -133,14 +177,17 @@ export async function handleCreateTab(ctx: HandlerContext, ws: WSContext, workin
133
177
 
134
178
  registry.registerTab(tabId, sessionId, tabName);
135
179
  const registeredTab = registry.getTab(tabId);
180
+ const engine: EngineId = resolveEngineForSession(session);
136
181
 
137
182
  ctx.broadcastToAll({
138
183
  type: 'tabCreated',
184
+ engine,
139
185
  data: {
140
186
  tabId,
141
187
  tabName: registeredTab?.tabName || 'Chat',
142
188
  createdAt: registeredTab?.createdAt,
143
189
  order: registeredTab?.order,
190
+ engine,
144
191
  sessionInfo: session.getSessionInfo(),
145
192
  }
146
193
  });
@@ -148,6 +195,7 @@ export async function handleCreateTab(ctx: HandlerContext, ws: WSContext, workin
148
195
  ctx.send(ws, {
149
196
  type: 'tabInitialized',
150
197
  tabId,
198
+ engine,
151
199
  data: session.getSessionInfo()
152
200
  });
153
201
  }
@@ -37,11 +37,11 @@ const GitWorktreeMessages = ['gitWorktreeList', 'gitWorktreeCreate', 'gitWorktre
37
37
 
38
38
  const GitMergeMessages = ['gitMergePreview', 'gitWorktreeMerge', 'gitMergeAbort', 'gitMergeComplete', 'gitMergeStashPop', 'gitMergeDiscardBlockers'] as const;
39
39
 
40
- const SessionSyncMessages = ['getActiveTabs', 'createTab', 'reorderTabs', 'syncTabMeta', 'removeTab', 'markTabViewed'] as const;
40
+ const SessionSyncMessages = ['getActiveTabs', 'createTab', 'reorderTabs', 'syncTabMeta', 'removeTab', 'markTabViewed', 'setTabEngine'] as const;
41
41
 
42
42
  const SettingsMessages = ['getSettings', 'updateSettings'] as const;
43
43
 
44
- const QualityMessages = ['qualityDetectTools', 'qualityScan', 'qualityInstallTools', 'qualityCodeReview', 'qualityLoadState', 'qualitySaveDirectories'] as const;
44
+ const QualityMessages = ['qualityDetectTools', 'qualityScan', 'qualityInstallTools', 'qualityCodeReview', 'qualityCancel', 'qualityLoadState', 'qualityClearPending', 'qualitySaveDirectories'] as const;
45
45
 
46
46
  const FileUploadMessages = ['fileUploadStart', 'fileUploadChunk', 'fileUploadComplete', 'fileUploadCancel'] as const;
47
47
 
@@ -55,6 +55,8 @@ const PlanSprintMessages = ['planCreateSprint', 'planActivateSprint', 'planCompl
55
55
 
56
56
  const SkillMessages = ['listSkills', 'chatToBoard'] as const;
57
57
 
58
+ const InstanceMessages = ['shutdownInstance'] as const;
59
+
58
60
  type WebSocketMessageType =
59
61
  | typeof CoreMessages[number]
60
62
  | typeof TerminalMessages[number]
@@ -73,12 +75,33 @@ type WebSocketMessageType =
73
75
  | typeof PlanMessages[number]
74
76
  | typeof PlanBoardMessages[number]
75
77
  | typeof PlanSprintMessages[number]
76
- | typeof SkillMessages[number];
78
+ | typeof SkillMessages[number]
79
+ | typeof InstanceMessages[number];
80
+
81
+ /**
82
+ * AI engine identifier carried on WebSocket envelopes so the web client can
83
+ * render engine-specific affordances. Server relay forwards the field
84
+ * unchanged; missing values on inbound messages default to 'claude-code'.
85
+ */
86
+ export type EngineId = 'claude-code' | 'opencode';
87
+
88
+ export const DEFAULT_ENGINE_ID: EngineId = 'claude-code';
89
+
90
+ /** Narrow an unknown engine value to a valid EngineId, defaulting to 'claude-code'. */
91
+ export function normalizeEngineId(value: unknown): EngineId {
92
+ return value === 'opencode' ? 'opencode' : DEFAULT_ENGINE_ID;
93
+ }
77
94
 
78
95
  export interface WebSocketMessage {
79
96
  type: WebSocketMessageType;
80
97
  tabId?: string;
81
98
  terminalId?: string;
99
+ /**
100
+ * Engine that produced / should handle this message. Optional on the wire —
101
+ * missing field is treated as 'claude-code' by receivers. Populated on
102
+ * prompt-send, session-state, and tab-state messages.
103
+ */
104
+ engine?: EngineId;
82
105
  // biome-ignore lint/suspicious/noExplicitAny: message envelope carries heterogeneous payloads — typed per-handler via destructuring
83
106
  data?: any;
84
107
  /** Injected by server relay for view-only shared users */
@@ -103,7 +126,7 @@ const GitWorktreeResponseMessages = ['gitWorktreeListResult', 'gitWorktreeCreate
103
126
 
104
127
  const GitMergeResponseMessages = ['gitMergePreviewResult', 'gitWorktreeMergeResult', 'gitMergeAborted', 'gitMergeCompleted', 'gitMergeStashPopped', 'gitMergeBlockersDiscarded'] as const;
105
128
 
106
- const SessionSyncResponseMessages = ['activeTabs', 'tabCreated', 'tabRemoved', 'tabRenamed', 'tabsReordered', 'tabViewed', 'tabStateChanged'] as const;
129
+ const SessionSyncResponseMessages = ['activeTabs', 'tabCreated', 'tabRemoved', 'tabRenamed', 'tabsReordered', 'promptTextSync', 'tabViewed', 'tabStateChanged', 'tabEngineOverride'] as const;
107
130
 
108
131
  const SettingsResponseMessages = ['settings', 'settingsUpdated'] as const;
109
132
 
@@ -121,6 +144,8 @@ const PlanSprintResponseMessages = ['planSprintCreated', 'planSprintUpdated', 'p
121
144
 
122
145
  const SkillResponseMessages = ['skillsList', 'chatToBoardCreated'] as const;
123
146
 
147
+ const InstanceResponseMessages = ['shuttingDown'] as const;
148
+
124
149
  type WebSocketResponseType =
125
150
  | typeof CoreResponseMessages[number]
126
151
  | typeof TerminalResponseMessages[number]
@@ -139,12 +164,19 @@ type WebSocketResponseType =
139
164
  | typeof PlanResponseMessages[number]
140
165
  | typeof PlanBoardResponseMessages[number]
141
166
  | typeof PlanSprintResponseMessages[number]
142
- | typeof SkillResponseMessages[number];
167
+ | typeof SkillResponseMessages[number]
168
+ | typeof InstanceResponseMessages[number];
143
169
 
144
170
  export interface WebSocketResponse {
145
171
  type: WebSocketResponseType;
146
172
  tabId?: string;
147
173
  terminalId?: string;
174
+ /**
175
+ * Engine that produced this response. Populated by CLI handlers for
176
+ * prompt-send, session-state, and tab-state message categories so the web
177
+ * client can render engine-specific affordances.
178
+ */
179
+ engine?: EngineId;
148
180
  // biome-ignore lint/suspicious/noExplicitAny: message envelope carries heterogeneous payloads — typed per-handler via destructuring
149
181
  data?: any;
150
182
  /**