@myrialabs/clopen 0.2.5 → 0.2.7

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 (43) hide show
  1. package/backend/chat/stream-manager.ts +136 -10
  2. package/backend/database/queries/session-queries.ts +9 -0
  3. package/backend/engine/adapters/claude/error-handler.ts +7 -2
  4. package/backend/engine/adapters/claude/stream.ts +16 -7
  5. package/backend/index.ts +25 -3
  6. package/backend/mcp/servers/browser-automation/browser.ts +23 -6
  7. package/backend/preview/browser/browser-mcp-control.ts +32 -16
  8. package/backend/preview/browser/browser-pool.ts +3 -1
  9. package/backend/preview/browser/browser-preview-service.ts +16 -17
  10. package/backend/preview/browser/browser-tab-manager.ts +1 -1
  11. package/backend/preview/browser/browser-video-capture.ts +199 -156
  12. package/backend/preview/browser/scripts/audio-stream.ts +11 -0
  13. package/backend/preview/browser/scripts/video-stream.ts +3 -5
  14. package/backend/snapshot/helpers.ts +15 -2
  15. package/backend/ws/chat/stream.ts +1 -1
  16. package/backend/ws/preview/browser/tab-info.ts +5 -2
  17. package/backend/ws/snapshot/restore.ts +43 -2
  18. package/frontend/components/chat/input/ChatInput.svelte +6 -4
  19. package/frontend/components/chat/input/components/ChatInputActions.svelte +10 -0
  20. package/frontend/components/chat/input/composables/use-chat-actions.svelte.ts +6 -2
  21. package/frontend/components/chat/message/MessageBubble.svelte +22 -1
  22. package/frontend/components/chat/tools/components/TerminalCommand.svelte +2 -2
  23. package/frontend/components/files/FileViewer.svelte +13 -2
  24. package/frontend/components/history/HistoryModal.svelte +1 -1
  25. package/frontend/components/preview/browser/BrowserPreview.svelte +15 -0
  26. package/frontend/components/preview/browser/components/Canvas.svelte +432 -69
  27. package/frontend/components/preview/browser/components/Container.svelte +23 -1
  28. package/frontend/components/preview/browser/components/Toolbar.svelte +1 -8
  29. package/frontend/components/preview/browser/core/coordinator.svelte.ts +27 -4
  30. package/frontend/components/preview/browser/core/mcp-handlers.svelte.ts +36 -3
  31. package/frontend/components/preview/browser/core/tab-operations.svelte.ts +1 -0
  32. package/frontend/components/terminal/TerminalTabs.svelte +1 -2
  33. package/frontend/components/workspace/PanelHeader.svelte +15 -0
  34. package/frontend/components/workspace/panels/FilesPanel.svelte +1 -0
  35. package/frontend/components/workspace/panels/GitPanel.svelte +27 -13
  36. package/frontend/components/workspace/panels/PreviewPanel.svelte +2 -0
  37. package/frontend/services/chat/chat.service.ts +9 -8
  38. package/frontend/services/preview/browser/browser-webcodecs.service.ts +43 -138
  39. package/frontend/stores/core/app.svelte.ts +4 -3
  40. package/frontend/stores/core/presence.svelte.ts +3 -2
  41. package/frontend/stores/core/sessions.svelte.ts +2 -0
  42. package/frontend/stores/ui/notification.svelte.ts +4 -1
  43. package/package.json +1 -1
@@ -41,6 +41,7 @@ export interface StreamState {
41
41
  abortController?: AbortController;
42
42
  streamPromise?: Promise<void>;
43
43
  sdkSessionId?: string;
44
+ preStreamSdkSessionId?: string | null; // latest_sdk_session_id before this stream started
44
45
  hasCompactBoundary?: boolean;
45
46
  eventSeq: number; // Sequence number for deduplication
46
47
  }
@@ -132,7 +133,17 @@ class StreamManager extends EventEmitter {
132
133
  const existingStream = this.activeStreams.get(existingStreamId);
133
134
  if (existingStream && existingStream.status === 'active') {
134
135
  if (existingStream.projectId === request.projectId) {
135
- return existingStreamId;
136
+ if ((request.engine || 'claude-code') === 'claude-code') {
137
+ // Claude Code: cancel existing stream to prevent message loss from race condition.
138
+ // Claude Code SDK only returns session_id inside yielded messages, so a cancelled
139
+ // stream may never have established a valid session — safe to cancel and restart.
140
+ debug.log('chat', `Cancelling existing active stream ${existingStreamId} before starting new one`);
141
+ await this.cancelStream(existingStreamId);
142
+ } else {
143
+ // Other engines (OpenCode): return existing stream ID (original behavior).
144
+ // OpenCode creates sessions synchronously, so the existing stream is valid.
145
+ return existingStreamId;
146
+ }
136
147
  }
137
148
  }
138
149
  }
@@ -302,6 +313,9 @@ class StreamManager extends EventEmitter {
302
313
  }
303
314
  }
304
315
 
316
+ // Store pre-stream session ID so cancelStream() can restore it
317
+ streamState.preStreamSdkSessionId = resumeSessionId ?? null;
318
+
305
319
  // Prepare user message
306
320
  const userMessage = {
307
321
  ...(prompt as SDKMessage),
@@ -378,10 +392,102 @@ class StreamManager extends EventEmitter {
378
392
  return;
379
393
  }
380
394
 
395
+ // Detect orphaned user messages and prepend context (claude-code only).
396
+ // When a stream is cancelled before the SDK returns a session_id,
397
+ // the user's message is saved to DB but unknown to the SDK session.
398
+ // We prepend those orphaned messages as context so the AI has full history.
399
+ let enginePrompt = prompt;
400
+ if (engineType === 'claude-code' && chatSessionId) {
401
+ try {
402
+ const head = sessionQueries.getHead(chatSessionId);
403
+ if (head) {
404
+ const chain = messageQueries.getPathToRoot(head);
405
+ // Remove the current user message (last in chain, just saved)
406
+ const previousChain = chain.slice(0, -1);
407
+
408
+ if (previousChain.length > 0) {
409
+ // Find boundary: last message with session_id matching resumeSessionId
410
+ let boundaryIndex = -1;
411
+
412
+ if (resumeSessionId) {
413
+ for (let i = previousChain.length - 1; i >= 0; i--) {
414
+ try {
415
+ const sdk = JSON.parse(previousChain[i].sdk_message);
416
+ if (sdk.session_id === resumeSessionId) {
417
+ boundaryIndex = i;
418
+ break;
419
+ }
420
+ } catch { /* skip unparseable */ }
421
+ }
422
+ }
423
+
424
+ // Collect orphaned user messages after boundary
425
+ const orphanedUserTexts: string[] = [];
426
+ for (let i = boundaryIndex + 1; i < previousChain.length; i++) {
427
+ try {
428
+ const sdk = JSON.parse(previousChain[i].sdk_message);
429
+ if (sdk.type === 'user') {
430
+ const content = sdk.message?.content;
431
+ let text = '';
432
+ if (typeof content === 'string') {
433
+ text = content;
434
+ } else if (Array.isArray(content)) {
435
+ text = content
436
+ .filter((block: any) => block.type === 'text')
437
+ .map((block: any) => block.text)
438
+ .join('\n');
439
+ }
440
+ if (text.trim()) {
441
+ orphanedUserTexts.push(text.trim());
442
+ }
443
+ }
444
+ } catch { /* skip unparseable */ }
445
+ }
446
+
447
+ // Prepend context if there are orphaned messages
448
+ if (orphanedUserTexts.length > 0) {
449
+ debug.log('chat', `Prepending ${orphanedUserTexts.length} orphaned user message(s) as context`);
450
+
451
+ const contextPrefix = [
452
+ '[Previous unprocessed messages from the user:]',
453
+ ...orphanedUserTexts.map((text, i) => `${i + 1}. "${text}"`),
454
+ '',
455
+ '[Current message:]'
456
+ ].join('\n');
457
+
458
+ const originalContent = prompt.message.content;
459
+ let modifiedContent: typeof originalContent;
460
+
461
+ if (typeof originalContent === 'string') {
462
+ modifiedContent = contextPrefix + '\n' + originalContent;
463
+ } else if (Array.isArray(originalContent)) {
464
+ modifiedContent = [
465
+ { type: 'text' as const, text: contextPrefix },
466
+ ...originalContent
467
+ ];
468
+ } else {
469
+ modifiedContent = originalContent;
470
+ }
471
+
472
+ enginePrompt = {
473
+ ...prompt,
474
+ message: {
475
+ ...prompt.message,
476
+ content: modifiedContent
477
+ }
478
+ } as SDKUserMessage;
479
+ }
480
+ }
481
+ }
482
+ } catch (error) {
483
+ debug.error('chat', 'Failed to detect orphaned messages:', error);
484
+ }
485
+ }
486
+
381
487
  // Stream messages through the engine adapter
382
488
  for await (const message of engine.streamQuery({
383
489
  projectPath: actualProjectPath,
384
- prompt: prompt,
490
+ prompt: enginePrompt,
385
491
  resume: resumeSessionId,
386
492
  model: model || 'sonnet',
387
493
  includePartialMessages: true,
@@ -1009,15 +1115,28 @@ class StreamManager extends EventEmitter {
1009
1115
  }
1010
1116
  }
1011
1117
 
1012
- // Abort the stream-manager's controller FIRST.
1013
- // This ensures processStream() sees the abort signal at its next check point
1014
- // and avoids starting a new engine query with an already-aborted controller.
1015
- streamState.abortController?.abort();
1118
+ // Claude Code only: restore latest_sdk_session_id to pre-stream value.
1119
+ // Claude Code SDK only returns session_id inside yielded messages, so a cancelled
1120
+ // stream's fork session_id is not a valid resume target. OpenCode creates sessions
1121
+ // synchronously, so its session_id is always valid — no restoration needed.
1122
+ if (streamState.engine === 'claude-code' && streamState.chatSessionId && streamState.preStreamSdkSessionId !== undefined) {
1123
+ try {
1124
+ if (streamState.preStreamSdkSessionId) {
1125
+ sessionQueries.updateLatestSdkSessionId(streamState.chatSessionId, streamState.preStreamSdkSessionId);
1126
+ } else {
1127
+ sessionQueries.clearLatestSdkSessionId(streamState.chatSessionId);
1128
+ }
1129
+ debug.log('chat', `Restored latest_sdk_session_id to: ${streamState.preStreamSdkSessionId || 'null'}`);
1130
+ } catch (error) {
1131
+ debug.error('chat', 'Failed to restore latest_sdk_session_id:', error);
1132
+ }
1133
+ }
1016
1134
 
1017
- // Cancel the per-project engine — this is safe because each project
1018
- // has its own engine instance (via getProjectEngine), so cancel()
1019
- // only affects this project's active query/session, not other projects.
1020
- // Wrapped in try/catch to prevent SDK errors from crashing the server.
1135
+ // Cancel the per-project engine — this sends an interrupt to the
1136
+ // still-alive SDK subprocess, then aborts the controller. If we abort
1137
+ // the controller first, the subprocess dies and the SDK's subsequent
1138
+ // interrupt write fails with "Operation aborted" (unhandled rejection
1139
+ // that crashes Bun).
1021
1140
  const projectId = streamState.projectId || 'default';
1022
1141
  try {
1023
1142
  const engine = getProjectEngine(projectId, streamState.engine);
@@ -1028,6 +1147,13 @@ class StreamManager extends EventEmitter {
1028
1147
  debug.error('chat', 'Error cancelling engine (non-fatal):', error);
1029
1148
  }
1030
1149
 
1150
+ // Abort the stream-manager's controller as a fallback.
1151
+ // engine.cancel() already aborts the same controller, so this is
1152
+ // typically a no-op but ensures cleanup if the engine wasn't active.
1153
+ if (!streamState.abortController?.signal.aborted) {
1154
+ streamState.abortController?.abort();
1155
+ }
1156
+
1031
1157
  this.emitStreamEvent(streamState, 'cancelled', {
1032
1158
  processId: streamState.processId,
1033
1159
  timestamp: streamState.completedAt.toISOString()
@@ -66,6 +66,15 @@ export const sessionQueries = {
66
66
  `).run(sdkSessionId, id);
67
67
  },
68
68
 
69
+ clearLatestSdkSessionId(id: string): void {
70
+ const db = getDatabase();
71
+ db.prepare(`
72
+ UPDATE chat_sessions
73
+ SET latest_sdk_session_id = NULL
74
+ WHERE id = ?
75
+ `).run(id);
76
+ },
77
+
69
78
  updateEngineModel(id: string, engine: string, model: string): void {
70
79
  const db = getDatabase();
71
80
  db.prepare(`
@@ -3,8 +3,13 @@ export function handleStreamError(error: unknown): void {
3
3
  throw error;
4
4
  }
5
5
 
6
- // Abort errors are expected during cancellation - don't re-throw
7
- if (error.name === 'AbortError' || error.message.includes('aborted') || error.message.includes('abort')) {
6
+ // Abort errors are expected during cancellation - don't re-throw.
7
+ // "Operation aborted" comes from the SDK's internal write() when the
8
+ // subprocess is killed during handleControlRequest.
9
+ if (error.name === 'AbortError'
10
+ || error.message.includes('aborted')
11
+ || error.message.includes('abort')
12
+ || error.message === 'Operation aborted') {
8
13
  return;
9
14
  }
10
15
 
@@ -180,21 +180,30 @@ export class ClaudeCodeEngine implements AIEngine {
180
180
  * Cancel active query
181
181
  */
182
182
  async cancel(): Promise<void> {
183
- if (this.activeQuery && typeof this.activeQuery.interrupt === 'function') {
183
+ // Resolve all pending AskUserQuestion promises before terminating.
184
+ for (const [, pending] of this.pendingUserAnswers) {
185
+ pending.resolve({ behavior: 'deny', message: 'Cancelled' });
186
+ }
187
+ this.pendingUserAnswers.clear();
188
+
189
+ // Use close() to forcefully terminate the query process and clean up
190
+ // all resources (docs: "Forcefully ends the query and cleans up all
191
+ // resources"). Unlike interrupt() which can hang indefinitely when the
192
+ // subprocess is unresponsive, close() is synchronous and guaranteed to
193
+ // complete — making cancel deterministic.
194
+ if (this.activeQuery && typeof this.activeQuery.close === 'function') {
184
195
  try {
185
- await this.activeQuery.interrupt();
196
+ this.activeQuery.close();
186
197
  } catch {
187
- // Ignore interrupt errors
198
+ // Ignore close errors — process may already be dead
188
199
  }
189
200
  }
190
201
 
191
- if (this.activeController) {
202
+ if (this.activeController && !this.activeController.signal.aborted) {
192
203
  this.activeController.abort();
193
- this.activeController = null;
194
204
  }
205
+ this.activeController = null;
195
206
  this.activeQuery = null;
196
- // Reject all pending user answer promises (abort signal handles this, but clean up the map)
197
- this.pendingUserAnswers.clear();
198
207
  }
199
208
 
200
209
  /**
package/backend/index.ts CHANGED
@@ -180,10 +180,32 @@ process.on('SIGTERM', gracefulShutdown);
180
180
  // Safety net: prevent server crash from unhandled errors.
181
181
  // These can occur when AI engine SDKs emit asynchronous errors that bypass
182
182
  // the normal try/catch flow (e.g., subprocess killed during initialization).
183
- process.on('unhandledRejection', (reason) => {
184
- debug.error('server', 'Unhandled promise rejection (server still running):', reason);
183
+ //
184
+ // IMPORTANT: Use the Web API (globalThis.addEventListener) instead of Node's
185
+ // process.on('unhandledRejection') because Bun only respects event.preventDefault()
186
+ // from the Web API to suppress the default crash behavior.
187
+ globalThis.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
188
+ // preventDefault() is the ONLY way to prevent Bun from exiting on unhandled rejections.
189
+ // process.on('unhandledRejection') alone does NOT prevent the crash in Bun 1.3.x.
190
+ event.preventDefault();
191
+
192
+ try {
193
+ const reason = event.reason;
194
+ const message = reason instanceof Error ? reason.message : String(reason);
195
+ if (message.includes('Operation aborted') || message.includes('aborted')) {
196
+ debug.warn('server', 'Suppressed expected SDK abort rejection:', message);
197
+ return;
198
+ }
199
+ debug.error('server', 'Unhandled promise rejection (server still running):', reason);
200
+ } catch {
201
+ console.error('Unhandled promise rejection (server still running)');
202
+ }
185
203
  });
186
204
 
187
205
  process.on('uncaughtException', (error) => {
188
- debug.error('server', 'Uncaught exception (server still running):', error);
206
+ try {
207
+ debug.error('server', 'Uncaught exception (server still running):', error);
208
+ } catch {
209
+ console.error('Uncaught exception (server still running)');
210
+ }
189
211
  });
@@ -89,10 +89,26 @@ interface CloseTabResponse {
89
89
  * Internal helper: Get active tab
90
90
  * Throws error if no active tab found
91
91
  * Automatically acquires MCP control for the active tab to ensure UI sync
92
+ *
93
+ * If MCP is already controlling a specific tab, that tab is returned regardless
94
+ * of which tab the user has currently active in the frontend. This prevents MCP
95
+ * from "following" the user when they switch tabs mid-session.
92
96
  */
93
97
  export async function getActiveTabSession(projectId?: string) {
94
- // Get active tab directly from backend tab manager
95
98
  const previewService = getPreviewService(projectId);
99
+
100
+ // If MCP is already controlling a specific tab, stick to that tab.
101
+ // This prevents user tab-switching from hijacking the MCP session.
102
+ const controlState = browserMcpControl.getControlState();
103
+ if (controlState.isControlling && controlState.browserTabId) {
104
+ const controlledTab = previewService.getTab(controlState.browserTabId);
105
+ if (controlledTab) {
106
+ debug.log('mcp', `🎮 Using MCP-controlled tab: ${controlledTab.id} (ignoring active tab)`);
107
+ return { tab: controlledTab, session: controlledTab };
108
+ }
109
+ }
110
+
111
+ // No controlled tab — use the active tab and acquire control
96
112
  const tab = previewService.getActiveTab();
97
113
 
98
114
  if (!tab) {
@@ -101,8 +117,9 @@ export async function getActiveTabSession(projectId?: string) {
101
117
 
102
118
  // Acquire control for active tab (ensures UI sync after idle timeout)
103
119
  // This is idempotent - if already controlling this tab, just updates timestamp
104
- if (!browserMcpControl.isTabControlled(tab.id)) {
105
- const acquired = browserMcpControl.acquireControl(tab.id);
120
+ const resolvedProjectId = previewService.getProjectId();
121
+ if (!browserMcpControl.isTabControlled(tab.id, resolvedProjectId)) {
122
+ const acquired = browserMcpControl.acquireControl(tab.id, undefined, resolvedProjectId);
106
123
  if (acquired) {
107
124
  debug.log('mcp', `🔄 Auto-acquired control for tab ${tab.id} (resumed after idle)`);
108
125
  }
@@ -189,7 +206,7 @@ export async function switchTabHandler(args: { tabId: string; projectId?: string
189
206
  // Update MCP control to the new tab
190
207
  if (tab) {
191
208
  browserMcpControl.releaseControl();
192
- browserMcpControl.acquireControl(tab.id);
209
+ browserMcpControl.acquireControl(tab.id, undefined, previewService.getProjectId());
193
210
  }
194
211
 
195
212
  // Update last action to keep control alive
@@ -244,7 +261,7 @@ export async function openNewTabHandler(args: { url?: string; deviceSize?: 'desk
244
261
 
245
262
  // Auto-acquire control of the new tab
246
263
  browserMcpControl.releaseControl();
247
- browserMcpControl.acquireControl(tab.id);
264
+ browserMcpControl.acquireControl(tab.id, undefined, previewService.getProjectId());
248
265
 
249
266
  // Update last action to keep control alive
250
267
  browserMcpControl.updateLastAction();
@@ -296,7 +313,7 @@ export async function closeTabHandler(args: { tabId: string; projectId?: string
296
313
  if (result.newActiveTabId) {
297
314
  const newActiveTab = previewService.getTab(result.newActiveTabId);
298
315
  if (newActiveTab) {
299
- browserMcpControl.acquireControl(newActiveTab.id);
316
+ browserMcpControl.acquireControl(newActiveTab.id, undefined, previewService.getProjectId());
300
317
  }
301
318
  }
302
319
 
@@ -28,6 +28,7 @@ export interface McpControlState {
28
28
  isControlling: boolean;
29
29
  mcpSessionId: string | null;
30
30
  browserTabId: string | null;
31
+ projectId: string | null;
31
32
  startedAt: number | null;
32
33
  lastActionAt: number | null;
33
34
  }
@@ -60,6 +61,7 @@ export class BrowserMcpControl extends EventEmitter {
60
61
  isControlling: false,
61
62
  mcpSessionId: null,
62
63
  browserTabId: null,
64
+ projectId: null,
63
65
  startedAt: null,
64
66
  lastActionAt: null
65
67
  };
@@ -96,13 +98,16 @@ export class BrowserMcpControl extends EventEmitter {
96
98
 
97
99
  /**
98
100
  * Handle tab destroyed event
99
- * Auto-release control if the destroyed tab was being controlled
101
+ * Auto-release control if the destroyed tab was being controlled.
102
+ * Uses the service's projectId to avoid cross-project false-positives.
100
103
  */
101
104
  private handleTabDestroyed(tabId: string): void {
102
- if (this.controlState.isControlling && this.controlState.browserTabId === tabId) {
103
- debug.warn('mcp', `⚠️ Controlled tab ${tabId} was destroyed - auto-releasing control`);
104
- this.releaseControl();
105
- }
105
+ if (!this.controlState.isControlling || this.controlState.browserTabId !== tabId) return;
106
+ // Validate project to prevent cross-project collisions (tab IDs are not globally unique)
107
+ const serviceProjectId = this.previewService?.getProjectId();
108
+ if (serviceProjectId && this.controlState.projectId && this.controlState.projectId !== serviceProjectId) return;
109
+ debug.warn('mcp', `⚠️ Controlled tab ${tabId} was destroyed - auto-releasing control`);
110
+ this.releaseControl();
106
111
  }
107
112
 
108
113
  /**
@@ -167,18 +172,25 @@ export class BrowserMcpControl extends EventEmitter {
167
172
  }
168
173
 
169
174
  /**
170
- * Check if a specific browser tab is being controlled
175
+ * Check if a specific browser tab is being controlled.
176
+ * When projectId is provided, also validates the project to prevent cross-project
177
+ * false-positives (tab IDs are only unique per project, not globally).
171
178
  */
172
- isTabControlled(browserTabId: string): boolean {
173
- return this.controlState.isControlling &&
174
- this.controlState.browserTabId === browserTabId;
179
+ isTabControlled(browserTabId: string, projectId?: string): boolean {
180
+ if (!this.controlState.isControlling || this.controlState.browserTabId !== browserTabId) {
181
+ return false;
182
+ }
183
+ if (projectId && this.controlState.projectId && this.controlState.projectId !== projectId) {
184
+ return false;
185
+ }
186
+ return true;
175
187
  }
176
188
 
177
189
  /**
178
190
  * Acquire control of a browser tab
179
191
  * Returns true if control was acquired, false if already controlled by another MCP
180
192
  */
181
- acquireControl(browserTabId: string, mcpSessionId?: string): boolean {
193
+ acquireControl(browserTabId: string, mcpSessionId?: string, projectId?: string): boolean {
182
194
  // Validate tab exists before acquiring control
183
195
  if (this.previewService && !this.previewService.getTab(browserTabId)) {
184
196
  debug.warn('mcp', `❌ Cannot acquire control: tab ${browserTabId} does not exist`);
@@ -204,6 +216,7 @@ export class BrowserMcpControl extends EventEmitter {
204
216
  isControlling: true,
205
217
  mcpSessionId: mcpSessionId || null,
206
218
  browserTabId,
219
+ projectId: projectId || null,
207
220
  startedAt: now,
208
221
  lastActionAt: now
209
222
  };
@@ -238,6 +251,7 @@ export class BrowserMcpControl extends EventEmitter {
238
251
  isControlling: false,
239
252
  mcpSessionId: null,
240
253
  browserTabId: null,
254
+ projectId: null,
241
255
  startedAt: null,
242
256
  lastActionAt: null
243
257
  };
@@ -380,13 +394,14 @@ export class BrowserMcpControl extends EventEmitter {
380
394
  }
381
395
 
382
396
  /**
383
- * Auto-release control for a specific browser tab (called when tab closes)
397
+ * Auto-release control for a specific browser tab (called when tab closes).
398
+ * projectId is used to prevent accidental release across projects with same tab IDs.
384
399
  */
385
- autoReleaseForTab(browserTabId: string): void {
386
- if (this.controlState.isControlling && this.controlState.browserTabId === browserTabId) {
387
- debug.log('mcp', `🗑️ Auto-releasing MCP control for closed tab: ${browserTabId}`);
388
- this.releaseControl(browserTabId);
389
- }
400
+ autoReleaseForTab(browserTabId: string, projectId?: string): void {
401
+ if (!this.controlState.isControlling || this.controlState.browserTabId !== browserTabId) return;
402
+ if (projectId && this.controlState.projectId && this.controlState.projectId !== projectId) return;
403
+ debug.log('mcp', `🗑️ Auto-releasing MCP control for closed tab: ${browserTabId}`);
404
+ this.releaseControl(browserTabId);
390
405
  }
391
406
 
392
407
  /**
@@ -403,6 +418,7 @@ export class BrowserMcpControl extends EventEmitter {
403
418
  isControlling: false,
404
419
  mcpSessionId: null,
405
420
  browserTabId: null,
421
+ projectId: null,
406
422
  startedAt: null,
407
423
  lastActionAt: null
408
424
  };
@@ -49,7 +49,9 @@ const DEFAULT_CONFIG: PoolConfig = {
49
49
  const CHROMIUM_ARGS = [
50
50
  '--no-sandbox',
51
51
  '--disable-blink-features=AutomationControlled',
52
- '--window-size=1366,768'
52
+ '--window-size=1366,768',
53
+ '--autoplay-policy=no-user-gesture-required',
54
+ '--disable-features=AudioServiceOutOfProcess'
53
55
  ];
54
56
 
55
57
  class BrowserPool {
@@ -103,17 +103,15 @@ export class BrowserPreviewService extends EventEmitter {
103
103
  if (this.videoCapture.isStreaming(sessionId)) {
104
104
  const tab = this.getTab(sessionId);
105
105
  if (tab) {
106
- // Small delay to ensure page is fully loaded
107
- setTimeout(async () => {
108
- try {
109
- const success = await this.videoCapture.handleNavigation(sessionId, tab);
110
- if (success) {
111
- this.emit('preview:browser-navigation-streaming-ready', { sessionId });
112
- }
113
- } catch (error) {
114
- // Silently fail - frontend will request refresh if needed
106
+ // Restart streaming immediately page is already navigated
107
+ try {
108
+ const success = await this.videoCapture.handleNavigation(sessionId, tab);
109
+ if (success) {
110
+ this.emit('preview:browser-navigation-streaming-ready', { sessionId });
115
111
  }
116
- }, 100);
112
+ } catch (error) {
113
+ // Silently fail - frontend will request refresh if needed
114
+ }
117
115
  }
118
116
  }
119
117
  });
@@ -214,14 +212,15 @@ export class BrowserPreviewService extends EventEmitter {
214
212
  preNavigationSetup
215
213
  });
216
214
 
217
- // Setup console and navigation tracking
218
- await this.consoleManager.setupConsoleLogging(tab.id, tab.page, tab);
219
- await this.navigationTracker.setupNavigationTracking(tab.id, tab.page, tab);
215
+ // Setup console, navigation tracking, and pre-inject streaming scripts in parallel
216
+ await Promise.all([
217
+ this.consoleManager.setupConsoleLogging(tab.id, tab.page, tab),
218
+ this.navigationTracker.setupNavigationTracking(tab.id, tab.page, tab),
219
+ ]);
220
220
 
221
- // Setup dialog bindings and handling
222
- // Temporarily disable dialog injection to test CloudFlare evasion
223
- // await this.dialogHandler.setupDialogBindings(tab.id, tab.page);
224
- // await this.dialogHandler.setupDialogHandling(tab.id, tab.page, tab);
221
+ // Pre-inject WebCodecs scripts so startStreaming() is fast (~50-80ms vs ~200-350ms)
222
+ // Fire-and-forget: failure here is non-fatal, startStreaming() will retry injection
223
+ this.videoCapture.preInjectScripts(tab.id, tab).catch(() => {});
225
224
 
226
225
  return tab;
227
226
  }
@@ -273,7 +273,7 @@ export class BrowserTabManager extends EventEmitter {
273
273
  const wasActive = tab.isActive;
274
274
 
275
275
  // Auto-release MCP control if this tab is being controlled
276
- browserMcpControl.autoReleaseForTab(tabId);
276
+ browserMcpControl.autoReleaseForTab(tabId, this.projectId);
277
277
 
278
278
  // IMMEDIATELY set destroyed flag and stop streaming
279
279
  tab.isDestroyed = true;