@myrialabs/clopen 0.2.11 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/backend/chat/stream-manager.ts +106 -9
  2. package/backend/database/queries/project-queries.ts +1 -4
  3. package/backend/database/queries/session-queries.ts +36 -1
  4. package/backend/database/queries/snapshot-queries.ts +122 -0
  5. package/backend/database/utils/connection.ts +17 -11
  6. package/backend/engine/adapters/claude/stream.ts +14 -3
  7. package/backend/engine/types.ts +9 -0
  8. package/backend/index.ts +13 -2
  9. package/backend/mcp/config.ts +32 -6
  10. package/backend/snapshot/blob-store.ts +52 -72
  11. package/backend/snapshot/snapshot-service.ts +24 -0
  12. package/backend/terminal/stream-manager.ts +121 -131
  13. package/backend/ws/chat/stream.ts +14 -7
  14. package/backend/ws/engine/claude/accounts.ts +6 -8
  15. package/backend/ws/projects/crud.ts +72 -7
  16. package/backend/ws/sessions/crud.ts +119 -2
  17. package/backend/ws/system/operations.ts +14 -39
  18. package/backend/ws/terminal/persistence.ts +19 -33
  19. package/backend/ws/terminal/session.ts +37 -19
  20. package/bun.lock +6 -0
  21. package/frontend/components/auth/SetupPage.svelte +1 -1
  22. package/frontend/components/chat/input/ChatInput.svelte +22 -1
  23. package/frontend/components/chat/input/composables/use-animations.svelte.ts +127 -111
  24. package/frontend/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -1
  25. package/frontend/components/chat/message/MessageBubble.svelte +13 -0
  26. package/frontend/components/chat/widgets/FloatingTodoList.svelte +2 -2
  27. package/frontend/components/common/form/FolderBrowser.svelte +17 -4
  28. package/frontend/components/common/overlay/Dialog.svelte +17 -15
  29. package/frontend/components/files/FileNode.svelte +0 -15
  30. package/frontend/components/git/ChangesSection.svelte +104 -13
  31. package/frontend/components/history/HistoryModal.svelte +94 -19
  32. package/frontend/components/history/HistoryView.svelte +29 -36
  33. package/frontend/components/settings/engines/AIEnginesSettings.svelte +1 -1
  34. package/frontend/components/settings/general/DataManagementSettings.svelte +1 -54
  35. package/frontend/components/terminal/Terminal.svelte +5 -1
  36. package/frontend/components/workspace/DesktopNavigator.svelte +57 -10
  37. package/frontend/components/workspace/MobileNavigator.svelte +57 -10
  38. package/frontend/components/workspace/WorkspaceLayout.svelte +0 -8
  39. package/frontend/services/chat/chat.service.ts +94 -23
  40. package/frontend/services/notification/global-stream-monitor.ts +5 -2
  41. package/frontend/services/terminal/project.service.ts +4 -60
  42. package/frontend/services/terminal/terminal.service.ts +18 -27
  43. package/frontend/stores/core/app.svelte.ts +10 -2
  44. package/frontend/stores/core/sessions.svelte.ts +10 -1
  45. package/package.json +4 -2
@@ -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
  */
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Terminal Stream Manager
3
3
  * Manages background terminal streams and their state
4
+ * Uses @xterm/headless to maintain accurate terminal state in-memory
4
5
  */
5
6
 
6
7
  import type { IPty } from 'bun-pty';
7
- import { existsSync, mkdirSync, readFileSync, unlinkSync } from 'fs';
8
- import { join } from 'path';
8
+ import { Terminal } from '@xterm/headless';
9
+ import { SerializeAddon } from '@xterm/addon-serialize';
9
10
 
10
11
  interface TerminalStream {
11
12
  streamId: string;
@@ -17,23 +18,30 @@ interface TerminalStream {
17
18
  workingDirectory?: string;
18
19
  projectPath?: string;
19
20
  projectId?: string;
20
- output: string[];
21
21
  processId?: number;
22
- outputStartIndex?: number; // Track where new output starts (for background output)
22
+ headlessTerminal: Terminal;
23
+ serializeAddon: SerializeAddon;
23
24
  }
24
25
 
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
29
 
30
- constructor() {
31
- // Create temp directory for output caching
32
- if (!existsSync(this.tempDir)) {
33
- mkdirSync(this.tempDir, { recursive: true });
34
- }
30
+ /**
31
+ * Create a headless terminal instance with serialize addon
32
+ */
33
+ private createHeadlessTerminal(cols: number, rows: number): { terminal: Terminal; serializeAddon: SerializeAddon } {
34
+ const terminal = new Terminal({
35
+ scrollback: 1000,
36
+ cols,
37
+ rows,
38
+ allowProposedApi: true
39
+ });
40
+ const serializeAddon = new SerializeAddon();
41
+ terminal.loadAddon(serializeAddon);
42
+ return { terminal, serializeAddon };
35
43
  }
36
-
44
+
37
45
  /**
38
46
  * Create a new terminal stream
39
47
  */
@@ -45,26 +53,45 @@ class TerminalStreamManager {
45
53
  projectPath?: string,
46
54
  projectId?: string,
47
55
  predefinedStreamId?: string,
48
- outputStartIndex?: number
56
+ terminalSize?: { cols: number; rows: number }
49
57
  ): string {
58
+ const cols = terminalSize?.cols || 80;
59
+ const rows = terminalSize?.rows || 24;
60
+
50
61
  // Check if there's already a stream for this session
51
62
  const existingStreamId = this.sessionToStream.get(sessionId);
52
- let preservedOutput: string[] = [];
53
63
  if (existingStreamId) {
54
64
  const existingStream = this.streams.get(existingStreamId);
55
65
  if (existingStream) {
56
- if (existingStream.pty && existingStream.pty !== pty) {
57
- // Different PTY, kill the old one
66
+ if (existingStream.pty === pty) {
67
+ // Same PTY (reconnection) - reuse existing headless terminal as-is
68
+ // The headless terminal already has all accumulated output
69
+ const newStreamId = predefinedStreamId || existingStreamId;
70
+
71
+ // Resize headless terminal if dimensions changed
72
+ existingStream.headlessTerminal.resize(cols, rows);
73
+
74
+ // Update stream ID if changed
75
+ if (newStreamId !== existingStreamId) {
76
+ this.streams.delete(existingStreamId);
77
+ existingStream.streamId = newStreamId;
78
+ this.streams.set(newStreamId, existingStream);
79
+ this.sessionToStream.set(sessionId, newStreamId);
80
+ }
81
+
82
+ return newStreamId;
83
+ }
84
+
85
+ // Different PTY, kill the old one and dispose headless terminal
86
+ if (existingStream.pty) {
58
87
  try {
59
88
  existingStream.pty.kill();
60
- } catch (error) {
89
+ } catch {
61
90
  // Ignore error if PTY already killed
62
91
  }
63
- } else if (existingStream.pty === pty) {
64
- // Same PTY (reconnection after browser refresh) - preserve output buffer
65
- preservedOutput = [...existingStream.output];
66
92
  }
67
- // Remove the old stream
93
+ existingStream.serializeAddon.dispose();
94
+ existingStream.headlessTerminal.dispose();
68
95
  this.streams.delete(existingStreamId);
69
96
  }
70
97
  }
@@ -72,6 +99,9 @@ class TerminalStreamManager {
72
99
  // Use provided streamId or generate a new one
73
100
  const streamId = predefinedStreamId || `terminal-stream-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
74
101
 
102
+ // Create headless terminal
103
+ const { terminal: headlessTerminal, serializeAddon } = this.createHeadlessTerminal(cols, rows);
104
+
75
105
  const stream: TerminalStream = {
76
106
  streamId,
77
107
  sessionId,
@@ -82,9 +112,9 @@ class TerminalStreamManager {
82
112
  workingDirectory,
83
113
  projectPath,
84
114
  projectId,
85
- output: preservedOutput,
86
115
  processId: pty.pid,
87
- outputStartIndex: outputStartIndex || 0
116
+ headlessTerminal,
117
+ serializeAddon
88
118
  };
89
119
 
90
120
  this.streams.set(streamId, stream);
@@ -92,14 +122,14 @@ class TerminalStreamManager {
92
122
 
93
123
  return streamId;
94
124
  }
95
-
125
+
96
126
  /**
97
127
  * Get stream by ID
98
128
  */
99
129
  getStream(streamId: string): TerminalStream | undefined {
100
130
  return this.streams.get(streamId);
101
131
  }
102
-
132
+
103
133
  /**
104
134
  * Get stream by session ID
105
135
  */
@@ -110,121 +140,65 @@ class TerminalStreamManager {
110
140
  }
111
141
  return undefined;
112
142
  }
113
-
143
+
114
144
  /**
115
- * Add output to stream
145
+ * Add output to stream (writes to headless terminal)
116
146
  */
117
147
  addOutput(streamId: string, output: string): void {
118
148
  const stream = this.streams.get(streamId);
119
149
  if (stream) {
120
- stream.output.push(output);
121
-
122
- // Keep only last 2000 entries to prevent memory overflow
123
- if (stream.output.length > 2000) {
124
- stream.output = stream.output.slice(-2000);
125
- }
126
-
127
- // Also persist output to disk for background persistence
128
- this.persistOutputToDisk(stream);
150
+ stream.headlessTerminal.write(output);
129
151
  }
130
152
  }
131
153
 
132
- /** Pending write flag to coalesce rapid writes */
133
- private pendingWrites = new Set<string>();
134
-
135
154
  /**
136
- * Persist output to disk for cross-project persistence (async, coalesced)
155
+ * Get serialized terminal output for a stream
137
156
  */
138
- private persistOutputToDisk(stream: TerminalStream): void {
139
- // Coalesce rapid writes - only schedule one write per session per microtask
140
- if (this.pendingWrites.has(stream.sessionId)) return;
141
- this.pendingWrites.add(stream.sessionId);
142
-
143
- queueMicrotask(() => {
144
- this.pendingWrites.delete(stream.sessionId);
145
-
146
- try {
147
- const cacheFile = join(this.tempDir, `${stream.sessionId}.json`);
148
-
149
- // Only save new output (from outputStartIndex onwards)
150
- const newOutput = stream.outputStartIndex !== undefined
151
- ? stream.output.slice(stream.outputStartIndex)
152
- : stream.output;
153
-
154
- const cacheData = {
155
- streamId: stream.streamId,
156
- sessionId: stream.sessionId,
157
- command: stream.command,
158
- projectId: stream.projectId,
159
- projectPath: stream.projectPath,
160
- workingDirectory: stream.workingDirectory,
161
- startedAt: stream.startedAt,
162
- status: stream.status,
163
- output: newOutput,
164
- outputStartIndex: stream.outputStartIndex || 0,
165
- lastUpdated: new Date().toISOString()
166
- };
167
-
168
- // Use Bun.write for non-blocking async disk write
169
- Bun.write(cacheFile, JSON.stringify(cacheData)).catch(() => {
170
- // Silently handle write errors
171
- });
172
- } catch {
173
- // Silently handle errors
174
- }
175
- });
157
+ getSerializedOutput(streamId: string): string {
158
+ const stream = this.streams.get(streamId);
159
+ if (stream) {
160
+ return stream.serializeAddon.serialize();
161
+ }
162
+ return '';
176
163
  }
177
164
 
178
165
  /**
179
- * Load cached output from disk (public method for API access)
166
+ * Get serialized terminal output by session ID
180
167
  */
181
- loadCachedOutput(sessionId: string): string[] | null {
182
- try {
183
- const cacheFile = join(this.tempDir, `${sessionId}.json`);
184
- if (existsSync(cacheFile)) {
185
- const data = JSON.parse(readFileSync(cacheFile, 'utf-8'));
186
- return data.output || [];
187
- }
188
- } catch (error) {
189
- // Silently handle read errors
168
+ getSerializedOutputBySession(sessionId: string): string {
169
+ const streamId = this.sessionToStream.get(sessionId);
170
+ if (streamId) {
171
+ return this.getSerializedOutput(streamId);
190
172
  }
191
- return null;
173
+ return '';
192
174
  }
193
-
175
+
194
176
  /**
195
- * Get output from index
177
+ * Clear headless terminal buffer (sync with frontend clear)
196
178
  */
197
- getOutput(streamId: string, fromIndex: number = 0): string[] {
198
- const stream = this.streams.get(streamId);
199
- if (stream) {
200
- return stream.output.slice(fromIndex);
201
- }
202
-
203
- // If stream not in memory, try to load from cache
204
- // This handles cases where server restarts or stream is cleaned from memory
205
- const sessionId = this.getSessionIdByStreamId(streamId);
206
- if (sessionId) {
207
- const cachedOutput = this.loadCachedOutput(sessionId);
208
- if (cachedOutput) {
209
- return cachedOutput.slice(fromIndex);
179
+ clearHeadlessTerminal(sessionId: string): void {
180
+ const streamId = this.sessionToStream.get(sessionId);
181
+ if (streamId) {
182
+ const stream = this.streams.get(streamId);
183
+ if (stream) {
184
+ stream.headlessTerminal.clear();
210
185
  }
211
186
  }
212
-
213
- return [];
214
187
  }
215
188
 
216
189
  /**
217
- * Get session ID from stream ID (helper method)
190
+ * Resize headless terminal to match PTY dimensions
218
191
  */
219
- private getSessionIdByStreamId(streamId: string): string | null {
220
- for (const [sessionId, sid] of this.sessionToStream.entries()) {
221
- if (sid === streamId) {
222
- return sessionId;
192
+ resizeHeadlessTerminal(sessionId: string, cols: number, rows: number): void {
193
+ const streamId = this.sessionToStream.get(sessionId);
194
+ if (streamId) {
195
+ const stream = this.streams.get(streamId);
196
+ if (stream) {
197
+ stream.headlessTerminal.resize(cols, rows);
223
198
  }
224
199
  }
225
- return null;
226
200
  }
227
-
201
+
228
202
  /**
229
203
  * Update stream status
230
204
  */
@@ -232,7 +206,7 @@ class TerminalStreamManager {
232
206
  const stream = this.streams.get(streamId);
233
207
  if (stream) {
234
208
  stream.status = status;
235
-
209
+
236
210
  // Clean up completed/cancelled streams after a delay
237
211
  if (status === 'completed' || status === 'cancelled' || status === 'error') {
238
212
  // Keep stream for 5 minutes for reconnection attempts
@@ -242,9 +216,9 @@ class TerminalStreamManager {
242
216
  }
243
217
  }
244
218
  }
245
-
219
+
246
220
  /**
247
- * Remove stream
221
+ * Remove stream and dispose headless terminal
248
222
  */
249
223
  removeStream(streamId: string): void {
250
224
  const stream = this.streams.get(streamId);
@@ -253,33 +227,27 @@ class TerminalStreamManager {
253
227
  if (stream.status === 'active' && stream.pty) {
254
228
  try {
255
229
  stream.pty.kill();
256
- } catch (error) {
230
+ } catch {
257
231
  // Silently handle error
258
232
  }
259
233
  }
260
234
 
261
- // Clean up cache file
262
- try {
263
- const cacheFile = join(this.tempDir, `${stream.sessionId}.json`);
264
- if (existsSync(cacheFile)) {
265
- unlinkSync(cacheFile);
266
- }
267
- } catch (error) {
268
- // Silently handle error
269
- }
235
+ // Dispose headless terminal
236
+ stream.serializeAddon.dispose();
237
+ stream.headlessTerminal.dispose();
270
238
 
271
239
  // Remove from maps
272
240
  this.streams.delete(streamId);
273
241
  this.sessionToStream.delete(stream.sessionId);
274
242
  }
275
243
  }
276
-
244
+
277
245
  /**
278
246
  * Get stream status info
279
247
  */
280
248
  getStreamStatus(streamId: string): {
281
249
  status: string;
282
- messagesCount: number;
250
+ bufferLength: number;
283
251
  startedAt: Date;
284
252
  processId?: number;
285
253
  } | null {
@@ -287,15 +255,37 @@ class TerminalStreamManager {
287
255
  if (!stream) {
288
256
  return null;
289
257
  }
290
-
258
+
291
259
  return {
292
260
  status: stream.status,
293
- messagesCount: stream.output.length,
261
+ bufferLength: stream.headlessTerminal.buffer.active.length,
294
262
  startedAt: stream.startedAt,
295
263
  processId: stream.processId
296
264
  };
297
265
  }
298
-
266
+
267
+ /**
268
+ * Clean up terminal streams for a specific project
269
+ */
270
+ cleanupProjectStreams(projectId: string): number {
271
+ let cleaned = 0;
272
+
273
+ for (const [streamId, stream] of this.streams) {
274
+ if (stream.projectId === projectId) {
275
+ if (stream.status === 'active' && stream.pty) {
276
+ try { stream.pty.kill(); } catch {}
277
+ }
278
+ stream.serializeAddon.dispose();
279
+ stream.headlessTerminal.dispose();
280
+ this.streams.delete(streamId);
281
+ this.sessionToStream.delete(stream.sessionId);
282
+ cleaned++;
283
+ }
284
+ }
285
+
286
+ return cleaned;
287
+ }
288
+
299
289
  /**
300
290
  * Clean up all streams
301
291
  */
@@ -307,4 +297,4 @@ class TerminalStreamManager {
307
297
  }
308
298
 
309
299
  // Export singleton instance
310
- export const terminalStreamManager = new TerminalStreamManager();
300
+ export const terminalStreamManager = new TerminalStreamManager();
@@ -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({
@@ -33,15 +33,13 @@ function stripAnsi(str: string): string {
33
33
  .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '');
34
34
  }
35
35
 
36
+ // Extracts the first https:// URL from PTY output.
37
+ // Known formats (may change across Claude Code versions):
38
+ // - https://claude.ai/oauth/authorize?...
39
+ // - https://claude.com/cai/oauth/authorize?...
36
40
  function extractAuthUrl(clean: string): string | null {
37
- const urlPrefix = 'https://claude.ai/oauth/authorize?';
38
- const urlStart = clean.indexOf(urlPrefix);
39
- if (urlStart === -1) return null;
40
-
41
- const pasteIdx = clean.indexOf('Paste', urlStart);
42
- if (pasteIdx === -1) return null;
43
-
44
- return clean.substring(urlStart, pasteIdx).replace(/\s/g, '');
41
+ const match = clean.match(/https:\/\/\S+/);
42
+ return match ? match[0] : null;
45
43
  }
46
44
 
47
45
  function extractOAuthToken(clean: string): string | null {