@myrialabs/clopen 0.2.11 → 0.2.13
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 +106 -9
- package/backend/database/queries/project-queries.ts +1 -4
- package/backend/database/queries/session-queries.ts +36 -1
- package/backend/database/queries/snapshot-queries.ts +122 -0
- package/backend/database/utils/connection.ts +17 -11
- package/backend/engine/adapters/claude/stream.ts +14 -3
- package/backend/engine/types.ts +9 -0
- package/backend/index.ts +13 -2
- package/backend/mcp/config.ts +32 -6
- package/backend/snapshot/blob-store.ts +52 -72
- package/backend/snapshot/snapshot-service.ts +24 -0
- package/backend/terminal/stream-manager.ts +121 -131
- package/backend/ws/chat/stream.ts +14 -7
- package/backend/ws/engine/claude/accounts.ts +6 -8
- package/backend/ws/projects/crud.ts +72 -7
- package/backend/ws/sessions/crud.ts +119 -2
- package/backend/ws/system/operations.ts +14 -39
- package/backend/ws/terminal/persistence.ts +19 -33
- package/backend/ws/terminal/session.ts +37 -19
- package/bun.lock +6 -0
- package/frontend/components/auth/SetupPage.svelte +1 -1
- package/frontend/components/chat/input/ChatInput.svelte +22 -1
- package/frontend/components/chat/input/composables/use-animations.svelte.ts +127 -111
- package/frontend/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -1
- package/frontend/components/chat/message/MessageBubble.svelte +13 -0
- package/frontend/components/chat/widgets/FloatingTodoList.svelte +2 -2
- package/frontend/components/common/form/FolderBrowser.svelte +17 -4
- package/frontend/components/common/overlay/Dialog.svelte +17 -15
- package/frontend/components/files/FileNode.svelte +0 -15
- package/frontend/components/git/ChangesSection.svelte +104 -13
- package/frontend/components/history/HistoryModal.svelte +94 -19
- package/frontend/components/history/HistoryView.svelte +29 -36
- package/frontend/components/settings/engines/AIEnginesSettings.svelte +1 -1
- package/frontend/components/settings/general/DataManagementSettings.svelte +1 -54
- package/frontend/components/terminal/Terminal.svelte +5 -1
- package/frontend/components/workspace/DesktopNavigator.svelte +57 -10
- package/frontend/components/workspace/MobileNavigator.svelte +57 -10
- package/frontend/components/workspace/WorkspaceLayout.svelte +0 -8
- package/frontend/services/chat/chat.service.ts +94 -23
- package/frontend/services/notification/global-stream-monitor.ts +5 -2
- package/frontend/services/terminal/project.service.ts +4 -60
- package/frontend/services/terminal/terminal.service.ts +18 -27
- package/frontend/stores/core/app.svelte.ts +10 -2
- package/frontend/stores/core/sessions.svelte.ts +10 -1
- package/package.json +4 -2
|
@@ -103,7 +103,7 @@ class StreamManager extends EventEmitter {
|
|
|
103
103
|
* This event fires regardless of per-connection subscribers.
|
|
104
104
|
* Used by the WS layer to send cross-project notifications (presence, sound, push).
|
|
105
105
|
*/
|
|
106
|
-
private emitStreamLifecycle(streamState: StreamState, status: 'completed' | 'error' | 'cancelled'): void {
|
|
106
|
+
private emitStreamLifecycle(streamState: StreamState, status: 'completed' | 'error' | 'cancelled', reason?: string): void {
|
|
107
107
|
if (this.lifecycleEmitted.has(streamState.streamId)) return;
|
|
108
108
|
this.lifecycleEmitted.add(streamState.streamId);
|
|
109
109
|
|
|
@@ -112,7 +112,8 @@ class StreamManager extends EventEmitter {
|
|
|
112
112
|
streamId: streamState.streamId,
|
|
113
113
|
projectId: streamState.projectId,
|
|
114
114
|
chatSessionId: streamState.chatSessionId,
|
|
115
|
-
timestamp: (streamState.completedAt || new Date()).toISOString()
|
|
115
|
+
timestamp: (streamState.completedAt || new Date()).toISOString(),
|
|
116
|
+
reason
|
|
116
117
|
});
|
|
117
118
|
|
|
118
119
|
// Clean up guard after 60s (no need to keep forever)
|
|
@@ -494,6 +495,9 @@ class StreamManager extends EventEmitter {
|
|
|
494
495
|
includePartialMessages: true,
|
|
495
496
|
abortController: streamState.abortController,
|
|
496
497
|
...(claudeAccountId !== undefined && { claudeAccountId }),
|
|
498
|
+
...(projectId && chatSessionId && {
|
|
499
|
+
mcpContext: { projectId, chatSessionId, streamId: streamState.streamId }
|
|
500
|
+
}),
|
|
497
501
|
});
|
|
498
502
|
|
|
499
503
|
await projectContextService.runWithContextAsync(
|
|
@@ -707,10 +711,16 @@ class StreamManager extends EventEmitter {
|
|
|
707
711
|
});
|
|
708
712
|
} else if ((event as any).content_block?.type === 'text') {
|
|
709
713
|
// Reset partial text for new text content block
|
|
710
|
-
// Don't emit the initial text — deltas will provide the content
|
|
711
|
-
// This prevents double-counting if content_block_start.text repeats
|
|
712
|
-
// the first content_block_delta.text
|
|
713
714
|
streamState.currentPartialText = '';
|
|
715
|
+
// Emit a start event so frontend has a text stream_event
|
|
716
|
+
// before deltas arrive (matches thinking block behavior)
|
|
717
|
+
this.emitStreamEvent(streamState, 'partial', {
|
|
718
|
+
processId: streamState.processId,
|
|
719
|
+
eventType: 'start',
|
|
720
|
+
partialText: '',
|
|
721
|
+
deltaText: '',
|
|
722
|
+
timestamp: new Date().toISOString()
|
|
723
|
+
});
|
|
714
724
|
}
|
|
715
725
|
} else if (event.type === 'content_block_delta') {
|
|
716
726
|
debug.log('chat', `[SM] content_block_delta: deltaType=${(event as any).delta?.type}, hasThinking=${'thinking' in ((event as any).delta || {})}, hasText=${'text' in ((event as any).delta || {})}`);
|
|
@@ -830,6 +840,9 @@ class StreamManager extends EventEmitter {
|
|
|
830
840
|
savedReasoningParentId = saved?.parent_message_id || null;
|
|
831
841
|
}
|
|
832
842
|
|
|
843
|
+
// Clear reasoning text after save to prevent stale catchup injection
|
|
844
|
+
streamState.currentReasoningText = undefined;
|
|
845
|
+
|
|
833
846
|
this.emitStreamEvent(streamState, 'message', {
|
|
834
847
|
processId: streamState.processId,
|
|
835
848
|
message: reasoningMsg,
|
|
@@ -890,6 +903,16 @@ class StreamManager extends EventEmitter {
|
|
|
890
903
|
savedParentId = saved?.parent_message_id || null;
|
|
891
904
|
}
|
|
892
905
|
|
|
906
|
+
// Clear partial text after saving a complete assistant message to prevent
|
|
907
|
+
// cancelStream from saving a duplicate text-only message to DB.
|
|
908
|
+
// Also prevents catchupActiveStream from injecting a stale stream_event
|
|
909
|
+
// with text that's already part of the saved message.
|
|
910
|
+
if (message.type === 'assistant' && !message.metadata?.reasoning) {
|
|
911
|
+
streamState.currentPartialText = undefined;
|
|
912
|
+
} else if (message.type === 'assistant' && message.metadata?.reasoning) {
|
|
913
|
+
streamState.currentReasoningText = undefined;
|
|
914
|
+
}
|
|
915
|
+
|
|
893
916
|
streamState.messages.push({
|
|
894
917
|
processId: streamState.processId,
|
|
895
918
|
message,
|
|
@@ -1083,7 +1106,7 @@ class StreamManager extends EventEmitter {
|
|
|
1083
1106
|
return engine.resolveUserAnswer(toolUseId, answers);
|
|
1084
1107
|
}
|
|
1085
1108
|
|
|
1086
|
-
async cancelStream(streamId: string): Promise<boolean> {
|
|
1109
|
+
async cancelStream(streamId: string, reason?: string): Promise<boolean> {
|
|
1087
1110
|
const streamState = this.activeStreams.get(streamId);
|
|
1088
1111
|
if (!streamState || streamState.status !== 'active') {
|
|
1089
1112
|
return false;
|
|
@@ -1093,6 +1116,37 @@ class StreamManager extends EventEmitter {
|
|
|
1093
1116
|
streamState.status = 'cancelled';
|
|
1094
1117
|
streamState.completedAt = new Date();
|
|
1095
1118
|
|
|
1119
|
+
// Save partial reasoning text to DB before cancelling (persists across refresh/project switch)
|
|
1120
|
+
if (streamState.currentReasoningText && streamState.chatSessionId) {
|
|
1121
|
+
try {
|
|
1122
|
+
const reasoningMessage = {
|
|
1123
|
+
type: 'assistant' as const,
|
|
1124
|
+
parent_tool_use_id: null,
|
|
1125
|
+
message: {
|
|
1126
|
+
role: 'assistant' as const,
|
|
1127
|
+
content: [{ type: 'text' as const, text: streamState.currentReasoningText }]
|
|
1128
|
+
},
|
|
1129
|
+
session_id: streamState.sdkSessionId || '',
|
|
1130
|
+
metadata: { reasoning: true }
|
|
1131
|
+
};
|
|
1132
|
+
|
|
1133
|
+
const timestamp = new Date().toISOString();
|
|
1134
|
+
const currentHead = sessionQueries.getHead(streamState.chatSessionId);
|
|
1135
|
+
|
|
1136
|
+
const savedMessage = messageQueries.create({
|
|
1137
|
+
session_id: streamState.chatSessionId,
|
|
1138
|
+
sdk_message: reasoningMessage as any,
|
|
1139
|
+
timestamp,
|
|
1140
|
+
parent_message_id: currentHead || undefined
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
sessionQueries.updateHead(streamState.chatSessionId, savedMessage.id);
|
|
1144
|
+
debug.log('chat', 'Saved partial reasoning on cancel:', savedMessage.id);
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
debug.error('chat', 'Failed to save partial reasoning on cancel:', error);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1096
1150
|
// Save partial text to DB before cancelling (persists across refresh/project switch)
|
|
1097
1151
|
if (streamState.currentPartialText && streamState.chatSessionId) {
|
|
1098
1152
|
try {
|
|
@@ -1172,7 +1226,7 @@ class StreamManager extends EventEmitter {
|
|
|
1172
1226
|
timestamp: streamState.completedAt.toISOString()
|
|
1173
1227
|
});
|
|
1174
1228
|
|
|
1175
|
-
this.emitStreamLifecycle(streamState, 'cancelled');
|
|
1229
|
+
this.emitStreamLifecycle(streamState, 'cancelled', reason);
|
|
1176
1230
|
|
|
1177
1231
|
// Auto-release all MCP-controlled tabs for this chat session
|
|
1178
1232
|
if (streamState.chatSessionId) {
|
|
@@ -1190,8 +1244,16 @@ class StreamManager extends EventEmitter {
|
|
|
1190
1244
|
const streamState = this.activeStreams.get(streamId);
|
|
1191
1245
|
if (streamState) {
|
|
1192
1246
|
const sessionKey = this.getSessionKey(streamState.projectId, streamState.chatSessionId);
|
|
1193
|
-
|
|
1194
|
-
|
|
1247
|
+
// Only delete session key if it still points to THIS stream.
|
|
1248
|
+
// A newer stream for the same session may have overridden the key;
|
|
1249
|
+
// blindly deleting it would orphan the active stream — making it
|
|
1250
|
+
// unfindable by getSessionStream() and breaking cancel/reconnect.
|
|
1251
|
+
if (this.sessionStreams.get(sessionKey) === streamId) {
|
|
1252
|
+
this.sessionStreams.delete(sessionKey);
|
|
1253
|
+
}
|
|
1254
|
+
if (this.sessionStreams.get(streamState.chatSessionId) === streamId) {
|
|
1255
|
+
this.sessionStreams.delete(streamState.chatSessionId);
|
|
1256
|
+
}
|
|
1195
1257
|
this.activeStreams.delete(streamId);
|
|
1196
1258
|
|
|
1197
1259
|
// Cleanup project context service
|
|
@@ -1376,6 +1438,41 @@ class StreamManager extends EventEmitter {
|
|
|
1376
1438
|
});
|
|
1377
1439
|
}
|
|
1378
1440
|
|
|
1441
|
+
/**
|
|
1442
|
+
* Cancel and clean up all streams for a specific chat session.
|
|
1443
|
+
* Used when a session is deleted to remove green/amber status indicators.
|
|
1444
|
+
*/
|
|
1445
|
+
async cleanupSessionStreams(chatSessionId: string): Promise<void> {
|
|
1446
|
+
const streamsToCancel: string[] = [];
|
|
1447
|
+
const streamsToClean: string[] = [];
|
|
1448
|
+
|
|
1449
|
+
this.activeStreams.forEach((stream, streamId) => {
|
|
1450
|
+
if (stream.chatSessionId === chatSessionId) {
|
|
1451
|
+
if (stream.status === 'active') {
|
|
1452
|
+
streamsToCancel.push(streamId);
|
|
1453
|
+
} else {
|
|
1454
|
+
streamsToClean.push(streamId);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
// Cancel active streams and await their processStream promise so the
|
|
1460
|
+
// finally block (snapshot capture) completes before the caller deletes
|
|
1461
|
+
// the session — preventing FOREIGN KEY constraint failures.
|
|
1462
|
+
for (const streamId of streamsToCancel) {
|
|
1463
|
+
await this.cancelStream(streamId, 'session-deleted');
|
|
1464
|
+
const stream = this.activeStreams.get(streamId);
|
|
1465
|
+
if (stream?.streamPromise) {
|
|
1466
|
+
await stream.streamPromise.catch(() => {});
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// Clean up non-active streams
|
|
1471
|
+
for (const streamId of streamsToClean) {
|
|
1472
|
+
this.cleanupStream(streamId);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1379
1476
|
/**
|
|
1380
1477
|
* Clean up all completed streams
|
|
1381
1478
|
*/
|
|
@@ -57,11 +57,8 @@ export const projectQueries = {
|
|
|
57
57
|
`).run(now, id);
|
|
58
58
|
},
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
deleteProject(id: string): void {
|
|
61
61
|
const db = getDatabase();
|
|
62
|
-
// Delete related data first
|
|
63
|
-
db.prepare('DELETE FROM messages WHERE session_id IN (SELECT id FROM chat_sessions WHERE project_id = ?)').run(id);
|
|
64
|
-
db.prepare('DELETE FROM chat_sessions WHERE project_id = ?').run(id);
|
|
65
62
|
db.prepare('DELETE FROM user_projects WHERE project_id = ?').run(id);
|
|
66
63
|
db.prepare('DELETE FROM projects WHERE id = ?').run(id);
|
|
67
64
|
},
|
|
@@ -125,11 +125,46 @@ export const sessionQueries = {
|
|
|
125
125
|
|
|
126
126
|
delete(id: string): void {
|
|
127
127
|
const db = getDatabase();
|
|
128
|
-
// Delete related
|
|
128
|
+
// Delete all related data
|
|
129
|
+
db.prepare('DELETE FROM branches WHERE session_id = ?').run(id);
|
|
130
|
+
db.prepare('DELETE FROM message_snapshots WHERE session_id = ?').run(id);
|
|
131
|
+
db.prepare('DELETE FROM session_relationships WHERE parent_session_id = ? OR child_session_id = ?').run(id, id);
|
|
129
132
|
db.prepare('DELETE FROM messages WHERE session_id = ?').run(id);
|
|
133
|
+
db.prepare('DELETE FROM user_unread_sessions WHERE session_id = ?').run(id);
|
|
134
|
+
// Clear current_session_id references in user_projects
|
|
135
|
+
db.prepare('UPDATE user_projects SET current_session_id = NULL WHERE current_session_id = ?').run(id);
|
|
130
136
|
db.prepare('DELETE FROM chat_sessions WHERE id = ?').run(id);
|
|
131
137
|
},
|
|
132
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Delete all sessions for a project and their related data.
|
|
141
|
+
* Returns the list of deleted session IDs.
|
|
142
|
+
*/
|
|
143
|
+
deleteAllByProjectId(projectId: string): string[] {
|
|
144
|
+
const db = getDatabase();
|
|
145
|
+
const sessions = db.prepare('SELECT id FROM chat_sessions WHERE project_id = ?')
|
|
146
|
+
.all(projectId) as { id: string }[];
|
|
147
|
+
const sessionIds = sessions.map(s => s.id);
|
|
148
|
+
|
|
149
|
+
if (sessionIds.length === 0) return [];
|
|
150
|
+
|
|
151
|
+
// Delete all related data for the project's sessions
|
|
152
|
+
db.prepare('DELETE FROM branches WHERE session_id IN (SELECT id FROM chat_sessions WHERE project_id = ?)').run(projectId);
|
|
153
|
+
db.prepare('DELETE FROM message_snapshots WHERE project_id = ?').run(projectId);
|
|
154
|
+
db.prepare(`
|
|
155
|
+
DELETE FROM session_relationships
|
|
156
|
+
WHERE parent_session_id IN (SELECT id FROM chat_sessions WHERE project_id = ?)
|
|
157
|
+
OR child_session_id IN (SELECT id FROM chat_sessions WHERE project_id = ?)
|
|
158
|
+
`).run(projectId, projectId);
|
|
159
|
+
db.prepare('DELETE FROM messages WHERE session_id IN (SELECT id FROM chat_sessions WHERE project_id = ?)').run(projectId);
|
|
160
|
+
db.prepare('DELETE FROM user_unread_sessions WHERE project_id = ?').run(projectId);
|
|
161
|
+
// Clear current_session_id references in user_projects for this project
|
|
162
|
+
db.prepare('UPDATE user_projects SET current_session_id = NULL WHERE project_id = ?').run(projectId);
|
|
163
|
+
db.prepare('DELETE FROM chat_sessions WHERE project_id = ?').run(projectId);
|
|
164
|
+
|
|
165
|
+
return sessionIds;
|
|
166
|
+
},
|
|
167
|
+
|
|
133
168
|
/**
|
|
134
169
|
* Get the active shared session for a project
|
|
135
170
|
* Returns the most recent session that hasn't ended
|
|
@@ -325,5 +325,127 @@ export const snapshotQueries = {
|
|
|
325
325
|
`).all(projectId) as SessionRelationship[];
|
|
326
326
|
|
|
327
327
|
return relationships;
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Get ALL snapshots for a session (including soft-deleted).
|
|
332
|
+
* Used for cleanup — getBySessionId filters is_deleted which misses hashes.
|
|
333
|
+
*/
|
|
334
|
+
getAllBySessionId(sessionId: string): MessageSnapshot[] {
|
|
335
|
+
const db = getDatabase();
|
|
336
|
+
return db.prepare(`
|
|
337
|
+
SELECT * FROM message_snapshots WHERE session_id = ?
|
|
338
|
+
ORDER BY created_at ASC
|
|
339
|
+
`).all(sessionId) as MessageSnapshot[];
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Get ALL snapshots for a project (including soft-deleted).
|
|
344
|
+
* Used for cleanup.
|
|
345
|
+
*/
|
|
346
|
+
getAllByProjectId(projectId: string): MessageSnapshot[] {
|
|
347
|
+
const db = getDatabase();
|
|
348
|
+
return db.prepare(`
|
|
349
|
+
SELECT * FROM message_snapshots WHERE project_id = ?
|
|
350
|
+
ORDER BY created_at ASC
|
|
351
|
+
`).all(projectId) as MessageSnapshot[];
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Delete all snapshots for a session.
|
|
356
|
+
* Returns the deleted snapshots so callers can clean up blob store.
|
|
357
|
+
*/
|
|
358
|
+
deleteBySessionId(sessionId: string): MessageSnapshot[] {
|
|
359
|
+
const db = getDatabase();
|
|
360
|
+
const snapshots = db.prepare(`
|
|
361
|
+
SELECT * FROM message_snapshots WHERE session_id = ?
|
|
362
|
+
`).all(sessionId) as MessageSnapshot[];
|
|
363
|
+
|
|
364
|
+
if (snapshots.length > 0) {
|
|
365
|
+
db.prepare('DELETE FROM message_snapshots WHERE session_id = ?').run(sessionId);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return snapshots;
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Delete all snapshots for a project.
|
|
373
|
+
* Returns the deleted snapshots so callers can clean up blob store.
|
|
374
|
+
*/
|
|
375
|
+
deleteByProjectId(projectId: string): MessageSnapshot[] {
|
|
376
|
+
const db = getDatabase();
|
|
377
|
+
const snapshots = db.prepare(`
|
|
378
|
+
SELECT * FROM message_snapshots WHERE project_id = ?
|
|
379
|
+
`).all(projectId) as MessageSnapshot[];
|
|
380
|
+
|
|
381
|
+
if (snapshots.length > 0) {
|
|
382
|
+
db.prepare('DELETE FROM message_snapshots WHERE project_id = ?').run(projectId);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return snapshots;
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Delete session relationships by session ID (as parent or child).
|
|
390
|
+
*/
|
|
391
|
+
deleteRelationshipsBySessionId(sessionId: string): void {
|
|
392
|
+
const db = getDatabase();
|
|
393
|
+
db.prepare('DELETE FROM session_relationships WHERE parent_session_id = ? OR child_session_id = ?')
|
|
394
|
+
.run(sessionId, sessionId);
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Delete all session relationships for a project.
|
|
399
|
+
*/
|
|
400
|
+
deleteRelationshipsByProjectId(projectId: string): void {
|
|
401
|
+
const db = getDatabase();
|
|
402
|
+
db.prepare(`
|
|
403
|
+
DELETE FROM session_relationships
|
|
404
|
+
WHERE parent_session_id IN (SELECT id FROM chat_sessions WHERE project_id = ?)
|
|
405
|
+
OR child_session_id IN (SELECT id FROM chat_sessions WHERE project_id = ?)
|
|
406
|
+
`).run(projectId, projectId);
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Collect all blob hashes referenced by the given snapshots.
|
|
411
|
+
* Extracts oldHash and newHash from session_changes.
|
|
412
|
+
*/
|
|
413
|
+
collectBlobHashes(snapshots: MessageSnapshot[]): Set<string> {
|
|
414
|
+
const hashes = new Set<string>();
|
|
415
|
+
for (const snap of snapshots) {
|
|
416
|
+
if (!snap.session_changes) continue;
|
|
417
|
+
try {
|
|
418
|
+
const changes = JSON.parse(snap.session_changes as string) as Record<string, { oldHash: string; newHash: string }>;
|
|
419
|
+
for (const change of Object.values(changes)) {
|
|
420
|
+
if (change.oldHash) hashes.add(change.oldHash);
|
|
421
|
+
if (change.newHash) hashes.add(change.newHash);
|
|
422
|
+
}
|
|
423
|
+
} catch { /* skip malformed */ }
|
|
424
|
+
}
|
|
425
|
+
return hashes;
|
|
426
|
+
},
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Get all blob hashes still referenced by remaining snapshots in the database.
|
|
430
|
+
* Used to determine which blobs are safe to delete (orphan detection).
|
|
431
|
+
*/
|
|
432
|
+
getAllReferencedBlobHashes(): Set<string> {
|
|
433
|
+
const db = getDatabase();
|
|
434
|
+
const rows = db.prepare(`
|
|
435
|
+
SELECT session_changes FROM message_snapshots
|
|
436
|
+
WHERE session_changes IS NOT NULL
|
|
437
|
+
`).all() as { session_changes: string }[];
|
|
438
|
+
|
|
439
|
+
const hashes = new Set<string>();
|
|
440
|
+
for (const row of rows) {
|
|
441
|
+
try {
|
|
442
|
+
const changes = JSON.parse(row.session_changes) as Record<string, { oldHash: string; newHash: string }>;
|
|
443
|
+
for (const change of Object.values(changes)) {
|
|
444
|
+
if (change.oldHash) hashes.add(change.oldHash);
|
|
445
|
+
if (change.newHash) hashes.add(change.newHash);
|
|
446
|
+
}
|
|
447
|
+
} catch { /* skip malformed */ }
|
|
448
|
+
}
|
|
449
|
+
return hashes;
|
|
328
450
|
}
|
|
329
451
|
};
|
|
@@ -117,23 +117,29 @@ export class DatabaseManager {
|
|
|
117
117
|
|
|
118
118
|
async resetDatabase(): Promise<void> {
|
|
119
119
|
debug.log('database', '⚠️ Resetting database (dropping all tables)...');
|
|
120
|
-
|
|
120
|
+
|
|
121
121
|
if (!this.db) {
|
|
122
122
|
throw new Error('Database not connected');
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
SELECT name FROM sqlite_master
|
|
128
|
-
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
|
129
|
-
`).all() as { name: string }[];
|
|
125
|
+
// Disable foreign key checks to allow dropping in any order
|
|
126
|
+
this.db.exec('PRAGMA foreign_keys = OFF');
|
|
130
127
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
128
|
+
try {
|
|
129
|
+
const tables = this.db.prepare(`
|
|
130
|
+
SELECT name FROM sqlite_master
|
|
131
|
+
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
|
132
|
+
`).all() as { name: string }[];
|
|
133
|
+
|
|
134
|
+
for (const table of tables) {
|
|
135
|
+
debug.log('database', `🗑️ Dropping table: ${table.name}`);
|
|
136
|
+
this.db.exec(`DROP TABLE IF EXISTS ${table.name}`);
|
|
137
|
+
}
|
|
135
138
|
|
|
136
|
-
|
|
139
|
+
debug.log('database', '✅ Database reset completed');
|
|
140
|
+
} finally {
|
|
141
|
+
this.db.exec('PRAGMA foreign_keys = ON');
|
|
142
|
+
}
|
|
137
143
|
}
|
|
138
144
|
|
|
139
145
|
async vacuum(): Promise<void> {
|
|
@@ -22,6 +22,7 @@ import { debug } from '$shared/utils/logger';
|
|
|
22
22
|
/** Pending AskUserQuestion resolver — stored while SDK is blocked waiting for user input */
|
|
23
23
|
interface PendingUserAnswer {
|
|
24
24
|
resolve: (result: PermissionResult) => void;
|
|
25
|
+
removeAbortListener: () => void;
|
|
25
26
|
input: Record<string, unknown>;
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -89,7 +90,8 @@ export class ClaudeCodeEngine implements AIEngine {
|
|
|
89
90
|
|
|
90
91
|
try {
|
|
91
92
|
// Get custom MCP servers and allowed tools
|
|
92
|
-
|
|
93
|
+
// Pass mcpContext so tool handlers are bound to the correct project
|
|
94
|
+
const mcpServers = getEnabledMcpServers(options.mcpContext);
|
|
93
95
|
const allowedMcpTools = getAllowedMcpTools();
|
|
94
96
|
|
|
95
97
|
debug.log('mcp', '📦 Loading custom MCP servers...');
|
|
@@ -130,6 +132,9 @@ export class ClaudeCodeEngine implements AIEngine {
|
|
|
130
132
|
options.signal.removeEventListener('abort', onAbort);
|
|
131
133
|
resolve(result);
|
|
132
134
|
},
|
|
135
|
+
removeAbortListener: () => {
|
|
136
|
+
options.signal.removeEventListener('abort', onAbort);
|
|
137
|
+
},
|
|
133
138
|
input
|
|
134
139
|
});
|
|
135
140
|
});
|
|
@@ -180,9 +185,15 @@ export class ClaudeCodeEngine implements AIEngine {
|
|
|
180
185
|
* Cancel active query
|
|
181
186
|
*/
|
|
182
187
|
async cancel(): Promise<void> {
|
|
183
|
-
//
|
|
188
|
+
// Remove abort listeners from pending AskUserQuestion promises WITHOUT
|
|
189
|
+
// resolving them. Resolving causes the SDK to call handleControlRequest →
|
|
190
|
+
// write() to send the permission result to the subprocess. If close() has
|
|
191
|
+
// already killed the subprocess, this write throws "Operation aborted" as
|
|
192
|
+
// an unhandled error, crashing the server. By removing listeners and not
|
|
193
|
+
// resolving, the promises are safely abandoned when close() terminates the
|
|
194
|
+
// process and the async generator completes.
|
|
184
195
|
for (const [, pending] of this.pendingUserAnswers) {
|
|
185
|
-
pending.
|
|
196
|
+
pending.removeAbortListener();
|
|
186
197
|
}
|
|
187
198
|
this.pendingUserAnswers.clear();
|
|
188
199
|
|
package/backend/engine/types.ts
CHANGED
|
@@ -11,6 +11,13 @@ import type { EngineType, EngineModel } from '$shared/types/engine';
|
|
|
11
11
|
|
|
12
12
|
export type { EngineType, EngineModel };
|
|
13
13
|
|
|
14
|
+
/** Execution context for MCP tool handlers (project isolation) */
|
|
15
|
+
export interface McpExecutionContext {
|
|
16
|
+
projectId?: string;
|
|
17
|
+
chatSessionId?: string;
|
|
18
|
+
streamId?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
/** Options passed to engine.streamQuery() */
|
|
15
22
|
export interface EngineQueryOptions {
|
|
16
23
|
projectPath: string;
|
|
@@ -22,6 +29,8 @@ export interface EngineQueryOptions {
|
|
|
22
29
|
includePartialMessages?: boolean;
|
|
23
30
|
abortController?: AbortController;
|
|
24
31
|
claudeAccountId?: number;
|
|
32
|
+
/** Context bound to MCP tool handlers for project isolation */
|
|
33
|
+
mcpContext?: McpExecutionContext;
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
/** Options for one-shot structured generation (no tools, no streaming) */
|
package/backend/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { handleMcpRequest, closeMcpServer } from './mcp/remote-server';
|
|
|
35
35
|
|
|
36
36
|
// Auth middleware
|
|
37
37
|
import { checkRouteAccess } from './auth/permissions';
|
|
38
|
+
import { authRateLimiter } from './auth';
|
|
38
39
|
import { ws as wsServer } from './utils/ws';
|
|
39
40
|
|
|
40
41
|
// Register auth gate on WebSocket router — blocks unauthenticated/unauthorized access
|
|
@@ -165,22 +166,32 @@ async function gracefulShutdown() {
|
|
|
165
166
|
if (isShuttingDown) return;
|
|
166
167
|
isShuttingDown = true;
|
|
167
168
|
|
|
169
|
+
// Force exit after 5 seconds — prevents port from being held by slow cleanup
|
|
170
|
+
// during bun --watch restarts, which causes ECONNREFUSED on the Vite WS proxy.
|
|
171
|
+
const forceExitTimer = setTimeout(() => {
|
|
172
|
+
debug.warn('server', '⚠️ Shutdown timeout — forcing exit to release port');
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}, 5_000);
|
|
175
|
+
|
|
168
176
|
console.log('\n🛑 Shutting down server...');
|
|
169
177
|
try {
|
|
178
|
+
// Stop accepting new connections first — release the port ASAP
|
|
179
|
+
app.stop();
|
|
180
|
+
// Dispose rate limiter timer
|
|
181
|
+
authRateLimiter.dispose();
|
|
170
182
|
// Close MCP remote server (before engines, as they may still reference it)
|
|
171
183
|
await closeMcpServer();
|
|
172
184
|
// Cleanup browser preview sessions
|
|
173
185
|
await browserPreviewServiceManager.cleanup();
|
|
174
186
|
// Dispose all AI engines
|
|
175
187
|
await disposeAllEngines();
|
|
176
|
-
// Stop accepting new connections
|
|
177
|
-
app.stop();
|
|
178
188
|
// Close database connection
|
|
179
189
|
closeDatabase();
|
|
180
190
|
debug.log('server', '✅ Graceful shutdown completed');
|
|
181
191
|
} catch (error) {
|
|
182
192
|
debug.error('server', '❌ Error during shutdown:', error);
|
|
183
193
|
}
|
|
194
|
+
clearTimeout(forceExitTimer);
|
|
184
195
|
process.exit(0);
|
|
185
196
|
}
|
|
186
197
|
|
package/backend/mcp/config.ts
CHANGED
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
* to avoid duplication and make it easier to add new servers.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type
|
|
8
|
+
import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance, type McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
9
9
|
import type { McpRemoteConfig } from '@opencode-ai/sdk';
|
|
10
10
|
import type { ServerConfig, ParsedMcpToolName, ServerName } from './types';
|
|
11
|
-
import {
|
|
11
|
+
import type { McpExecutionContext } from '../engine/types';
|
|
12
|
+
import { serverRegistry, serverFactories, serverMetadata } from './servers';
|
|
13
|
+
import { projectContextService } from './project-context';
|
|
12
14
|
import { debug } from '$shared/utils/logger';
|
|
13
15
|
import { SERVER_ENV } from '../utils/env';
|
|
14
16
|
|
|
@@ -87,15 +89,39 @@ export const mcpServers: Record<string, ServerConfig & { instance: McpSdkServerC
|
|
|
87
89
|
*
|
|
88
90
|
* Creates FRESH server instances each call so that concurrent streams
|
|
89
91
|
* each get their own Protocol — avoids "Already connected to a transport" errors.
|
|
92
|
+
*
|
|
93
|
+
* When `context` is provided, tool handlers are wrapped to restore the
|
|
94
|
+
* AsyncLocalStorage execution context. This is required because the SDK
|
|
95
|
+
* invokes MCP tool handlers through IPC which breaks AsyncLocalStorage
|
|
96
|
+
* propagation — without this, background streams from Project A would
|
|
97
|
+
* resolve to Project B's preview browser when the user switches projects.
|
|
90
98
|
*/
|
|
91
|
-
export function getEnabledMcpServers(): Record<string, McpServerConfig> {
|
|
99
|
+
export function getEnabledMcpServers(context?: McpExecutionContext): Record<string, McpServerConfig> {
|
|
92
100
|
const enabledServers: Record<string, McpServerConfig> = {};
|
|
93
101
|
|
|
94
102
|
Object.entries(mcpServers).forEach(([serverName, serverConfig]) => {
|
|
95
103
|
if (serverConfig.enabled) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
104
|
+
if (context) {
|
|
105
|
+
// Create context-bound instance: wrap each tool handler so
|
|
106
|
+
// AsyncLocalStorage context is restored on invocation
|
|
107
|
+
const meta = serverMetadata[serverName as ServerName];
|
|
108
|
+
const sdkTools = (serverConfig.tools as readonly string[]).map(toolName => {
|
|
109
|
+
const def = meta.toolDefs[toolName];
|
|
110
|
+
const boundHandler = async (args: any) => {
|
|
111
|
+
return projectContextService.runWithContextAsync(context, () => def.handler(args));
|
|
112
|
+
};
|
|
113
|
+
return tool(toolName, def.description, def.schema, boundHandler as any);
|
|
114
|
+
});
|
|
115
|
+
enabledServers[serverName] = createSdkMcpServer({
|
|
116
|
+
name: meta.name,
|
|
117
|
+
version: '1.0.0',
|
|
118
|
+
tools: sdkTools
|
|
119
|
+
});
|
|
120
|
+
} else {
|
|
121
|
+
const factory = serverFactories[serverName as ServerName];
|
|
122
|
+
enabledServers[serverName] = factory ? factory() : serverConfig.instance;
|
|
123
|
+
}
|
|
124
|
+
debug.log('mcp', `✓ Enabled MCP server: ${serverName}${context ? ' (context-bound)' : ''}`);
|
|
99
125
|
} else {
|
|
100
126
|
debug.log('mcp', `✗ Disabled MCP server: ${serverName}`);
|
|
101
127
|
}
|