@myrialabs/clopen 0.2.11 → 0.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/chat/stream-manager.ts +106 -9
- package/backend/database/queries/project-queries.ts +1 -4
- package/backend/database/queries/session-queries.ts +36 -1
- package/backend/database/queries/snapshot-queries.ts +122 -0
- package/backend/database/utils/connection.ts +17 -11
- package/backend/engine/adapters/claude/stream.ts +14 -3
- package/backend/engine/types.ts +9 -0
- package/backend/index.ts +13 -2
- package/backend/mcp/config.ts +32 -6
- package/backend/snapshot/blob-store.ts +52 -72
- package/backend/snapshot/snapshot-service.ts +24 -0
- package/backend/terminal/stream-manager.ts +121 -131
- package/backend/ws/chat/stream.ts +14 -7
- package/backend/ws/engine/claude/accounts.ts +6 -8
- package/backend/ws/projects/crud.ts +72 -7
- package/backend/ws/sessions/crud.ts +119 -2
- package/backend/ws/system/operations.ts +14 -39
- package/backend/ws/terminal/persistence.ts +19 -33
- package/backend/ws/terminal/session.ts +37 -19
- package/bun.lock +6 -0
- package/frontend/components/auth/SetupPage.svelte +1 -1
- package/frontend/components/chat/input/ChatInput.svelte +22 -1
- package/frontend/components/chat/input/composables/use-animations.svelte.ts +127 -111
- package/frontend/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -1
- package/frontend/components/chat/message/MessageBubble.svelte +13 -0
- package/frontend/components/chat/widgets/FloatingTodoList.svelte +2 -2
- package/frontend/components/common/form/FolderBrowser.svelte +17 -4
- package/frontend/components/common/overlay/Dialog.svelte +17 -15
- package/frontend/components/files/FileNode.svelte +0 -15
- package/frontend/components/git/ChangesSection.svelte +104 -13
- package/frontend/components/history/HistoryModal.svelte +94 -19
- package/frontend/components/history/HistoryView.svelte +29 -36
- package/frontend/components/settings/engines/AIEnginesSettings.svelte +1 -1
- package/frontend/components/settings/general/DataManagementSettings.svelte +1 -54
- package/frontend/components/terminal/Terminal.svelte +5 -1
- package/frontend/components/workspace/DesktopNavigator.svelte +57 -10
- package/frontend/components/workspace/MobileNavigator.svelte +57 -10
- package/frontend/components/workspace/WorkspaceLayout.svelte +0 -8
- package/frontend/services/chat/chat.service.ts +94 -23
- package/frontend/services/notification/global-stream-monitor.ts +5 -2
- package/frontend/services/terminal/project.service.ts +4 -60
- package/frontend/services/terminal/terminal.service.ts +18 -27
- package/frontend/stores/core/app.svelte.ts +10 -2
- package/frontend/stores/core/sessions.svelte.ts +10 -1
- package/package.json +4 -2
|
@@ -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
|
*/
|
|
@@ -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 {
|
|
8
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
57
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
155
|
+
* Get serialized terminal output for a stream
|
|
137
156
|
*/
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
*
|
|
166
|
+
* Get serialized terminal output by session ID
|
|
180
167
|
*/
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
173
|
+
return '';
|
|
192
174
|
}
|
|
193
|
-
|
|
175
|
+
|
|
194
176
|
/**
|
|
195
|
-
*
|
|
177
|
+
* Clear headless terminal buffer (sync with frontend clear)
|
|
196
178
|
*/
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
if (
|
|
200
|
-
|
|
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
|
-
*
|
|
190
|
+
* Resize headless terminal to match PTY dimensions
|
|
218
191
|
*/
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
230
|
+
} catch {
|
|
257
231
|
// Silently handle error
|
|
258
232
|
}
|
|
259
233
|
}
|
|
260
234
|
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
38
|
-
|
|
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 {
|