@myrialabs/clopen 0.2.10 → 0.2.12
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/README.md +61 -27
- package/backend/chat/stream-manager.ts +114 -16
- 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 +12 -2
- package/backend/engine/adapters/opencode/stream.ts +37 -19
- package/backend/index.ts +18 -2
- package/backend/mcp/servers/browser-automation/browser.ts +2 -0
- package/backend/preview/browser/browser-mcp-control.ts +16 -0
- package/backend/preview/browser/browser-navigation-tracker.ts +31 -3
- package/backend/preview/browser/browser-preview-service.ts +0 -34
- package/backend/preview/browser/browser-video-capture.ts +13 -1
- package/backend/preview/browser/scripts/audio-stream.ts +5 -0
- package/backend/preview/browser/types.ts +7 -6
- package/backend/snapshot/blob-store.ts +52 -72
- package/backend/snapshot/snapshot-service.ts +24 -0
- package/backend/terminal/stream-manager.ts +41 -2
- package/backend/ws/chat/stream.ts +14 -7
- package/backend/ws/engine/claude/accounts.ts +6 -8
- package/backend/ws/preview/browser/interact.ts +46 -50
- package/backend/ws/preview/browser/webcodecs.ts +24 -15
- 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/frontend/components/auth/SetupPage.svelte +1 -1
- package/frontend/components/chat/input/ChatInput.svelte +14 -1
- package/frontend/components/chat/message/MessageBubble.svelte +13 -0
- package/frontend/components/common/feedback/NotificationToast.svelte +26 -11
- package/frontend/components/common/form/FolderBrowser.svelte +17 -4
- package/frontend/components/common/overlay/Dialog.svelte +17 -15
- package/frontend/components/files/FileNode.svelte +16 -73
- package/frontend/components/git/CommitForm.svelte +1 -1
- package/frontend/components/history/HistoryModal.svelte +94 -19
- package/frontend/components/history/HistoryView.svelte +29 -36
- package/frontend/components/preview/browser/components/Canvas.svelte +119 -42
- package/frontend/components/preview/browser/components/Container.svelte +18 -3
- package/frontend/components/preview/browser/components/Toolbar.svelte +23 -21
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +13 -1
- package/frontend/components/preview/browser/core/stream-handler.svelte.ts +31 -7
- package/frontend/components/preview/browser/core/tab-operations.svelte.ts +5 -4
- package/frontend/components/settings/engines/AIEnginesSettings.svelte +1 -1
- package/frontend/components/settings/general/DataManagementSettings.svelte +1 -54
- 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 +111 -16
- package/frontend/services/notification/global-stream-monitor.ts +5 -2
- package/frontend/services/notification/push.service.ts +2 -2
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +170 -46
- package/frontend/stores/core/app.svelte.ts +10 -2
- package/frontend/stores/core/sessions.svelte.ts +4 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,26 +1,64 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://clopen.myrialabs.dev/favicon.svg" alt="Clopen" width="72" height="72" />
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
[](https://bun.sh)
|
|
5
|
+
<h1 align="center">Clopen</h1>
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
<p align="center">
|
|
8
|
+
<strong>Build more. Switch less.</strong><br />
|
|
9
|
+
All-in-one workspace for Claude Code & OpenCode
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<p align="center">
|
|
13
|
+
<a href="https://clopen.myrialabs.dev">Website</a> ·
|
|
14
|
+
<a href="https://github.com/myrialabs/clopen/issues">Issues</a> ·
|
|
15
|
+
<a href="https://www.npmjs.com/package/@myrialabs/clopen">npm</a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<p align="center">
|
|
19
|
+
<a href="https://www.npmjs.com/package/@myrialabs/clopen"><img src="https://img.shields.io/npm/v/@myrialabs/clopen" alt="npm version" /></a>
|
|
20
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT" /></a>
|
|
21
|
+
<a href="https://github.com/myrialabs/clopen/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome" /></a>
|
|
22
|
+
<a href="https://bun.sh"><img src="https://img.shields.io/badge/Built%20with-Bun-black" alt="Built with Bun" /></a>
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
<p align="center">
|
|
26
|
+
<img src="https://clopen.myrialabs.dev/images/workspace-overview.webp" alt="Clopen workspace overview" />
|
|
27
|
+
</p>
|
|
28
|
+
|
|
29
|
+
All-in-one workspace for Claude Code & OpenCode. Chat, terminal, git, browser preview, and real-time collaboration, built for multi-project and multi-session workflows.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Screenshots
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+
|
|
37
|
+

|
|
38
|
+
|
|
39
|
+

|
|
40
|
+
|
|
41
|
+

|
|
7
42
|
|
|
8
43
|
---
|
|
9
44
|
|
|
10
45
|
## Features
|
|
11
46
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
20
|
-
- **
|
|
21
|
-
- **
|
|
22
|
-
- **
|
|
23
|
-
- **
|
|
47
|
+
A complete development environment designed around AI-assisted workflows, built to disappear into the background and just work.
|
|
48
|
+
|
|
49
|
+
- **Multi-Account Claude Code** — Manage multiple Claude Code accounts (personal, work, or team) and switch between them instantly per chat session
|
|
50
|
+
- **Multi-Engine Support** — Switch between Claude Code and OpenCode as your AI engine, per session
|
|
51
|
+
- **Integrated Terminal** — Full PTY emulation with xterm.js UI. Multi-tab terminal sessions with complete ANSI/VT sequence support and full keyboard control
|
|
52
|
+
- **Full Git Management** — Stage, commit, branch, push, pull, stash, log, and resolve conflicts, all from a clean UI. Powered by native git CLI for accuracy
|
|
53
|
+
- **Real Browser Preview** — A live browser preview streams directly into your workspace. Interact with your app manually, or let the AI drive: clicking, typing, and scrolling for autonomous visual testing
|
|
54
|
+
- **Git-Like Checkpoints** — Multi-branch undo/redo with full file snapshots. Roll back to any point in your AI conversation without touching your actual git history
|
|
55
|
+
- **Real-Time Collaboration** — WebSocket-based presence tracking per project. Multiple users can work on the same codebase simultaneously with live awareness
|
|
56
|
+
- **Monaco File Editor** — VS Code's editor embedded in the browser. Full syntax highlighting, autocomplete, and live file watching, right beside your AI chat
|
|
57
|
+
- **Cloudflare Tunnel** — One-click public HTTPS URL for your local dev server. Built-in QR code for instant mobile access. Share your work without deploying
|
|
58
|
+
- **MCP Support** — Full Model Context Protocol integration. Connect AI tools, external APIs, and custom capabilities to your AI agents with zero friction
|
|
59
|
+
- **Flexible Authentication** — No Login or With Login mode with admin/member roles, invite links, rate-limited login, and CLI token recovery
|
|
60
|
+
- **Background Processing** — Chat, terminal, and other processes continue running even when you close the browser — come back later and pick up where you left off
|
|
61
|
+
- **Database Management** — Browse tables, run queries, and inspect your database directly from the workspace *(coming soon)*
|
|
24
62
|
|
|
25
63
|
---
|
|
26
64
|
|
|
@@ -28,8 +66,8 @@
|
|
|
28
66
|
|
|
29
67
|
### Prerequisites
|
|
30
68
|
|
|
31
|
-
- [Bun](https://bun.sh/) v1.2.12+
|
|
32
|
-
- [Claude Code](https://github.com/anthropics/claude-code)
|
|
69
|
+
- [Bun.js](https://bun.sh/) v1.2.12+
|
|
70
|
+
- [Claude Code](https://github.com/anthropics/claude-code) or [OpenCode](https://opencode.ai) — required for AI functionality
|
|
33
71
|
|
|
34
72
|
### Installation
|
|
35
73
|
|
|
@@ -82,7 +120,9 @@ This regenerates and displays a new admin PAT.
|
|
|
82
120
|
|
|
83
121
|
---
|
|
84
122
|
|
|
85
|
-
##
|
|
123
|
+
## Contributing
|
|
124
|
+
|
|
125
|
+
Clopen is open source and contributions are welcome! Whether it's a bug fix, new feature, or improvement to docs — feel free to open an issue or submit a pull request.
|
|
86
126
|
|
|
87
127
|
```bash
|
|
88
128
|
git clone https://github.com/myrialabs/clopen.git
|
|
@@ -94,6 +134,8 @@ bun run check # Type checking
|
|
|
94
134
|
|
|
95
135
|
When running in development mode, Clopen uses `~/.clopen-dev` instead of `~/.clopen`, keeping dev data separate from any production instance.
|
|
96
136
|
|
|
137
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines and [DECISIONS.md](DECISIONS.md) for architectural decisions.
|
|
138
|
+
|
|
97
139
|
---
|
|
98
140
|
|
|
99
141
|
## Architecture
|
|
@@ -112,14 +154,6 @@ Clopen uses an engine-agnostic adapter pattern — both engines normalize output
|
|
|
112
154
|
|
|
113
155
|
---
|
|
114
156
|
|
|
115
|
-
## Documentation
|
|
116
|
-
|
|
117
|
-
- [Technical Decisions](DECISIONS.md) - Architectural and technical decision log
|
|
118
|
-
- [Contributing](CONTRIBUTING.md) - How to contribute to this project
|
|
119
|
-
- [Development Guidelines](CLAUDE.md) - Guidelines for working with Claude Code on this project
|
|
120
|
-
|
|
121
|
-
---
|
|
122
|
-
|
|
123
157
|
## Troubleshooting
|
|
124
158
|
|
|
125
159
|
### Port 9141 Already in Use
|
|
@@ -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)
|
|
@@ -707,10 +708,16 @@ class StreamManager extends EventEmitter {
|
|
|
707
708
|
});
|
|
708
709
|
} else if ((event as any).content_block?.type === 'text') {
|
|
709
710
|
// 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
711
|
streamState.currentPartialText = '';
|
|
712
|
+
// Emit a start event so frontend has a text stream_event
|
|
713
|
+
// before deltas arrive (matches thinking block behavior)
|
|
714
|
+
this.emitStreamEvent(streamState, 'partial', {
|
|
715
|
+
processId: streamState.processId,
|
|
716
|
+
eventType: 'start',
|
|
717
|
+
partialText: '',
|
|
718
|
+
deltaText: '',
|
|
719
|
+
timestamp: new Date().toISOString()
|
|
720
|
+
});
|
|
714
721
|
}
|
|
715
722
|
} else if (event.type === 'content_block_delta') {
|
|
716
723
|
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 +837,9 @@ class StreamManager extends EventEmitter {
|
|
|
830
837
|
savedReasoningParentId = saved?.parent_message_id || null;
|
|
831
838
|
}
|
|
832
839
|
|
|
840
|
+
// Clear reasoning text after save to prevent stale catchup injection
|
|
841
|
+
streamState.currentReasoningText = undefined;
|
|
842
|
+
|
|
833
843
|
this.emitStreamEvent(streamState, 'message', {
|
|
834
844
|
processId: streamState.processId,
|
|
835
845
|
message: reasoningMsg,
|
|
@@ -890,6 +900,16 @@ class StreamManager extends EventEmitter {
|
|
|
890
900
|
savedParentId = saved?.parent_message_id || null;
|
|
891
901
|
}
|
|
892
902
|
|
|
903
|
+
// Clear partial text after saving a complete assistant message to prevent
|
|
904
|
+
// cancelStream from saving a duplicate text-only message to DB.
|
|
905
|
+
// Also prevents catchupActiveStream from injecting a stale stream_event
|
|
906
|
+
// with text that's already part of the saved message.
|
|
907
|
+
if (message.type === 'assistant' && !message.metadata?.reasoning) {
|
|
908
|
+
streamState.currentPartialText = undefined;
|
|
909
|
+
} else if (message.type === 'assistant' && message.metadata?.reasoning) {
|
|
910
|
+
streamState.currentReasoningText = undefined;
|
|
911
|
+
}
|
|
912
|
+
|
|
893
913
|
streamState.messages.push({
|
|
894
914
|
processId: streamState.processId,
|
|
895
915
|
message,
|
|
@@ -1083,7 +1103,7 @@ class StreamManager extends EventEmitter {
|
|
|
1083
1103
|
return engine.resolveUserAnswer(toolUseId, answers);
|
|
1084
1104
|
}
|
|
1085
1105
|
|
|
1086
|
-
async cancelStream(streamId: string): Promise<boolean> {
|
|
1106
|
+
async cancelStream(streamId: string, reason?: string): Promise<boolean> {
|
|
1087
1107
|
const streamState = this.activeStreams.get(streamId);
|
|
1088
1108
|
if (!streamState || streamState.status !== 'active') {
|
|
1089
1109
|
return false;
|
|
@@ -1093,6 +1113,37 @@ class StreamManager extends EventEmitter {
|
|
|
1093
1113
|
streamState.status = 'cancelled';
|
|
1094
1114
|
streamState.completedAt = new Date();
|
|
1095
1115
|
|
|
1116
|
+
// Save partial reasoning text to DB before cancelling (persists across refresh/project switch)
|
|
1117
|
+
if (streamState.currentReasoningText && streamState.chatSessionId) {
|
|
1118
|
+
try {
|
|
1119
|
+
const reasoningMessage = {
|
|
1120
|
+
type: 'assistant' as const,
|
|
1121
|
+
parent_tool_use_id: null,
|
|
1122
|
+
message: {
|
|
1123
|
+
role: 'assistant' as const,
|
|
1124
|
+
content: [{ type: 'text' as const, text: streamState.currentReasoningText }]
|
|
1125
|
+
},
|
|
1126
|
+
session_id: streamState.sdkSessionId || '',
|
|
1127
|
+
metadata: { reasoning: true }
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
const timestamp = new Date().toISOString();
|
|
1131
|
+
const currentHead = sessionQueries.getHead(streamState.chatSessionId);
|
|
1132
|
+
|
|
1133
|
+
const savedMessage = messageQueries.create({
|
|
1134
|
+
session_id: streamState.chatSessionId,
|
|
1135
|
+
sdk_message: reasoningMessage as any,
|
|
1136
|
+
timestamp,
|
|
1137
|
+
parent_message_id: currentHead || undefined
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
sessionQueries.updateHead(streamState.chatSessionId, savedMessage.id);
|
|
1141
|
+
debug.log('chat', 'Saved partial reasoning on cancel:', savedMessage.id);
|
|
1142
|
+
} catch (error) {
|
|
1143
|
+
debug.error('chat', 'Failed to save partial reasoning on cancel:', error);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1096
1147
|
// Save partial text to DB before cancelling (persists across refresh/project switch)
|
|
1097
1148
|
if (streamState.currentPartialText && streamState.chatSessionId) {
|
|
1098
1149
|
try {
|
|
@@ -1141,16 +1192,19 @@ class StreamManager extends EventEmitter {
|
|
|
1141
1192
|
}
|
|
1142
1193
|
}
|
|
1143
1194
|
|
|
1144
|
-
// Cancel the per-project engine
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1195
|
+
// Cancel the per-project engine with a bounded timeout.
|
|
1196
|
+
// engine.cancel() stops the SDK process (Claude Code: close() kills subprocess,
|
|
1197
|
+
// OpenCode: aborts controller + HTTP abort to server). If cancel() hangs
|
|
1198
|
+
// (e.g. unresponsive SDK), the timeout ensures we always proceed to emit
|
|
1199
|
+
// events and update presence — preventing infinite loader on the frontend.
|
|
1149
1200
|
const projectId = streamState.projectId || 'default';
|
|
1150
1201
|
try {
|
|
1151
1202
|
const engine = getProjectEngine(projectId, streamState.engine);
|
|
1152
1203
|
if (engine.isActive) {
|
|
1153
|
-
await
|
|
1204
|
+
await Promise.race([
|
|
1205
|
+
engine.cancel(),
|
|
1206
|
+
new Promise<void>(resolve => setTimeout(resolve, 5000))
|
|
1207
|
+
]);
|
|
1154
1208
|
}
|
|
1155
1209
|
} catch (error) {
|
|
1156
1210
|
debug.error('chat', 'Error cancelling engine (non-fatal):', error);
|
|
@@ -1158,7 +1212,8 @@ class StreamManager extends EventEmitter {
|
|
|
1158
1212
|
|
|
1159
1213
|
// Abort the stream-manager's controller as a fallback.
|
|
1160
1214
|
// engine.cancel() already aborts the same controller, so this is
|
|
1161
|
-
// typically a no-op but ensures cleanup if the engine
|
|
1215
|
+
// typically a no-op but ensures cleanup if the engine timed out
|
|
1216
|
+
// or wasn't active.
|
|
1162
1217
|
if (!streamState.abortController?.signal.aborted) {
|
|
1163
1218
|
streamState.abortController?.abort();
|
|
1164
1219
|
}
|
|
@@ -1168,7 +1223,7 @@ class StreamManager extends EventEmitter {
|
|
|
1168
1223
|
timestamp: streamState.completedAt.toISOString()
|
|
1169
1224
|
});
|
|
1170
1225
|
|
|
1171
|
-
this.emitStreamLifecycle(streamState, 'cancelled');
|
|
1226
|
+
this.emitStreamLifecycle(streamState, 'cancelled', reason);
|
|
1172
1227
|
|
|
1173
1228
|
// Auto-release all MCP-controlled tabs for this chat session
|
|
1174
1229
|
if (streamState.chatSessionId) {
|
|
@@ -1186,8 +1241,16 @@ class StreamManager extends EventEmitter {
|
|
|
1186
1241
|
const streamState = this.activeStreams.get(streamId);
|
|
1187
1242
|
if (streamState) {
|
|
1188
1243
|
const sessionKey = this.getSessionKey(streamState.projectId, streamState.chatSessionId);
|
|
1189
|
-
|
|
1190
|
-
|
|
1244
|
+
// Only delete session key if it still points to THIS stream.
|
|
1245
|
+
// A newer stream for the same session may have overridden the key;
|
|
1246
|
+
// blindly deleting it would orphan the active stream — making it
|
|
1247
|
+
// unfindable by getSessionStream() and breaking cancel/reconnect.
|
|
1248
|
+
if (this.sessionStreams.get(sessionKey) === streamId) {
|
|
1249
|
+
this.sessionStreams.delete(sessionKey);
|
|
1250
|
+
}
|
|
1251
|
+
if (this.sessionStreams.get(streamState.chatSessionId) === streamId) {
|
|
1252
|
+
this.sessionStreams.delete(streamState.chatSessionId);
|
|
1253
|
+
}
|
|
1191
1254
|
this.activeStreams.delete(streamId);
|
|
1192
1255
|
|
|
1193
1256
|
// Cleanup project context service
|
|
@@ -1372,6 +1435,41 @@ class StreamManager extends EventEmitter {
|
|
|
1372
1435
|
});
|
|
1373
1436
|
}
|
|
1374
1437
|
|
|
1438
|
+
/**
|
|
1439
|
+
* Cancel and clean up all streams for a specific chat session.
|
|
1440
|
+
* Used when a session is deleted to remove green/amber status indicators.
|
|
1441
|
+
*/
|
|
1442
|
+
async cleanupSessionStreams(chatSessionId: string): Promise<void> {
|
|
1443
|
+
const streamsToCancel: string[] = [];
|
|
1444
|
+
const streamsToClean: string[] = [];
|
|
1445
|
+
|
|
1446
|
+
this.activeStreams.forEach((stream, streamId) => {
|
|
1447
|
+
if (stream.chatSessionId === chatSessionId) {
|
|
1448
|
+
if (stream.status === 'active') {
|
|
1449
|
+
streamsToCancel.push(streamId);
|
|
1450
|
+
} else {
|
|
1451
|
+
streamsToClean.push(streamId);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
// Cancel active streams and await their processStream promise so the
|
|
1457
|
+
// finally block (snapshot capture) completes before the caller deletes
|
|
1458
|
+
// the session — preventing FOREIGN KEY constraint failures.
|
|
1459
|
+
for (const streamId of streamsToCancel) {
|
|
1460
|
+
await this.cancelStream(streamId, 'session-deleted');
|
|
1461
|
+
const stream = this.activeStreams.get(streamId);
|
|
1462
|
+
if (stream?.streamPromise) {
|
|
1463
|
+
await stream.streamPromise.catch(() => {});
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// Clean up non-active streams
|
|
1468
|
+
for (const streamId of streamsToClean) {
|
|
1469
|
+
this.cleanupStream(streamId);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1375
1473
|
/**
|
|
1376
1474
|
* Clean up all completed streams
|
|
1377
1475
|
*/
|
|
@@ -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
|
|
|
@@ -130,6 +131,9 @@ export class ClaudeCodeEngine implements AIEngine {
|
|
|
130
131
|
options.signal.removeEventListener('abort', onAbort);
|
|
131
132
|
resolve(result);
|
|
132
133
|
},
|
|
134
|
+
removeAbortListener: () => {
|
|
135
|
+
options.signal.removeEventListener('abort', onAbort);
|
|
136
|
+
},
|
|
133
137
|
input
|
|
134
138
|
});
|
|
135
139
|
});
|
|
@@ -180,9 +184,15 @@ export class ClaudeCodeEngine implements AIEngine {
|
|
|
180
184
|
* Cancel active query
|
|
181
185
|
*/
|
|
182
186
|
async cancel(): Promise<void> {
|
|
183
|
-
//
|
|
187
|
+
// Remove abort listeners from pending AskUserQuestion promises WITHOUT
|
|
188
|
+
// resolving them. Resolving causes the SDK to call handleControlRequest →
|
|
189
|
+
// write() to send the permission result to the subprocess. If close() has
|
|
190
|
+
// already killed the subprocess, this write throws "Operation aborted" as
|
|
191
|
+
// an unhandled error, crashing the server. By removing listeners and not
|
|
192
|
+
// resolving, the promises are safely abandoned when close() terminates the
|
|
193
|
+
// process and the async generator completes.
|
|
184
194
|
for (const [, pending] of this.pendingUserAnswers) {
|
|
185
|
-
pending.
|
|
195
|
+
pending.removeAbortListener();
|
|
186
196
|
}
|
|
187
197
|
this.pendingUserAnswers.clear();
|
|
188
198
|
|