@myrialabs/clopen 0.2.6 → 0.2.8

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 (39) hide show
  1. package/backend/chat/stream-manager.ts +24 -13
  2. package/backend/engine/adapters/claude/stream.ts +10 -19
  3. package/backend/mcp/project-context.ts +20 -0
  4. package/backend/mcp/servers/browser-automation/actions.ts +0 -2
  5. package/backend/mcp/servers/browser-automation/browser.ts +86 -132
  6. package/backend/mcp/servers/browser-automation/inspection.ts +5 -11
  7. package/backend/preview/browser/browser-mcp-control.ts +175 -180
  8. package/backend/preview/browser/browser-pool.ts +3 -1
  9. package/backend/preview/browser/browser-preview-service.ts +3 -3
  10. package/backend/preview/browser/browser-tab-manager.ts +1 -1
  11. package/backend/preview/browser/browser-video-capture.ts +12 -14
  12. package/backend/preview/browser/scripts/audio-stream.ts +11 -0
  13. package/backend/preview/browser/scripts/video-stream.ts +14 -14
  14. package/backend/preview/browser/types.ts +7 -7
  15. package/backend/preview/index.ts +1 -1
  16. package/backend/ws/chat/stream.ts +1 -1
  17. package/backend/ws/preview/browser/tab-info.ts +5 -2
  18. package/backend/ws/preview/index.ts +3 -3
  19. package/frontend/components/chat/input/ChatInput.svelte +0 -3
  20. package/frontend/components/chat/input/composables/use-chat-actions.svelte.ts +6 -2
  21. package/frontend/components/chat/message/MessageBubble.svelte +2 -2
  22. package/frontend/components/history/HistoryModal.svelte +1 -1
  23. package/frontend/components/preview/browser/BrowserPreview.svelte +15 -1
  24. package/frontend/components/preview/browser/components/Canvas.svelte +323 -49
  25. package/frontend/components/preview/browser/components/Container.svelte +21 -0
  26. package/frontend/components/preview/browser/components/Toolbar.svelte +3 -3
  27. package/frontend/components/preview/browser/core/coordinator.svelte.ts +15 -0
  28. package/frontend/components/preview/browser/core/mcp-handlers.svelte.ts +78 -51
  29. package/frontend/components/preview/browser/core/tab-operations.svelte.ts +1 -0
  30. package/frontend/components/workspace/PanelHeader.svelte +15 -0
  31. package/frontend/components/workspace/panels/GitPanel.svelte +22 -13
  32. package/frontend/components/workspace/panels/PreviewPanel.svelte +2 -0
  33. package/frontend/services/chat/chat.service.ts +3 -7
  34. package/frontend/services/preview/browser/browser-webcodecs.service.ts +32 -135
  35. package/frontend/stores/core/app.svelte.ts +4 -3
  36. package/frontend/stores/core/presence.svelte.ts +3 -2
  37. package/frontend/stores/core/sessions.svelte.ts +2 -0
  38. package/frontend/stores/ui/notification.svelte.ts +4 -1
  39. package/package.json +1 -1
@@ -485,7 +485,8 @@ class StreamManager extends EventEmitter {
485
485
  }
486
486
 
487
487
  // Stream messages through the engine adapter
488
- for await (const message of engine.streamQuery({
488
+ // Wrap in execution context so MCP tool handlers can access chatSessionId/projectId
489
+ const streamIterable = engine.streamQuery({
489
490
  projectPath: actualProjectPath,
490
491
  prompt: enginePrompt,
491
492
  resume: resumeSessionId,
@@ -493,7 +494,11 @@ class StreamManager extends EventEmitter {
493
494
  includePartialMessages: true,
494
495
  abortController: streamState.abortController,
495
496
  ...(claudeAccountId !== undefined && { claudeAccountId }),
496
- })) {
497
+ });
498
+
499
+ await projectContextService.runWithContextAsync(
500
+ { chatSessionId, projectId, streamId: streamState.streamId },
501
+ async () => { for await (const message of streamIterable) {
497
502
  // Check if cancelled (cancelStream() already set status and emitted event)
498
503
  if ((streamState.status as string) === 'cancelled' || streamState.abortController?.signal.aborted) {
499
504
  break;
@@ -909,7 +914,7 @@ class StreamManager extends EventEmitter {
909
914
  sender_id: requestData.senderId,
910
915
  sender_name: requestData.senderName
911
916
  });
912
- }
917
+ } }); // end runWithContextAsync + for await
913
918
 
914
919
  // Only mark as completed if not already cancelled/errored
915
920
  if (streamState.status === 'active') {
@@ -924,9 +929,11 @@ class StreamManager extends EventEmitter {
924
929
  this.emitStreamLifecycle(streamState, 'completed');
925
930
  }
926
931
 
927
- // Auto-release MCP control when stream completes
928
- browserMcpControl.releaseControl();
929
- debug.log('mcp', '✅ Auto-released MCP control on stream completion');
932
+ // Auto-release all MCP-controlled tabs for this chat session
933
+ if (chatSessionId) {
934
+ browserMcpControl.releaseSession(chatSessionId);
935
+ debug.log('mcp', `✅ Auto-released MCP tabs for session ${chatSessionId.slice(0, 8)} on stream completion`);
936
+ }
930
937
 
931
938
  } catch (error) {
932
939
  // Don't overwrite status if already cancelled by cancelStream()
@@ -988,9 +995,11 @@ class StreamManager extends EventEmitter {
988
995
  this.emitStreamLifecycle(streamState, 'error');
989
996
  }
990
997
 
991
- // Auto-release MCP control on stream error
992
- browserMcpControl.releaseControl();
993
- debug.log('mcp', '✅ Auto-released MCP control on stream error');
998
+ // Auto-release all MCP-controlled tabs for this chat session
999
+ if (requestData.chatSessionId) {
1000
+ browserMcpControl.releaseSession(requestData.chatSessionId);
1001
+ debug.log('mcp', `✅ Auto-released MCP tabs for session ${requestData.chatSessionId.slice(0, 8)} on stream error`);
1002
+ }
994
1003
  } finally {
995
1004
  // Capture snapshot ONCE at stream end (regardless of completion/error/cancel).
996
1005
  // Associates the snapshot with the user message (checkpoint) that triggered the stream.
@@ -1132,7 +1141,7 @@ class StreamManager extends EventEmitter {
1132
1141
  }
1133
1142
  }
1134
1143
 
1135
- // Cancel the per-project engine FIRST — this sends an interrupt to the
1144
+ // Cancel the per-project engine — this sends an interrupt to the
1136
1145
  // still-alive SDK subprocess, then aborts the controller. If we abort
1137
1146
  // the controller first, the subprocess dies and the SDK's subsequent
1138
1147
  // interrupt write fails with "Operation aborted" (unhandled rejection
@@ -1161,9 +1170,11 @@ class StreamManager extends EventEmitter {
1161
1170
 
1162
1171
  this.emitStreamLifecycle(streamState, 'cancelled');
1163
1172
 
1164
- // Auto-release MCP control on stream cancellation
1165
- browserMcpControl.releaseControl();
1166
- debug.log('mcp', '✅ Auto-released MCP control on stream cancellation');
1173
+ // Auto-release all MCP-controlled tabs for this chat session
1174
+ if (streamState.chatSessionId) {
1175
+ browserMcpControl.releaseSession(streamState.chatSessionId);
1176
+ debug.log('mcp', `✅ Auto-released MCP tabs for session ${streamState.chatSessionId.slice(0, 8)} on stream cancellation`);
1177
+ }
1167
1178
 
1168
1179
  return true;
1169
1180
  }
@@ -180,38 +180,29 @@ export class ClaudeCodeEngine implements AIEngine {
180
180
  * Cancel active query
181
181
  */
182
182
  async cancel(): Promise<void> {
183
- // Resolve all pending AskUserQuestion promises BEFORE aborting.
184
- // This lets the SDK process the denial responses while the subprocess
185
- // is still alive, preventing "Operation aborted" write errors.
183
+ // Resolve all pending AskUserQuestion promises before terminating.
186
184
  for (const [, pending] of this.pendingUserAnswers) {
187
185
  pending.resolve({ behavior: 'deny', message: 'Cancelled' });
188
186
  }
189
187
  this.pendingUserAnswers.clear();
190
188
 
191
- // Only interrupt if the controller hasn't been aborted yet.
192
- // Interrupting after abort causes the SDK to write to a dead subprocess,
193
- // resulting in "Operation aborted" unhandled rejections that crash Bun.
194
- if (this.activeQuery && typeof this.activeQuery.interrupt === 'function'
195
- && this.activeController && !this.activeController.signal.aborted) {
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') {
196
195
  try {
197
- await this.activeQuery.interrupt();
196
+ this.activeQuery.close();
198
197
  } catch {
199
- // Ignore interrupt errors
198
+ // Ignore close errors — process may already be dead
200
199
  }
201
200
  }
202
201
 
203
- // Brief delay between interrupt and abort to let the SDK flush pending
204
- // write operations (e.g., handleControlRequest responses). Without this,
205
- // aborting immediately after interrupt kills the subprocess while the SDK
206
- // still has in-flight writes, causing "Operation aborted" rejections.
207
202
  if (this.activeController && !this.activeController.signal.aborted) {
208
- await new Promise(resolve => setTimeout(resolve, 100));
209
- }
210
-
211
- if (this.activeController) {
212
203
  this.activeController.abort();
213
- this.activeController = null;
214
204
  }
205
+ this.activeController = null;
215
206
  this.activeQuery = null;
216
207
  }
217
208
 
@@ -184,6 +184,26 @@ class ProjectContextService {
184
184
  return executionContext.run(context, callback);
185
185
  }
186
186
 
187
+ /**
188
+ * Get chat session ID from current execution context
189
+ * Used by MCP browser automation to track which chat session is controlling tabs
190
+ */
191
+ getCurrentChatSessionId(): string | null {
192
+ const context = this.getCurrentContext();
193
+
194
+ // 1. From AsyncLocalStorage execution context (highest priority)
195
+ if (context?.chatSessionId) {
196
+ return context.chatSessionId;
197
+ }
198
+
199
+ // 2. Fallback to most recent active stream
200
+ if (this.mostRecentActiveStream) {
201
+ return this.mostRecentActiveStream.chatSessionId;
202
+ }
203
+
204
+ return null;
205
+ }
206
+
187
207
  /**
188
208
  * Get project ID from current execution context
189
209
  * This is the primary method MCP handlers should use
@@ -4,7 +4,6 @@
4
4
 
5
5
  import { browserPreviewServiceManager, type BrowserPreviewService } from "$backend/preview";
6
6
  import type { BrowserAutonomousAction } from "$backend/preview/browser/types";
7
- import { browserMcpControl } from "$backend/preview";
8
7
  import { projectContextService } from "$backend/mcp/project-context";
9
8
  import { getActiveTabSession } from "./browser";
10
9
  import { debug } from "$shared/utils/logger";
@@ -116,7 +115,6 @@ export async function actionsHandler(args: {
116
115
  // Note: Cursor events are emitted by performAutonomousActions internally
117
116
  // with proper delays between each action. No need to emit here.
118
117
  const results = await previewService.performAutonomousActions(sessionId, processedActions);
119
- browserMcpControl.updateLastAction();
120
118
 
121
119
  // Format response with extracted data if any
122
120
  const extractedData = results?.filter((r: any) => r.action === 'extract_data') || [];
@@ -6,127 +6,100 @@
6
6
  * - Open and close tabs (auto session management)
7
7
  * - Navigate active tab
8
8
  *
9
- * Session management is handled internally - no session tools exposed.
10
- * All operations work on the active tab automatically.
9
+ * Control model:
10
+ * - Each chat session can control multiple tabs (accumulated via switch/open)
11
+ * - A tab can only be controlled by one chat session at a time
12
+ * - All controlled tabs are released when the chat stream ends
13
+ * - list_tabs shows MCP control status so AI avoids conflicts
11
14
  */
12
15
 
13
- import { ws } from "$backend/utils/ws";
14
16
  import { debug } from "$shared/utils/logger";
15
17
  import { browserMcpControl, browserPreviewServiceManager, type BrowserPreviewService } from "$backend/preview";
16
18
  import { projectContextService } from "$backend/mcp/project-context";
17
19
 
18
20
  /**
19
21
  * Get BrowserPreviewService for current MCP execution context
20
- *
21
- * Uses projectContextService to determine the correct project based on:
22
- * 1. Explicit projectId parameter (if provided)
23
- * 2. Current active chat session context
24
- * 3. Most recent active stream
25
- * 4. Fallback to first available project
26
22
  */
27
23
  function getPreviewService(projectId?: string): BrowserPreviewService {
28
- // 1. Use explicit projectId if provided
29
24
  if (projectId) {
30
- debug.log('mcp', `Using explicit projectId: ${projectId}`);
31
25
  return browserPreviewServiceManager.getService(projectId);
32
26
  }
33
27
 
34
- // 2. Try to get projectId from current execution context
35
28
  const contextProjectId = projectContextService.getCurrentProjectId();
36
29
  if (contextProjectId) {
37
- debug.log('mcp', `Using projectId from context: ${contextProjectId}`);
38
30
  return browserPreviewServiceManager.getService(contextProjectId);
39
31
  }
40
32
 
41
- // 3. Fallback: Get first available project's service
42
33
  const activeProjects = browserPreviewServiceManager.getActiveProjects();
43
34
  if (activeProjects.length > 0) {
44
- const fallbackProjectId = activeProjects[0];
45
- debug.warn('mcp', `⚠️ No project context found, falling back to first active project: ${fallbackProjectId}`);
46
- return browserPreviewServiceManager.getService(fallbackProjectId);
35
+ debug.warn('mcp', `⚠️ No project context found, falling back to first active project: ${activeProjects[0]}`);
36
+ return browserPreviewServiceManager.getService(activeProjects[0]);
47
37
  }
48
38
 
49
39
  throw new Error('No active browser preview service found. Project isolation requires projectId.');
50
40
  }
51
41
 
52
- // Tab response types
53
- interface FrontendTab {
54
- id: string;
55
- url: string;
56
- title: string;
57
- sessionId: string | null;
58
- isActive: boolean;
59
- }
60
-
61
- interface TabsListResponse {
62
- tabs: FrontendTab[];
63
- }
64
-
65
- interface ActiveTabResponse {
66
- tab: FrontendTab | null;
67
- }
68
-
69
- interface SwitchTabResponse {
70
- success: boolean;
71
- tab?: FrontendTab;
72
- error?: string;
73
- }
74
-
75
- interface OpenTabResponse {
76
- success: boolean;
77
- tab?: FrontendTab;
78
- error?: string;
79
- }
80
-
81
- interface CloseTabResponse {
82
- success: boolean;
83
- closedTabId?: string;
84
- newActiveTab?: FrontendTab;
85
- error?: string;
42
+ /**
43
+ * Get chatSessionId from current execution context.
44
+ * Required for session-scoped MCP control.
45
+ */
46
+ function getChatSessionId(): string {
47
+ const chatSessionId = projectContextService.getCurrentChatSessionId();
48
+ if (!chatSessionId) {
49
+ throw new Error('No chat session context available. Cannot acquire MCP control.');
50
+ }
51
+ return chatSessionId;
86
52
  }
87
53
 
88
54
  /**
89
- * Internal helper: Get active tab
90
- * Throws error if no active tab found
91
- * Automatically acquires MCP control for the active tab to ensure UI sync
55
+ * Internal helper: Get active tab for MCP operations.
56
+ * Automatically acquires MCP control for the chat session.
57
+ *
58
+ * If this chat session already controls tabs, uses the most recently
59
+ * controlled tab (not necessarily the frontend's active tab).
92
60
  */
93
61
  export async function getActiveTabSession(projectId?: string) {
94
- // Get active tab directly from backend tab manager
95
62
  const previewService = getPreviewService(projectId);
96
- const tab = previewService.getActiveTab();
63
+ const resolvedProjectId = previewService.getProjectId();
64
+ const chatSessionId = getChatSessionId();
65
+
66
+ // Check if this session already controls any tabs — use the last one
67
+ const sessionTabs = browserMcpControl.getSessionTabs(chatSessionId);
68
+ if (sessionTabs.length > 0) {
69
+ // Use the last tab this session was working with
70
+ const lastTabId = sessionTabs[sessionTabs.length - 1];
71
+ const controlledTab = previewService.getTab(lastTabId);
72
+ if (controlledTab) {
73
+ debug.log('mcp', `🎮 Using session-controlled tab: ${controlledTab.id}`);
74
+ return { tab: controlledTab, session: controlledTab };
75
+ }
76
+ }
97
77
 
78
+ // No controlled tab — use the frontend's active tab and acquire control
79
+ const tab = previewService.getActiveTab();
98
80
  if (!tab) {
99
- throw new Error("No active tab found. Open a tab first using 'open_tab'.");
81
+ throw new Error("No active tab found. Open a tab first using 'open_new_tab'.");
100
82
  }
101
83
 
102
- // Acquire control for active tab (ensures UI sync after idle timeout)
103
- // This is idempotent - if already controlling this tab, just updates timestamp
104
- if (!browserMcpControl.isTabControlled(tab.id)) {
105
- const acquired = browserMcpControl.acquireControl(tab.id);
106
- if (acquired) {
107
- debug.log('mcp', `🔄 Auto-acquired control for tab ${tab.id} (resumed after idle)`);
108
- }
84
+ // Acquire control (will fail if another session owns this tab)
85
+ const acquired = browserMcpControl.acquireControl(tab.id, chatSessionId, resolvedProjectId);
86
+ if (!acquired) {
87
+ const owner = browserMcpControl.getTabOwner(tab.id);
88
+ throw new Error(`Tab '${tab.id}' is controlled by another chat session (${owner?.slice(0, 8)}...). Use a different tab.`);
109
89
  }
110
90
 
111
- // For backward compatibility, return both tab and session-like reference
112
- // Note: In tab-centric architecture, tab IS the session
113
91
  return { tab, session: tab };
114
92
  }
115
93
 
116
94
  /**
117
- * List all open tabs in the browser preview
95
+ * List all open tabs in the browser preview.
96
+ * Shows MCP control status so AI can avoid conflicts.
118
97
  */
119
98
  export async function listTabsHandler(projectId?: string) {
120
99
  try {
121
- debug.log('mcp', '📋 MCP requesting tab list');
122
- debug.log('mcp', `🔍 Input projectId: ${projectId || '(none)'}`);
123
-
124
- // Get all tabs directly from backend
125
100
  const previewService = getPreviewService(projectId);
126
- debug.log('mcp', `✅ Using service for project: ${previewService.getProjectId()}`);
127
-
128
101
  const tabs = previewService.getAllTabs();
129
- debug.log('mcp', `📊 Found ${tabs.length} tabs`);
102
+ const chatSessionId = projectContextService.getCurrentChatSessionId();
130
103
 
131
104
  if (tabs.length === 0) {
132
105
  return {
@@ -137,12 +110,17 @@ export async function listTabsHandler(projectId?: string) {
137
110
  };
138
111
  }
139
112
 
140
- const tabList = tabs.map((tab: any, index: number) =>
141
- `${index + 1}. ${tab.isActive ? '* ' : ' '}[${tab.id}] ${tab.title || 'Untitled'}\n URL: ${tab.url || '(empty)'}`
142
- ).join('\n\n');
113
+ const tabList = tabs.map((tab: any, index: number) => {
114
+ const owner = browserMcpControl.getTabOwner(tab.id);
115
+ let status = '';
116
+ if (owner) {
117
+ status = owner === chatSessionId
118
+ ? ' (MCP: this session)'
119
+ : ' (MCP: another session)';
120
+ }
143
121
 
144
- // Update last action to keep control alive
145
- browserMcpControl.updateLastAction();
122
+ return `${index + 1}. ${tab.isActive ? '* ' : ' '}[${tab.id}] ${tab.title || 'Untitled'}${status}\n URL: ${tab.url || '(empty)'}`;
123
+ }).join('\n\n');
146
124
 
147
125
  return {
148
126
  content: [{
@@ -163,16 +141,18 @@ export async function listTabsHandler(projectId?: string) {
163
141
  }
164
142
 
165
143
  /**
166
- * Switch to a specific tab by ID
144
+ * Switch to a specific tab by ID.
145
+ * Adds the tab to this session's controlled set (does NOT release other tabs).
167
146
  */
168
147
  export async function switchTabHandler(args: { tabId: string; projectId?: string }) {
169
148
  try {
170
149
  debug.log('mcp', `🔄 MCP switching to tab: ${args.tabId}`);
171
150
 
172
- // Switch tab directly in backend
173
151
  const previewService = getPreviewService(args.projectId);
174
- const success = previewService.switchTab(args.tabId);
152
+ const chatSessionId = getChatSessionId();
153
+ const resolvedProjectId = previewService.getProjectId();
175
154
 
155
+ const success = previewService.switchTab(args.tabId);
176
156
  if (!success) {
177
157
  return {
178
158
  content: [{
@@ -183,18 +163,22 @@ export async function switchTabHandler(args: { tabId: string; projectId?: string
183
163
  };
184
164
  }
185
165
 
186
- // Get the tab that was just activated
166
+ // Acquire control of the new tab (adds to session's set, doesn't release others)
187
167
  const tab = previewService.getTab(args.tabId);
188
-
189
- // Update MCP control to the new tab
190
168
  if (tab) {
191
- browserMcpControl.releaseControl();
192
- browserMcpControl.acquireControl(tab.id);
169
+ const acquired = browserMcpControl.acquireControl(tab.id, chatSessionId, resolvedProjectId);
170
+ if (!acquired) {
171
+ const owner = browserMcpControl.getTabOwner(tab.id);
172
+ return {
173
+ content: [{
174
+ type: "text" as const,
175
+ text: `Tab '${args.tabId}' is controlled by another chat session (${owner?.slice(0, 8)}...). Cannot switch to it.`
176
+ }],
177
+ isError: true
178
+ };
179
+ }
193
180
  }
194
181
 
195
- // Update last action to keep control alive
196
- browserMcpControl.updateLastAction();
197
-
198
182
  return {
199
183
  content: [{
200
184
  type: "text" as const,
@@ -214,40 +198,31 @@ export async function switchTabHandler(args: { tabId: string; projectId?: string
214
198
  }
215
199
 
216
200
  /**
217
- * Open a new tab with optional URL and viewport configuration
218
- * Auto-creates browser session and acquires MCP control
201
+ * Open a new tab with optional URL and viewport configuration.
202
+ * Auto-acquires MCP control for the new tab.
219
203
  */
220
204
  export async function openNewTabHandler(args: { url?: string; deviceSize?: 'desktop' | 'laptop' | 'tablet' | 'mobile'; rotation?: 'portrait' | 'landscape'; projectId?: string }) {
221
205
  try {
222
206
  const deviceSize = args.deviceSize || 'laptop';
223
207
 
224
- // Determine default rotation based on device size if not specified
225
208
  let rotation: 'portrait' | 'landscape';
226
209
  if (args.rotation) {
227
210
  rotation = args.rotation;
228
211
  } else {
229
- // Desktop and laptop default to landscape
230
- // Tablet and mobile default to portrait
231
212
  rotation = (deviceSize === 'desktop' || deviceSize === 'laptop') ? 'landscape' : 'portrait';
232
213
  }
233
214
 
234
215
  debug.log('mcp', `📑 MCP opening new tab with URL: ${args.url || '(empty)'}`);
235
- debug.log('mcp', `📱 Device: ${deviceSize}, Rotation: ${rotation}`);
236
- debug.log('mcp', `🔍 Input projectId: ${args.projectId || '(none)'}`);
237
216
 
238
- // Create tab directly in backend
239
217
  const previewService = getPreviewService(args.projectId);
240
- debug.log('mcp', `✅ Using service for project: ${previewService.getProjectId()}`);
218
+ const chatSessionId = getChatSessionId();
219
+ const resolvedProjectId = previewService.getProjectId();
241
220
 
242
221
  const tab = await previewService.createTab(args.url || undefined, deviceSize, rotation);
243
222
  debug.log('mcp', `✅ Tab created: ${tab.id}`);
244
223
 
245
- // Auto-acquire control of the new tab
246
- browserMcpControl.releaseControl();
247
- browserMcpControl.acquireControl(tab.id);
248
-
249
- // Update last action to keep control alive
250
- browserMcpControl.updateLastAction();
224
+ // Acquire control of the new tab (adds to session's set)
225
+ browserMcpControl.acquireControl(tab.id, chatSessionId, resolvedProjectId);
251
226
 
252
227
  return {
253
228
  content: [{
@@ -268,14 +243,13 @@ export async function openNewTabHandler(args: { url?: string; deviceSize?: 'desk
268
243
  }
269
244
 
270
245
  /**
271
- * Close a specific tab by ID
272
- * Auto-destroys browser session and releases MCP control
246
+ * Close a specific tab by ID.
247
+ * Releases MCP control for the closed tab only.
273
248
  */
274
249
  export async function closeTabHandler(args: { tabId: string; projectId?: string }) {
275
250
  try {
276
251
  debug.log('mcp', `❌ MCP closing tab: ${args.tabId}`);
277
252
 
278
- // Close tab directly in backend
279
253
  const previewService = getPreviewService(args.projectId);
280
254
  const result = await previewService.closeTab(args.tabId);
281
255
 
@@ -289,19 +263,8 @@ export async function closeTabHandler(args: { tabId: string; projectId?: string
289
263
  };
290
264
  }
291
265
 
292
- // Release control of closed tab
293
- browserMcpControl.releaseControl();
294
-
295
- // If there's a new active tab, acquire control
296
- if (result.newActiveTabId) {
297
- const newActiveTab = previewService.getTab(result.newActiveTabId);
298
- if (newActiveTab) {
299
- browserMcpControl.acquireControl(newActiveTab.id);
300
- }
301
- }
302
-
303
- // Update last action to keep control alive
304
- browserMcpControl.updateLastAction();
266
+ // releaseTab is already called by autoReleaseForTab inside closeTab()
267
+ // No need to manually release here
305
268
 
306
269
  let responseText = `Tab '${args.tabId}' closed successfully.`;
307
270
  if (result.newActiveTabId) {
@@ -332,25 +295,21 @@ export async function closeTabHandler(args: { tabId: string; projectId?: string
332
295
  }
333
296
 
334
297
  /**
335
- * Navigate active tab to a different URL
298
+ * Navigate active tab to a different URL.
336
299
  * Waits for page load. Session state preserved.
337
300
  */
338
301
  export async function navigateHandler(args: { url: string; projectId?: string }) {
339
302
  try {
340
- // Get active tab session
341
303
  const { session } = await getActiveTabSession(args.projectId);
342
304
 
343
- // Navigate and wait for page to load
344
305
  await session.page.goto(args.url, {
345
306
  waitUntil: 'domcontentloaded',
346
307
  timeout: 30000
347
308
  });
348
309
 
349
- // Wait a bit for dynamic content to load
350
310
  await new Promise(resolve => setTimeout(resolve, 500));
351
311
 
352
312
  const finalUrl = session.page.url();
353
- browserMcpControl.updateLastAction();
354
313
 
355
314
  return {
356
315
  content: [{
@@ -371,11 +330,10 @@ export async function navigateHandler(args: { url: string; projectId?: string })
371
330
  }
372
331
 
373
332
  /**
374
- * Change viewport settings (device size and rotation) for active tab
333
+ * Change viewport settings (device size and rotation) for active tab.
375
334
  */
376
335
  export async function setViewportHandler(args: { deviceSize?: 'desktop' | 'laptop' | 'tablet' | 'mobile'; rotation?: 'portrait' | 'landscape'; projectId?: string }) {
377
336
  try {
378
- // Get active tab
379
337
  const { tab } = await getActiveTabSession(args.projectId);
380
338
 
381
339
  const deviceSize = args.deviceSize || tab.deviceSize;
@@ -383,7 +341,6 @@ export async function setViewportHandler(args: { deviceSize?: 'desktop' | 'lapto
383
341
 
384
342
  debug.log('mcp', `📱 MCP changing viewport for tab ${tab.id}: ${deviceSize} (${rotation})`);
385
343
 
386
- // Get preview service and update viewport
387
344
  const previewService = getPreviewService(args.projectId);
388
345
  const success = await previewService.setViewport(tab.id, deviceSize, rotation);
389
346
 
@@ -397,9 +354,6 @@ export async function setViewportHandler(args: { deviceSize?: 'desktop' | 'lapto
397
354
  };
398
355
  }
399
356
 
400
- // Update last action to keep control alive
401
- browserMcpControl.updateLastAction();
402
-
403
357
  return {
404
358
  content: [{
405
359
  type: "text" as const,
@@ -3,7 +3,6 @@
3
3
  */
4
4
 
5
5
  import { browserPreviewServiceManager, type BrowserPreviewService } from "$backend/preview";
6
- import { browserMcpControl } from "$backend/preview";
7
6
  import { projectContextService } from "$backend/mcp/project-context";
8
7
  import { getActiveTabSession } from "./browser";
9
8
  import { debug } from "$shared/utils/logger";
@@ -49,8 +48,7 @@ export async function getConsoleLogsHandler(args: {
49
48
  const previewService = getPreviewService(args.projectId);
50
49
  const logs = previewService.getConsoleLogs(sessionId);
51
50
 
52
- // Update last action to keep control alive
53
- browserMcpControl.updateLastAction();
51
+
54
52
 
55
53
  if (logs.length === 0) {
56
54
  return {
@@ -97,8 +95,7 @@ export async function clearConsoleLogsHandler(args: { projectId?: string } = {})
97
95
  const previewService = getPreviewService(args.projectId);
98
96
  const success = previewService.clearConsoleLogs(sessionId);
99
97
 
100
- // Update last action to keep control alive
101
- browserMcpControl.updateLastAction();
98
+
102
99
 
103
100
  if (!success) {
104
101
  return {
@@ -141,8 +138,7 @@ export async function executeConsoleHandler(args: {
141
138
  const previewService = getPreviewService(args.projectId);
142
139
  const result = await previewService.executeConsoleCommand(sessionId, args.command);
143
140
 
144
- // Update last action to keep control alive
145
- browserMcpControl.updateLastAction();
141
+
146
142
 
147
143
  return {
148
144
  content: [{
@@ -438,8 +434,7 @@ export async function analyzeDomHandler(args: {
438
434
  filtered = analysis;
439
435
  }
440
436
 
441
- // Update last action to keep control alive
442
- browserMcpControl.updateLastAction();
437
+
443
438
 
444
439
  return {
445
440
  content: [{
@@ -471,8 +466,7 @@ export async function takeScreenshotHandler() {
471
466
  type: 'png'
472
467
  });
473
468
 
474
- // Update last action to keep control alive
475
- browserMcpControl.updateLastAction();
469
+
476
470
 
477
471
  return {
478
472
  content: [