@myrialabs/clopen 0.2.5 → 0.2.6

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 (25) 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 +21 -3
  5. package/backend/index.ts +25 -3
  6. package/backend/preview/browser/browser-preview-service.ts +16 -17
  7. package/backend/preview/browser/browser-video-capture.ts +199 -156
  8. package/backend/preview/browser/scripts/video-stream.ts +3 -5
  9. package/backend/snapshot/helpers.ts +15 -2
  10. package/backend/ws/snapshot/restore.ts +43 -2
  11. package/frontend/components/chat/input/ChatInput.svelte +6 -1
  12. package/frontend/components/chat/input/components/ChatInputActions.svelte +10 -0
  13. package/frontend/components/chat/message/MessageBubble.svelte +22 -1
  14. package/frontend/components/chat/tools/components/TerminalCommand.svelte +2 -2
  15. package/frontend/components/files/FileViewer.svelte +13 -2
  16. package/frontend/components/preview/browser/BrowserPreview.svelte +1 -0
  17. package/frontend/components/preview/browser/components/Canvas.svelte +110 -21
  18. package/frontend/components/preview/browser/components/Container.svelte +2 -1
  19. package/frontend/components/preview/browser/components/Toolbar.svelte +1 -8
  20. package/frontend/components/preview/browser/core/coordinator.svelte.ts +12 -4
  21. package/frontend/components/terminal/TerminalTabs.svelte +1 -2
  22. package/frontend/components/workspace/panels/FilesPanel.svelte +1 -0
  23. package/frontend/services/chat/chat.service.ts +6 -1
  24. package/frontend/services/preview/browser/browser-webcodecs.service.ts +13 -5
  25. 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 FIRST — 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,7 +180,19 @@ 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 aborting.
184
+ // This lets the SDK process the denial responses while the subprocess
185
+ // is still alive, preventing "Operation aborted" write errors.
186
+ for (const [, pending] of this.pendingUserAnswers) {
187
+ pending.resolve({ behavior: 'deny', message: 'Cancelled' });
188
+ }
189
+ this.pendingUserAnswers.clear();
190
+
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) {
184
196
  try {
185
197
  await this.activeQuery.interrupt();
186
198
  } catch {
@@ -188,13 +200,19 @@ export class ClaudeCodeEngine implements AIEngine {
188
200
  }
189
201
  }
190
202
 
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
+ if (this.activeController && !this.activeController.signal.aborted) {
208
+ await new Promise(resolve => setTimeout(resolve, 100));
209
+ }
210
+
191
211
  if (this.activeController) {
192
212
  this.activeController.abort();
193
213
  this.activeController = null;
194
214
  }
195
215
  this.activeQuery = null;
196
- // Reject all pending user answer promises (abort signal handles this, but clean up the map)
197
- this.pendingUserAnswers.clear();
198
216
  }
199
217
 
200
218
  /**
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
  });
@@ -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
  }