@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.
- package/backend/chat/stream-manager.ts +136 -10
- package/backend/database/queries/session-queries.ts +9 -0
- package/backend/engine/adapters/claude/error-handler.ts +7 -2
- package/backend/engine/adapters/claude/stream.ts +16 -7
- package/backend/index.ts +25 -3
- package/backend/mcp/servers/browser-automation/browser.ts +23 -6
- package/backend/preview/browser/browser-mcp-control.ts +32 -16
- package/backend/preview/browser/browser-pool.ts +3 -1
- package/backend/preview/browser/browser-preview-service.ts +16 -17
- package/backend/preview/browser/browser-tab-manager.ts +1 -1
- package/backend/preview/browser/browser-video-capture.ts +199 -156
- package/backend/preview/browser/scripts/audio-stream.ts +11 -0
- package/backend/preview/browser/scripts/video-stream.ts +3 -5
- package/backend/snapshot/helpers.ts +15 -2
- package/backend/ws/chat/stream.ts +1 -1
- package/backend/ws/preview/browser/tab-info.ts +5 -2
- package/backend/ws/snapshot/restore.ts +43 -2
- package/frontend/components/chat/input/ChatInput.svelte +6 -4
- package/frontend/components/chat/input/components/ChatInputActions.svelte +10 -0
- package/frontend/components/chat/input/composables/use-chat-actions.svelte.ts +6 -2
- package/frontend/components/chat/message/MessageBubble.svelte +22 -1
- package/frontend/components/chat/tools/components/TerminalCommand.svelte +2 -2
- package/frontend/components/files/FileViewer.svelte +13 -2
- package/frontend/components/history/HistoryModal.svelte +1 -1
- package/frontend/components/preview/browser/BrowserPreview.svelte +15 -0
- package/frontend/components/preview/browser/components/Canvas.svelte +432 -69
- package/frontend/components/preview/browser/components/Container.svelte +23 -1
- package/frontend/components/preview/browser/components/Toolbar.svelte +1 -8
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +27 -4
- package/frontend/components/preview/browser/core/mcp-handlers.svelte.ts +36 -3
- package/frontend/components/preview/browser/core/tab-operations.svelte.ts +1 -0
- package/frontend/components/terminal/TerminalTabs.svelte +1 -2
- package/frontend/components/workspace/PanelHeader.svelte +15 -0
- package/frontend/components/workspace/panels/FilesPanel.svelte +1 -0
- package/frontend/components/workspace/panels/GitPanel.svelte +27 -13
- package/frontend/components/workspace/panels/PreviewPanel.svelte +2 -0
- package/frontend/services/chat/chat.service.ts +9 -8
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +43 -138
- package/frontend/stores/core/app.svelte.ts +4 -3
- package/frontend/stores/core/presence.svelte.ts +3 -2
- package/frontend/stores/core/sessions.svelte.ts +2 -0
- package/frontend/stores/ui/notification.svelte.ts +4 -1
- 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
|
-
|
|
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:
|
|
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
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1015
|
-
|
|
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
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
+
this.activeQuery.close();
|
|
186
197
|
} catch {
|
|
187
|
-
// Ignore
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
}
|
|
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
|
|
218
|
-
await
|
|
219
|
-
|
|
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
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
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;
|