@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.
- 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 +21 -3
- package/backend/index.ts +25 -3
- package/backend/preview/browser/browser-preview-service.ts +16 -17
- package/backend/preview/browser/browser-video-capture.ts +199 -156
- package/backend/preview/browser/scripts/video-stream.ts +3 -5
- package/backend/snapshot/helpers.ts +15 -2
- package/backend/ws/snapshot/restore.ts +43 -2
- package/frontend/components/chat/input/ChatInput.svelte +6 -1
- package/frontend/components/chat/input/components/ChatInputActions.svelte +10 -0
- 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/preview/browser/BrowserPreview.svelte +1 -0
- package/frontend/components/preview/browser/components/Canvas.svelte +110 -21
- package/frontend/components/preview/browser/components/Container.svelte +2 -1
- package/frontend/components/preview/browser/components/Toolbar.svelte +1 -8
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +12 -4
- package/frontend/components/terminal/TerminalTabs.svelte +1 -2
- package/frontend/components/workspace/panels/FilesPanel.svelte +1 -0
- package/frontend/services/chat/chat.service.ts +6 -1
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +13 -5
- 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 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
|
@@ -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
|
}
|