@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
|
@@ -770,20 +770,15 @@ export class OpenCodeEngine implements AIEngine {
|
|
|
770
770
|
}
|
|
771
771
|
|
|
772
772
|
async cancel(): Promise<void> {
|
|
773
|
-
//
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
} catch (error) {
|
|
783
|
-
debug.warn('engine', 'Failed to abort Open Code session:', error);
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
|
|
773
|
+
// Capture refs before clearing — needed for server-side abort below
|
|
774
|
+
const sessionId = this.activeSessionId;
|
|
775
|
+
const projectPath = this.activeProjectPath;
|
|
776
|
+
|
|
777
|
+
// 1. FIRST: Abort local stream processing immediately.
|
|
778
|
+
// This breaks the SSE event stream and causes the for-await loop
|
|
779
|
+
// in processStream() to throw AbortError, stopping all local processing.
|
|
780
|
+
// Must happen BEFORE the HTTP call because client.session.abort() can
|
|
781
|
+
// hang indefinitely if the OpenCode server is busy/unresponsive.
|
|
787
782
|
if (this.activeAbortController) {
|
|
788
783
|
this.activeAbortController.abort();
|
|
789
784
|
this.activeAbortController = null;
|
|
@@ -792,6 +787,26 @@ export class OpenCodeEngine implements AIEngine {
|
|
|
792
787
|
this.activeSessionId = null;
|
|
793
788
|
this.activeProjectPath = null;
|
|
794
789
|
this.pendingQuestions.clear();
|
|
790
|
+
|
|
791
|
+
// 2. THEN: Tell the OpenCode server to stop processing (with timeout).
|
|
792
|
+
// This is a courtesy cleanup — local processing is already stopped.
|
|
793
|
+
// The server-side session would otherwise keep running (consuming
|
|
794
|
+
// LLM API calls and compute resources) until it naturally completes.
|
|
795
|
+
const client = getClient();
|
|
796
|
+
if (client && sessionId) {
|
|
797
|
+
try {
|
|
798
|
+
await Promise.race([
|
|
799
|
+
client.session.abort({
|
|
800
|
+
path: { id: sessionId },
|
|
801
|
+
...(projectPath && { query: { directory: projectPath } }),
|
|
802
|
+
}),
|
|
803
|
+
new Promise<void>(resolve => setTimeout(resolve, 5000))
|
|
804
|
+
]);
|
|
805
|
+
debug.log('engine', 'Open Code session aborted:', sessionId);
|
|
806
|
+
} catch (error) {
|
|
807
|
+
debug.warn('engine', 'Failed to abort Open Code session (non-fatal):', error);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
795
810
|
}
|
|
796
811
|
|
|
797
812
|
/**
|
|
@@ -802,13 +817,16 @@ export class OpenCodeEngine implements AIEngine {
|
|
|
802
817
|
const client = getClient();
|
|
803
818
|
if (!client || !sessionId) return;
|
|
804
819
|
try {
|
|
805
|
-
await
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
820
|
+
await Promise.race([
|
|
821
|
+
client.session.abort({
|
|
822
|
+
path: { id: sessionId },
|
|
823
|
+
...(projectPath && { query: { directory: projectPath } }),
|
|
824
|
+
}),
|
|
825
|
+
new Promise<void>(resolve => setTimeout(resolve, 5000))
|
|
826
|
+
]);
|
|
809
827
|
debug.log('engine', 'Open Code session aborted (per-stream):', sessionId);
|
|
810
828
|
} catch (error) {
|
|
811
|
-
debug.warn('engine', 'Failed to abort Open Code session:', error);
|
|
829
|
+
debug.warn('engine', 'Failed to abort Open Code session (non-fatal):', error);
|
|
812
830
|
}
|
|
813
831
|
}
|
|
814
832
|
|
package/backend/index.ts
CHANGED
|
@@ -27,11 +27,15 @@ import { statSync } from 'node:fs';
|
|
|
27
27
|
// Import WebSocket router
|
|
28
28
|
import { wsRouter } from './ws';
|
|
29
29
|
|
|
30
|
+
// Import browser preview manager for graceful shutdown
|
|
31
|
+
import { browserPreviewServiceManager } from './preview';
|
|
32
|
+
|
|
30
33
|
// MCP remote server for Open Code custom tools
|
|
31
34
|
import { handleMcpRequest, closeMcpServer } from './mcp/remote-server';
|
|
32
35
|
|
|
33
36
|
// Auth middleware
|
|
34
37
|
import { checkRouteAccess } from './auth/permissions';
|
|
38
|
+
import { authRateLimiter } from './auth';
|
|
35
39
|
import { ws as wsServer } from './utils/ws';
|
|
36
40
|
|
|
37
41
|
// Register auth gate on WebSocket router — blocks unauthenticated/unauthorized access
|
|
@@ -162,20 +166,32 @@ async function gracefulShutdown() {
|
|
|
162
166
|
if (isShuttingDown) return;
|
|
163
167
|
isShuttingDown = true;
|
|
164
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
|
+
|
|
165
176
|
console.log('\n🛑 Shutting down server...');
|
|
166
177
|
try {
|
|
178
|
+
// Stop accepting new connections first — release the port ASAP
|
|
179
|
+
app.stop();
|
|
180
|
+
// Dispose rate limiter timer
|
|
181
|
+
authRateLimiter.dispose();
|
|
167
182
|
// Close MCP remote server (before engines, as they may still reference it)
|
|
168
183
|
await closeMcpServer();
|
|
184
|
+
// Cleanup browser preview sessions
|
|
185
|
+
await browserPreviewServiceManager.cleanup();
|
|
169
186
|
// Dispose all AI engines
|
|
170
187
|
await disposeAllEngines();
|
|
171
|
-
// Stop accepting new connections
|
|
172
|
-
app.stop();
|
|
173
188
|
// Close database connection
|
|
174
189
|
closeDatabase();
|
|
175
190
|
debug.log('server', '✅ Graceful shutdown completed');
|
|
176
191
|
} catch (error) {
|
|
177
192
|
debug.error('server', '❌ Error during shutdown:', error);
|
|
178
193
|
}
|
|
194
|
+
clearTimeout(forceExitTimer);
|
|
179
195
|
process.exit(0);
|
|
180
196
|
}
|
|
181
197
|
|
|
@@ -177,6 +177,8 @@ export async function switchTabHandler(args: { tabId: string; projectId?: string
|
|
|
177
177
|
isError: true
|
|
178
178
|
};
|
|
179
179
|
}
|
|
180
|
+
// Promote tab to end of session's set so getActiveTabSession() targets it next
|
|
181
|
+
browserMcpControl.promoteSessionTab(tab.id, chatSessionId);
|
|
180
182
|
}
|
|
181
183
|
|
|
182
184
|
return {
|
|
@@ -206,6 +206,22 @@ export class BrowserMcpControl extends EventEmitter {
|
|
|
206
206
|
// Control Acquisition
|
|
207
207
|
// ============================================================================
|
|
208
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Promote a tab to the end of the session's controlled set.
|
|
211
|
+
* This ensures getSessionTabs()[last] returns the most recently activated tab,
|
|
212
|
+
* which is used by getActiveTabSession to determine which tab MCP operates on.
|
|
213
|
+
*
|
|
214
|
+
* Must be called after switch_tab to reflect the new active tab.
|
|
215
|
+
*/
|
|
216
|
+
promoteSessionTab(browserTabId: string, chatSessionId: string): void {
|
|
217
|
+
const sessionSet = this.sessionTabs.get(chatSessionId);
|
|
218
|
+
if (sessionSet && sessionSet.has(browserTabId)) {
|
|
219
|
+
sessionSet.delete(browserTabId);
|
|
220
|
+
sessionSet.add(browserTabId);
|
|
221
|
+
debug.log('mcp', `🔀 Promoted tab ${browserTabId} to end of session ${chatSessionId.slice(0, 8)} set`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
209
225
|
/**
|
|
210
226
|
* Acquire control of a browser tab for a chat session.
|
|
211
227
|
*
|
|
@@ -6,10 +6,35 @@ import { debug } from '$shared/utils/logger';
|
|
|
6
6
|
export class BrowserNavigationTracker extends EventEmitter {
|
|
7
7
|
private cdpSessions = new Map<string, CDPSession>();
|
|
8
8
|
|
|
9
|
+
// Deduplication: track the last emitted navigation URL+timestamp per session.
|
|
10
|
+
// framenavigated and load often fire for the same navigation;
|
|
11
|
+
// this prevents emitting duplicate 'navigation' events that would cause
|
|
12
|
+
// parallel handleNavigation calls and double streaming restarts.
|
|
13
|
+
private lastNavigationEmit = new Map<string, { url: string; time: number }>();
|
|
14
|
+
private readonly DEDUP_WINDOW_MS = 500; // Ignore duplicate within 500ms
|
|
15
|
+
|
|
9
16
|
constructor() {
|
|
10
17
|
super();
|
|
11
18
|
}
|
|
12
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Emit a navigation event with deduplication.
|
|
22
|
+
* Returns true if the event was emitted, false if it was a duplicate.
|
|
23
|
+
*/
|
|
24
|
+
private emitNavigationDeduped(event: string, sessionId: string, url: string, data: any): boolean {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
const last = this.lastNavigationEmit.get(sessionId);
|
|
27
|
+
|
|
28
|
+
if (last && last.url === url && (now - last.time) < this.DEDUP_WINDOW_MS) {
|
|
29
|
+
debug.log('preview', `⏭️ Deduped ${event} for ${url} (${now - last.time}ms since last emit)`);
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
this.lastNavigationEmit.set(sessionId, { url, time: now });
|
|
34
|
+
this.emit(event, data);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
13
38
|
/**
|
|
14
39
|
* Check if two URLs differ only by hash/fragment.
|
|
15
40
|
* Hash-only changes are same-document navigations and should NOT trigger
|
|
@@ -134,8 +159,9 @@ export class BrowserNavigationTracker extends EventEmitter {
|
|
|
134
159
|
// Update session URL
|
|
135
160
|
session.url = newUrl;
|
|
136
161
|
|
|
137
|
-
// Emit navigation completed event to
|
|
138
|
-
|
|
162
|
+
// Emit navigation completed event (deduplicated to prevent double events
|
|
163
|
+
// from framenavigated + load firing for the same navigation)
|
|
164
|
+
this.emitNavigationDeduped('navigation', sessionId, newUrl, {
|
|
139
165
|
sessionId,
|
|
140
166
|
type: 'navigation',
|
|
141
167
|
url: newUrl,
|
|
@@ -184,7 +210,8 @@ export class BrowserNavigationTracker extends EventEmitter {
|
|
|
184
210
|
|
|
185
211
|
session.url = currentUrl;
|
|
186
212
|
|
|
187
|
-
this
|
|
213
|
+
// Deduplicated: framenavigated already emitted for this URL
|
|
214
|
+
this.emitNavigationDeduped('navigation', sessionId, currentUrl, {
|
|
188
215
|
sessionId,
|
|
189
216
|
type: 'navigation',
|
|
190
217
|
url: currentUrl,
|
|
@@ -246,6 +273,7 @@ export class BrowserNavigationTracker extends EventEmitter {
|
|
|
246
273
|
}
|
|
247
274
|
this.cdpSessions.delete(sessionId);
|
|
248
275
|
}
|
|
276
|
+
this.lastNavigationEmit.delete(sessionId);
|
|
249
277
|
}
|
|
250
278
|
|
|
251
279
|
async navigateSession(sessionId: string, session: BrowserTab, url: string): Promise<string> {
|
|
@@ -869,37 +869,3 @@ class BrowserPreviewServiceManager {
|
|
|
869
869
|
// Service manager instance (singleton)
|
|
870
870
|
export const browserPreviewServiceManager = new BrowserPreviewServiceManager();
|
|
871
871
|
|
|
872
|
-
// Graceful shutdown handlers
|
|
873
|
-
const gracefulShutdown = async (signal: string) => {
|
|
874
|
-
try {
|
|
875
|
-
await browserPreviewServiceManager.cleanup();
|
|
876
|
-
process.exit(0);
|
|
877
|
-
} catch (error) {
|
|
878
|
-
process.exit(1);
|
|
879
|
-
}
|
|
880
|
-
};
|
|
881
|
-
|
|
882
|
-
// Handle various termination signals
|
|
883
|
-
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
884
|
-
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
885
|
-
process.on('SIGHUP', () => gracefulShutdown('SIGHUP'));
|
|
886
|
-
|
|
887
|
-
// Handle Windows-specific signals
|
|
888
|
-
if (process.platform === 'win32') {
|
|
889
|
-
process.on('SIGBREAK', () => gracefulShutdown('SIGBREAK'));
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
// Handle uncaught exceptions and unhandled rejections
|
|
893
|
-
process.on('uncaughtException', async (error) => {
|
|
894
|
-
await browserPreviewServiceManager.cleanup();
|
|
895
|
-
process.exit(1);
|
|
896
|
-
});
|
|
897
|
-
|
|
898
|
-
process.on('unhandledRejection', async (reason, promise) => {
|
|
899
|
-
await browserPreviewServiceManager.cleanup();
|
|
900
|
-
process.exit(1);
|
|
901
|
-
});
|
|
902
|
-
|
|
903
|
-
// Handle process exit
|
|
904
|
-
process.on('exit', (code) => {
|
|
905
|
-
});
|
|
@@ -42,6 +42,7 @@ interface VideoStreamSession {
|
|
|
42
42
|
pendingCandidates: RTCIceCandidateInit[];
|
|
43
43
|
scriptInjected: boolean; // Track if persistent script was injected
|
|
44
44
|
scriptsPreInjected: boolean; // Track if scripts were pre-injected during tab creation
|
|
45
|
+
audioOnNewDocumentInjected: boolean; // Track if evaluateOnNewDocument was registered for audio
|
|
45
46
|
stats: {
|
|
46
47
|
videoBytesSent: number;
|
|
47
48
|
audioBytesSent: number;
|
|
@@ -97,6 +98,7 @@ export class BrowserVideoCapture extends EventEmitter {
|
|
|
97
98
|
pendingCandidates: [],
|
|
98
99
|
scriptInjected: true,
|
|
99
100
|
scriptsPreInjected: false, // Set to true only after injection completes
|
|
101
|
+
audioOnNewDocumentInjected: false,
|
|
100
102
|
stats: {
|
|
101
103
|
videoBytesSent: 0,
|
|
102
104
|
audioBytesSent: 0,
|
|
@@ -162,7 +164,16 @@ export class BrowserVideoCapture extends EventEmitter {
|
|
|
162
164
|
});
|
|
163
165
|
}
|
|
164
166
|
|
|
165
|
-
//
|
|
167
|
+
// Register audio capture as a startup script — runs before page scripts on every new document load.
|
|
168
|
+
// Critical for SPAs that create AudioContext during initialization (before page.evaluate runs).
|
|
169
|
+
// The idempotency guard in audioCaptureScript prevents double-injection.
|
|
170
|
+
const session = this.sessions.get(sessionId);
|
|
171
|
+
if (session && !session.audioOnNewDocumentInjected) {
|
|
172
|
+
await page.evaluateOnNewDocument(audioCaptureScript, config.audio);
|
|
173
|
+
session.audioOnNewDocumentInjected = true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Inject video encoder + audio capture scripts into the current page context
|
|
166
177
|
await page.evaluate(videoEncoderScript, videoConfig);
|
|
167
178
|
await page.evaluate(audioCaptureScript, config.audio);
|
|
168
179
|
}
|
|
@@ -220,6 +231,7 @@ export class BrowserVideoCapture extends EventEmitter {
|
|
|
220
231
|
pendingCandidates: [],
|
|
221
232
|
scriptInjected: false,
|
|
222
233
|
scriptsPreInjected: false,
|
|
234
|
+
audioOnNewDocumentInjected: false,
|
|
223
235
|
stats: {
|
|
224
236
|
videoBytesSent: 0,
|
|
225
237
|
audioBytesSent: 0,
|
|
@@ -16,6 +16,11 @@ import type { StreamingConfig } from '../types';
|
|
|
16
16
|
* This script intercepts AudioContext and captures all audio
|
|
17
17
|
*/
|
|
18
18
|
export function audioCaptureScript(config: StreamingConfig['audio']) {
|
|
19
|
+
// Idempotency guard — prevent double-injection when both evaluateOnNewDocument
|
|
20
|
+
// and page.evaluate inject this script into the same page context.
|
|
21
|
+
if ((window as any).__audioCaptureInstalled) return;
|
|
22
|
+
(window as any).__audioCaptureInstalled = true;
|
|
23
|
+
|
|
19
24
|
// Check AudioEncoder support
|
|
20
25
|
if (typeof AudioEncoder === 'undefined') {
|
|
21
26
|
(window as any).__audioEncoderSupported = false;
|
|
@@ -229,10 +229,11 @@ export interface StreamingConfig {
|
|
|
229
229
|
/**
|
|
230
230
|
* Default streaming configuration
|
|
231
231
|
*
|
|
232
|
-
* Optimized for visual quality with
|
|
232
|
+
* Optimized for visual quality with reduced resource usage:
|
|
233
233
|
* - Software encoding (hardwareAcceleration: 'no-preference')
|
|
234
|
-
* - JPEG quality
|
|
235
|
-
* - VP8 at 1.2Mbps
|
|
234
|
+
* - JPEG quality 65: slightly lower than before but still preserves thin borders/text
|
|
235
|
+
* - VP8 at 1.0Mbps: ~17% reduction from 1.2Mbps, sharp edges preserved by VP8 codec
|
|
236
|
+
* - keyframeInterval 5s: less frequent large keyframes, saves bandwidth on static pages
|
|
236
237
|
* - Opus for audio (efficient and widely supported)
|
|
237
238
|
*/
|
|
238
239
|
export const DEFAULT_STREAMING_CONFIG: StreamingConfig = {
|
|
@@ -241,9 +242,9 @@ export const DEFAULT_STREAMING_CONFIG: StreamingConfig = {
|
|
|
241
242
|
width: 0,
|
|
242
243
|
height: 0,
|
|
243
244
|
framerate: 24,
|
|
244
|
-
bitrate:
|
|
245
|
-
keyframeInterval:
|
|
246
|
-
screenshotQuality:
|
|
245
|
+
bitrate: 1_000_000,
|
|
246
|
+
keyframeInterval: 5,
|
|
247
|
+
screenshotQuality: 65,
|
|
247
248
|
hardwareAcceleration: 'no-preference',
|
|
248
249
|
latencyMode: 'realtime'
|
|
249
250
|
},
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Structure:
|
|
6
6
|
* ~/.clopen/snapshots/blobs/{hash[0:2]}/{hash}.gz - compressed file blobs
|
|
7
|
-
* ~/.clopen/snapshots/trees/{snapshotId}.json - tree maps (filepath -> hash)
|
|
8
7
|
*
|
|
9
8
|
* Deduplication: Same file content across any snapshot is stored only once.
|
|
10
9
|
* Compression: All blobs are gzip compressed to minimize disk usage.
|
|
@@ -18,7 +17,6 @@ import { getClopenDir } from '../utils/index.js';
|
|
|
18
17
|
|
|
19
18
|
const SNAPSHOTS_DIR = join(getClopenDir(), 'snapshots');
|
|
20
19
|
const BLOBS_DIR = join(SNAPSHOTS_DIR, 'blobs');
|
|
21
|
-
const TREES_DIR = join(SNAPSHOTS_DIR, 'trees');
|
|
22
20
|
|
|
23
21
|
export interface TreeMap {
|
|
24
22
|
[filepath: string]: string; // filepath -> blob hash
|
|
@@ -45,7 +43,6 @@ class BlobStore {
|
|
|
45
43
|
async init(): Promise<void> {
|
|
46
44
|
if (this.initialized) return;
|
|
47
45
|
await fs.mkdir(BLOBS_DIR, { recursive: true });
|
|
48
|
-
await fs.mkdir(TREES_DIR, { recursive: true });
|
|
49
46
|
this.initialized = true;
|
|
50
47
|
}
|
|
51
48
|
|
|
@@ -112,69 +109,6 @@ class BlobStore {
|
|
|
112
109
|
return gunzipSync(compressed);
|
|
113
110
|
}
|
|
114
111
|
|
|
115
|
-
/**
|
|
116
|
-
* Store a tree (snapshot state) as a JSON file.
|
|
117
|
-
* Returns the tree hash for reference.
|
|
118
|
-
*/
|
|
119
|
-
async storeTree(snapshotId: string, tree: TreeMap): Promise<string> {
|
|
120
|
-
await this.init();
|
|
121
|
-
const treePath = join(TREES_DIR, `${snapshotId}.json`);
|
|
122
|
-
const content = JSON.stringify(tree);
|
|
123
|
-
const treeHash = this.hashContent(Buffer.from(content, 'utf-8'));
|
|
124
|
-
await fs.writeFile(treePath, content, 'utf-8');
|
|
125
|
-
return treeHash;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Read a tree by snapshot ID
|
|
130
|
-
*/
|
|
131
|
-
async readTree(snapshotId: string): Promise<TreeMap> {
|
|
132
|
-
const treePath = join(TREES_DIR, `${snapshotId}.json`);
|
|
133
|
-
const content = await fs.readFile(treePath, 'utf-8');
|
|
134
|
-
return JSON.parse(content) as TreeMap;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Check if a tree exists
|
|
139
|
-
*/
|
|
140
|
-
async hasTree(snapshotId: string): Promise<boolean> {
|
|
141
|
-
try {
|
|
142
|
-
await fs.access(join(TREES_DIR, `${snapshotId}.json`));
|
|
143
|
-
return true;
|
|
144
|
-
} catch {
|
|
145
|
-
return false;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Resolve a tree to full file contents (as Buffers).
|
|
151
|
-
* Reads all blobs in parallel for performance.
|
|
152
|
-
* Returns { filepath: Buffer } map for binary-safe handling.
|
|
153
|
-
*/
|
|
154
|
-
async resolveTree(tree: TreeMap): Promise<Record<string, Buffer>> {
|
|
155
|
-
const result: Record<string, Buffer> = {};
|
|
156
|
-
|
|
157
|
-
const entries = Object.entries(tree);
|
|
158
|
-
const blobPromises = entries.map(async ([filepath, hash]) => {
|
|
159
|
-
try {
|
|
160
|
-
const content = await this.readBlob(hash);
|
|
161
|
-
return { filepath, content };
|
|
162
|
-
} catch (err) {
|
|
163
|
-
debug.warn('snapshot', `Could not read blob ${hash} for ${filepath}:`, err);
|
|
164
|
-
return null;
|
|
165
|
-
}
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
const results = await Promise.all(blobPromises);
|
|
169
|
-
for (const r of results) {
|
|
170
|
-
if (r) {
|
|
171
|
-
result[r.filepath] = r.content;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return result;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
112
|
/**
|
|
179
113
|
* Hash a file using mtime cache. Returns { hash, content? }.
|
|
180
114
|
* If the file hasn't changed (same mtime+size), returns cached hash without reading content.
|
|
@@ -191,10 +125,15 @@ class BlobStore {
|
|
|
191
125
|
// Check mtime cache
|
|
192
126
|
const cached = this.fileHashCache.get(filepath);
|
|
193
127
|
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
194
|
-
|
|
128
|
+
// Verify blob still exists on disk (could have been cleaned up)
|
|
129
|
+
if (await this.hasBlob(cached.hash)) {
|
|
130
|
+
return { hash: cached.hash, content: null, cached: true };
|
|
131
|
+
}
|
|
132
|
+
// Blob was deleted — invalidate cache, fall through to re-read and re-store
|
|
133
|
+
this.fileHashCache.delete(filepath);
|
|
195
134
|
}
|
|
196
135
|
|
|
197
|
-
// File changed - read as Buffer (binary-safe, no encoding conversion)
|
|
136
|
+
// File changed or cache miss - read as Buffer (binary-safe, no encoding conversion)
|
|
198
137
|
const content = await fs.readFile(fullPath);
|
|
199
138
|
const hash = this.hashContent(content);
|
|
200
139
|
|
|
@@ -212,14 +151,55 @@ class BlobStore {
|
|
|
212
151
|
}
|
|
213
152
|
|
|
214
153
|
/**
|
|
215
|
-
* Delete
|
|
154
|
+
* Delete multiple blobs by hash.
|
|
155
|
+
* Also invalidates fileHashCache entries whose hash matches a deleted blob.
|
|
216
156
|
*/
|
|
217
|
-
async
|
|
157
|
+
async deleteBlobs(hashes: string[]): Promise<number> {
|
|
158
|
+
const hashSet = new Set(hashes);
|
|
159
|
+
let deleted = 0;
|
|
160
|
+
for (const hash of hashes) {
|
|
161
|
+
try {
|
|
162
|
+
await fs.unlink(this.getBlobPath(hash));
|
|
163
|
+
deleted++;
|
|
164
|
+
} catch {
|
|
165
|
+
// Ignore - might not exist
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Invalidate fileHashCache entries pointing to deleted blobs
|
|
170
|
+
for (const [filepath, entry] of this.fileHashCache) {
|
|
171
|
+
if (hashSet.has(entry.hash)) {
|
|
172
|
+
this.fileHashCache.delete(filepath);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return deleted;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Scan all blob files on disk and return their hashes.
|
|
181
|
+
* Used for full garbage collection — compare with DB references to find orphans.
|
|
182
|
+
*/
|
|
183
|
+
async scanAllBlobHashes(): Promise<Set<string>> {
|
|
184
|
+
const hashes = new Set<string>();
|
|
218
185
|
try {
|
|
219
|
-
await fs.
|
|
186
|
+
const prefixDirs = await fs.readdir(BLOBS_DIR);
|
|
187
|
+
for (const prefix of prefixDirs) {
|
|
188
|
+
const prefixPath = join(BLOBS_DIR, prefix);
|
|
189
|
+
const stat = await fs.stat(prefixPath);
|
|
190
|
+
if (!stat.isDirectory()) continue;
|
|
191
|
+
|
|
192
|
+
const files = await fs.readdir(prefixPath);
|
|
193
|
+
for (const file of files) {
|
|
194
|
+
if (file.endsWith('.gz')) {
|
|
195
|
+
hashes.add(file.slice(0, -3)); // Remove .gz suffix
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
220
199
|
} catch {
|
|
221
|
-
//
|
|
200
|
+
// Directory might not exist yet
|
|
222
201
|
}
|
|
202
|
+
return hashes;
|
|
223
203
|
}
|
|
224
204
|
}
|
|
225
205
|
|
|
@@ -691,6 +691,30 @@ export class SnapshotService {
|
|
|
691
691
|
return calculateFileChangeStats(previousSnapshot, currentSnapshot);
|
|
692
692
|
}
|
|
693
693
|
|
|
694
|
+
/**
|
|
695
|
+
* Get all blob hashes from the in-memory baseline for a session.
|
|
696
|
+
* Must be called BEFORE clearSessionBaseline.
|
|
697
|
+
*/
|
|
698
|
+
getSessionBaselineHashes(sessionId: string): Set<string> {
|
|
699
|
+
const baseline = this.sessionBaselines.get(sessionId);
|
|
700
|
+
if (!baseline) return new Set();
|
|
701
|
+
return new Set(Object.values(baseline));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Get all blob hashes from ALL in-memory baselines (all active sessions).
|
|
706
|
+
* Used to protect blobs still needed by other sessions during cleanup.
|
|
707
|
+
*/
|
|
708
|
+
getAllBaselineHashes(): Set<string> {
|
|
709
|
+
const hashes = new Set<string>();
|
|
710
|
+
for (const baseline of this.sessionBaselines.values()) {
|
|
711
|
+
for (const hash of Object.values(baseline)) {
|
|
712
|
+
hashes.add(hash);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return hashes;
|
|
716
|
+
}
|
|
717
|
+
|
|
694
718
|
/**
|
|
695
719
|
* Clean up session baseline cache when session is no longer active.
|
|
696
720
|
*/
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { IPty } from 'bun-pty';
|
|
7
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync } from 'fs';
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync } from 'fs';
|
|
8
8
|
import { join } from 'path';
|
|
9
|
+
import { getClopenDir } from '../utils/paths';
|
|
9
10
|
|
|
10
11
|
interface TerminalStream {
|
|
11
12
|
streamId: string;
|
|
@@ -25,7 +26,7 @@ interface TerminalStream {
|
|
|
25
26
|
class TerminalStreamManager {
|
|
26
27
|
private streams: Map<string, TerminalStream> = new Map();
|
|
27
28
|
private sessionToStream: Map<string, string> = new Map();
|
|
28
|
-
private tempDir: string = '
|
|
29
|
+
private tempDir: string = join(getClopenDir(), 'terminal-cache');
|
|
29
30
|
|
|
30
31
|
constructor() {
|
|
31
32
|
// Create temp directory for output caching
|
|
@@ -296,6 +297,44 @@ class TerminalStreamManager {
|
|
|
296
297
|
};
|
|
297
298
|
}
|
|
298
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Clean up terminal cache files for a specific project
|
|
302
|
+
*/
|
|
303
|
+
cleanupProjectCache(projectId: string): number {
|
|
304
|
+
let deleted = 0;
|
|
305
|
+
try {
|
|
306
|
+
const files = readdirSync(this.tempDir);
|
|
307
|
+
for (const file of files) {
|
|
308
|
+
if (!file.endsWith('.json')) continue;
|
|
309
|
+
try {
|
|
310
|
+
const filePath = join(this.tempDir, file);
|
|
311
|
+
const data = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
312
|
+
if (data.projectId === projectId) {
|
|
313
|
+
unlinkSync(filePath);
|
|
314
|
+
deleted++;
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
// Skip unreadable files
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} catch {
|
|
321
|
+
// Directory may not exist
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Also remove in-memory streams for this project
|
|
325
|
+
for (const [streamId, stream] of this.streams) {
|
|
326
|
+
if (stream.projectId === projectId) {
|
|
327
|
+
if (stream.status === 'active' && stream.pty) {
|
|
328
|
+
try { stream.pty.kill(); } catch {}
|
|
329
|
+
}
|
|
330
|
+
this.streams.delete(streamId);
|
|
331
|
+
this.sessionToStream.delete(stream.sessionId);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return deleted;
|
|
336
|
+
}
|
|
337
|
+
|
|
299
338
|
/**
|
|
300
339
|
* Clean up all streams
|
|
301
340
|
*/
|
|
@@ -22,11 +22,11 @@ import { sessionQueries, messageQueries } from '../../database/queries';
|
|
|
22
22
|
// exists (e.g., after browser refresh when user is on a different project).
|
|
23
23
|
// Ensures cross-project notifications (presence update, sound, push) always work.
|
|
24
24
|
// ============================================================================
|
|
25
|
-
streamManager.on('stream:lifecycle', (event: { status: string; streamId: string; projectId?: string; chatSessionId?: string; timestamp: string }) => {
|
|
26
|
-
const { status, projectId, chatSessionId, timestamp } = event;
|
|
25
|
+
streamManager.on('stream:lifecycle', (event: { status: string; streamId: string; projectId?: string; chatSessionId?: string; timestamp: string; reason?: string }) => {
|
|
26
|
+
const { status, projectId, chatSessionId, timestamp, reason } = event;
|
|
27
27
|
if (!projectId) return;
|
|
28
28
|
|
|
29
|
-
debug.log('chat', `Stream lifecycle: ${status} for project ${projectId} session ${chatSessionId}`);
|
|
29
|
+
debug.log('chat', `Stream lifecycle: ${status} for project ${projectId} session ${chatSessionId}${reason ? ` (reason: ${reason})` : ''}`);
|
|
30
30
|
|
|
31
31
|
// Mark any tool_use blocks that never got a tool_result as interrupted (persisted to DB)
|
|
32
32
|
if (chatSessionId) {
|
|
@@ -42,7 +42,8 @@ streamManager.on('stream:lifecycle', (event: { status: string; streamId: string;
|
|
|
42
42
|
projectId,
|
|
43
43
|
chatSessionId: chatSessionId || '',
|
|
44
44
|
status: status as 'completed' | 'error' | 'cancelled',
|
|
45
|
-
timestamp
|
|
45
|
+
timestamp,
|
|
46
|
+
reason
|
|
46
47
|
});
|
|
47
48
|
|
|
48
49
|
// Broadcast updated presence (status indicators for all projects)
|
|
@@ -254,8 +255,11 @@ export const streamHandler = createRouter()
|
|
|
254
255
|
break;
|
|
255
256
|
}
|
|
256
257
|
} catch (err) {
|
|
258
|
+
// Log but do NOT unsubscribe — one bad event must not kill the
|
|
259
|
+
// entire stream subscription. The bridge between StreamManager
|
|
260
|
+
// and the WS room would be permanently broken, causing the UI
|
|
261
|
+
// to stop receiving stream output while the WS stays connected.
|
|
257
262
|
debug.error('chat', 'Error handling stream event:', err);
|
|
258
|
-
unsubscribe();
|
|
259
263
|
}
|
|
260
264
|
};
|
|
261
265
|
|
|
@@ -393,8 +397,10 @@ export const streamHandler = createRouter()
|
|
|
393
397
|
break;
|
|
394
398
|
}
|
|
395
399
|
} catch (err) {
|
|
400
|
+
// Log but do NOT unsubscribe — same rationale as the initial
|
|
401
|
+
// stream handler: a transient error must not permanently break
|
|
402
|
+
// the EventEmitter → WS room bridge.
|
|
396
403
|
debug.error('chat', 'Error handling reconnected stream event:', err);
|
|
397
|
-
unsubscribe();
|
|
398
404
|
}
|
|
399
405
|
};
|
|
400
406
|
|
|
@@ -789,7 +795,8 @@ export const streamHandler = createRouter()
|
|
|
789
795
|
projectId: t.String(),
|
|
790
796
|
chatSessionId: t.String(),
|
|
791
797
|
status: t.Union([t.Literal('completed'), t.Literal('error'), t.Literal('cancelled')]),
|
|
792
|
-
timestamp: t.String()
|
|
798
|
+
timestamp: t.String(),
|
|
799
|
+
reason: t.Optional(t.String())
|
|
793
800
|
}))
|
|
794
801
|
|
|
795
802
|
.emit('chat:waiting-input', t.Object({
|