@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.
Files changed (54) hide show
  1. package/README.md +61 -27
  2. package/backend/chat/stream-manager.ts +114 -16
  3. package/backend/database/queries/project-queries.ts +1 -4
  4. package/backend/database/queries/session-queries.ts +36 -1
  5. package/backend/database/queries/snapshot-queries.ts +122 -0
  6. package/backend/database/utils/connection.ts +17 -11
  7. package/backend/engine/adapters/claude/stream.ts +12 -2
  8. package/backend/engine/adapters/opencode/stream.ts +37 -19
  9. package/backend/index.ts +18 -2
  10. package/backend/mcp/servers/browser-automation/browser.ts +2 -0
  11. package/backend/preview/browser/browser-mcp-control.ts +16 -0
  12. package/backend/preview/browser/browser-navigation-tracker.ts +31 -3
  13. package/backend/preview/browser/browser-preview-service.ts +0 -34
  14. package/backend/preview/browser/browser-video-capture.ts +13 -1
  15. package/backend/preview/browser/scripts/audio-stream.ts +5 -0
  16. package/backend/preview/browser/types.ts +7 -6
  17. package/backend/snapshot/blob-store.ts +52 -72
  18. package/backend/snapshot/snapshot-service.ts +24 -0
  19. package/backend/terminal/stream-manager.ts +41 -2
  20. package/backend/ws/chat/stream.ts +14 -7
  21. package/backend/ws/engine/claude/accounts.ts +6 -8
  22. package/backend/ws/preview/browser/interact.ts +46 -50
  23. package/backend/ws/preview/browser/webcodecs.ts +24 -15
  24. package/backend/ws/projects/crud.ts +72 -7
  25. package/backend/ws/sessions/crud.ts +119 -2
  26. package/backend/ws/system/operations.ts +14 -39
  27. package/frontend/components/auth/SetupPage.svelte +1 -1
  28. package/frontend/components/chat/input/ChatInput.svelte +14 -1
  29. package/frontend/components/chat/message/MessageBubble.svelte +13 -0
  30. package/frontend/components/common/feedback/NotificationToast.svelte +26 -11
  31. package/frontend/components/common/form/FolderBrowser.svelte +17 -4
  32. package/frontend/components/common/overlay/Dialog.svelte +17 -15
  33. package/frontend/components/files/FileNode.svelte +16 -73
  34. package/frontend/components/git/CommitForm.svelte +1 -1
  35. package/frontend/components/history/HistoryModal.svelte +94 -19
  36. package/frontend/components/history/HistoryView.svelte +29 -36
  37. package/frontend/components/preview/browser/components/Canvas.svelte +119 -42
  38. package/frontend/components/preview/browser/components/Container.svelte +18 -3
  39. package/frontend/components/preview/browser/components/Toolbar.svelte +23 -21
  40. package/frontend/components/preview/browser/core/coordinator.svelte.ts +13 -1
  41. package/frontend/components/preview/browser/core/stream-handler.svelte.ts +31 -7
  42. package/frontend/components/preview/browser/core/tab-operations.svelte.ts +5 -4
  43. package/frontend/components/settings/engines/AIEnginesSettings.svelte +1 -1
  44. package/frontend/components/settings/general/DataManagementSettings.svelte +1 -54
  45. package/frontend/components/workspace/DesktopNavigator.svelte +57 -10
  46. package/frontend/components/workspace/MobileNavigator.svelte +57 -10
  47. package/frontend/components/workspace/WorkspaceLayout.svelte +0 -8
  48. package/frontend/services/chat/chat.service.ts +111 -16
  49. package/frontend/services/notification/global-stream-monitor.ts +5 -2
  50. package/frontend/services/notification/push.service.ts +2 -2
  51. package/frontend/services/preview/browser/browser-webcodecs.service.ts +170 -46
  52. package/frontend/stores/core/app.svelte.ts +10 -2
  53. package/frontend/stores/core/sessions.svelte.ts +4 -1
  54. 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
- // Abort the OpenCode session on the server so it stops processing
774
- const client = getClient();
775
- if (client && this.activeSessionId) {
776
- try {
777
- await client.session.abort({
778
- path: { id: this.activeSessionId },
779
- ...(this.activeProjectPath && { query: { directory: this.activeProjectPath } }),
780
- });
781
- debug.log('engine', 'Open Code session aborted:', this.activeSessionId);
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 client.session.abort({
806
- path: { id: sessionId },
807
- ...(projectPath && { query: { directory: projectPath } }),
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 frontend
138
- this.emit('navigation', {
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.emit('navigation', {
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
- // Inject video encoder + audio capture scripts
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 reasonable bandwidth:
232
+ * Optimized for visual quality with reduced resource usage:
233
233
  * - Software encoding (hardwareAcceleration: 'no-preference')
234
- * - JPEG quality 70 preserves thin borders/text, VP8 handles bandwidth
235
- * - VP8 at 1.2Mbps for crisp UI/text rendering
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: 1_200_000,
245
- keyframeInterval: 3,
246
- screenshotQuality: 70,
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
- return { hash: cached.hash, content: null, cached: true };
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 a tree file (cleanup)
154
+ * Delete multiple blobs by hash.
155
+ * Also invalidates fileHashCache entries whose hash matches a deleted blob.
216
156
  */
217
- async deleteTree(snapshotId: string): Promise<void> {
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.unlink(join(TREES_DIR, `${snapshotId}.json`));
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
- // Ignore - might not exist
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 = '.terminal-output-cache';
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({