@myrialabs/clopen 0.2.12 → 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 +3 -0
- package/backend/engine/adapters/claude/stream.ts +2 -1
- package/backend/engine/types.ts +9 -0
- package/backend/mcp/config.ts +32 -6
- package/backend/terminal/stream-manager.ts +106 -155
- package/backend/ws/projects/crud.ts +3 -3
- package/backend/ws/terminal/persistence.ts +19 -33
- package/backend/ws/terminal/session.ts +37 -19
- package/bun.lock +6 -0
- package/frontend/components/chat/input/ChatInput.svelte +8 -0
- 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/widgets/FloatingTodoList.svelte +2 -2
- package/frontend/components/git/ChangesSection.svelte +104 -13
- package/frontend/components/terminal/Terminal.svelte +5 -1
- package/frontend/services/chat/chat.service.ts +8 -10
- package/frontend/services/terminal/project.service.ts +4 -60
- package/frontend/services/terminal/terminal.service.ts +18 -27
- package/frontend/stores/core/sessions.svelte.ts +6 -0
- package/package.json +4 -2
|
@@ -495,6 +495,9 @@ class StreamManager extends EventEmitter {
|
|
|
495
495
|
includePartialMessages: true,
|
|
496
496
|
abortController: streamState.abortController,
|
|
497
497
|
...(claudeAccountId !== undefined && { claudeAccountId }),
|
|
498
|
+
...(projectId && chatSessionId && {
|
|
499
|
+
mcpContext: { projectId, chatSessionId, streamId: streamState.streamId }
|
|
500
|
+
}),
|
|
498
501
|
});
|
|
499
502
|
|
|
500
503
|
await projectContextService.runWithContextAsync(
|
|
@@ -90,7 +90,8 @@ export class ClaudeCodeEngine implements AIEngine {
|
|
|
90
90
|
|
|
91
91
|
try {
|
|
92
92
|
// Get custom MCP servers and allowed tools
|
|
93
|
-
|
|
93
|
+
// Pass mcpContext so tool handlers are bound to the correct project
|
|
94
|
+
const mcpServers = getEnabledMcpServers(options.mcpContext);
|
|
94
95
|
const allowedMcpTools = getAllowedMcpTools();
|
|
95
96
|
|
|
96
97
|
debug.log('mcp', '📦 Loading custom MCP servers...');
|
package/backend/engine/types.ts
CHANGED
|
@@ -11,6 +11,13 @@ import type { EngineType, EngineModel } from '$shared/types/engine';
|
|
|
11
11
|
|
|
12
12
|
export type { EngineType, EngineModel };
|
|
13
13
|
|
|
14
|
+
/** Execution context for MCP tool handlers (project isolation) */
|
|
15
|
+
export interface McpExecutionContext {
|
|
16
|
+
projectId?: string;
|
|
17
|
+
chatSessionId?: string;
|
|
18
|
+
streamId?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
/** Options passed to engine.streamQuery() */
|
|
15
22
|
export interface EngineQueryOptions {
|
|
16
23
|
projectPath: string;
|
|
@@ -22,6 +29,8 @@ export interface EngineQueryOptions {
|
|
|
22
29
|
includePartialMessages?: boolean;
|
|
23
30
|
abortController?: AbortController;
|
|
24
31
|
claudeAccountId?: number;
|
|
32
|
+
/** Context bound to MCP tool handlers for project isolation */
|
|
33
|
+
mcpContext?: McpExecutionContext;
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
/** Options for one-shot structured generation (no tools, no streaming) */
|
package/backend/mcp/config.ts
CHANGED
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
* to avoid duplication and make it easier to add new servers.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type
|
|
8
|
+
import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance, type McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
9
9
|
import type { McpRemoteConfig } from '@opencode-ai/sdk';
|
|
10
10
|
import type { ServerConfig, ParsedMcpToolName, ServerName } from './types';
|
|
11
|
-
import {
|
|
11
|
+
import type { McpExecutionContext } from '../engine/types';
|
|
12
|
+
import { serverRegistry, serverFactories, serverMetadata } from './servers';
|
|
13
|
+
import { projectContextService } from './project-context';
|
|
12
14
|
import { debug } from '$shared/utils/logger';
|
|
13
15
|
import { SERVER_ENV } from '../utils/env';
|
|
14
16
|
|
|
@@ -87,15 +89,39 @@ export const mcpServers: Record<string, ServerConfig & { instance: McpSdkServerC
|
|
|
87
89
|
*
|
|
88
90
|
* Creates FRESH server instances each call so that concurrent streams
|
|
89
91
|
* each get their own Protocol — avoids "Already connected to a transport" errors.
|
|
92
|
+
*
|
|
93
|
+
* When `context` is provided, tool handlers are wrapped to restore the
|
|
94
|
+
* AsyncLocalStorage execution context. This is required because the SDK
|
|
95
|
+
* invokes MCP tool handlers through IPC which breaks AsyncLocalStorage
|
|
96
|
+
* propagation — without this, background streams from Project A would
|
|
97
|
+
* resolve to Project B's preview browser when the user switches projects.
|
|
90
98
|
*/
|
|
91
|
-
export function getEnabledMcpServers(): Record<string, McpServerConfig> {
|
|
99
|
+
export function getEnabledMcpServers(context?: McpExecutionContext): Record<string, McpServerConfig> {
|
|
92
100
|
const enabledServers: Record<string, McpServerConfig> = {};
|
|
93
101
|
|
|
94
102
|
Object.entries(mcpServers).forEach(([serverName, serverConfig]) => {
|
|
95
103
|
if (serverConfig.enabled) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
104
|
+
if (context) {
|
|
105
|
+
// Create context-bound instance: wrap each tool handler so
|
|
106
|
+
// AsyncLocalStorage context is restored on invocation
|
|
107
|
+
const meta = serverMetadata[serverName as ServerName];
|
|
108
|
+
const sdkTools = (serverConfig.tools as readonly string[]).map(toolName => {
|
|
109
|
+
const def = meta.toolDefs[toolName];
|
|
110
|
+
const boundHandler = async (args: any) => {
|
|
111
|
+
return projectContextService.runWithContextAsync(context, () => def.handler(args));
|
|
112
|
+
};
|
|
113
|
+
return tool(toolName, def.description, def.schema, boundHandler as any);
|
|
114
|
+
});
|
|
115
|
+
enabledServers[serverName] = createSdkMcpServer({
|
|
116
|
+
name: meta.name,
|
|
117
|
+
version: '1.0.0',
|
|
118
|
+
tools: sdkTools
|
|
119
|
+
});
|
|
120
|
+
} else {
|
|
121
|
+
const factory = serverFactories[serverName as ServerName];
|
|
122
|
+
enabledServers[serverName] = factory ? factory() : serverConfig.instance;
|
|
123
|
+
}
|
|
124
|
+
debug.log('mcp', `✓ Enabled MCP server: ${serverName}${context ? ' (context-bound)' : ''}`);
|
|
99
125
|
} else {
|
|
100
126
|
debug.log('mcp', `✗ Disabled MCP server: ${serverName}`);
|
|
101
127
|
}
|
|
@@ -1,12 +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 {
|
|
9
|
-
import { getClopenDir } from '../utils/paths';
|
|
8
|
+
import { Terminal } from '@xterm/headless';
|
|
9
|
+
import { SerializeAddon } from '@xterm/addon-serialize';
|
|
10
10
|
|
|
11
11
|
interface TerminalStream {
|
|
12
12
|
streamId: string;
|
|
@@ -18,23 +18,30 @@ interface TerminalStream {
|
|
|
18
18
|
workingDirectory?: string;
|
|
19
19
|
projectPath?: string;
|
|
20
20
|
projectId?: string;
|
|
21
|
-
output: string[];
|
|
22
21
|
processId?: number;
|
|
23
|
-
|
|
22
|
+
headlessTerminal: Terminal;
|
|
23
|
+
serializeAddon: SerializeAddon;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
class TerminalStreamManager {
|
|
27
27
|
private streams: Map<string, TerminalStream> = new Map();
|
|
28
28
|
private sessionToStream: Map<string, string> = new Map();
|
|
29
|
-
private tempDir: string = join(getClopenDir(), 'terminal-cache');
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 };
|
|
36
43
|
}
|
|
37
|
-
|
|
44
|
+
|
|
38
45
|
/**
|
|
39
46
|
* Create a new terminal stream
|
|
40
47
|
*/
|
|
@@ -46,26 +53,45 @@ class TerminalStreamManager {
|
|
|
46
53
|
projectPath?: string,
|
|
47
54
|
projectId?: string,
|
|
48
55
|
predefinedStreamId?: string,
|
|
49
|
-
|
|
56
|
+
terminalSize?: { cols: number; rows: number }
|
|
50
57
|
): string {
|
|
58
|
+
const cols = terminalSize?.cols || 80;
|
|
59
|
+
const rows = terminalSize?.rows || 24;
|
|
60
|
+
|
|
51
61
|
// Check if there's already a stream for this session
|
|
52
62
|
const existingStreamId = this.sessionToStream.get(sessionId);
|
|
53
|
-
let preservedOutput: string[] = [];
|
|
54
63
|
if (existingStreamId) {
|
|
55
64
|
const existingStream = this.streams.get(existingStreamId);
|
|
56
65
|
if (existingStream) {
|
|
57
|
-
if (existingStream.pty
|
|
58
|
-
//
|
|
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) {
|
|
59
87
|
try {
|
|
60
88
|
existingStream.pty.kill();
|
|
61
|
-
} catch
|
|
89
|
+
} catch {
|
|
62
90
|
// Ignore error if PTY already killed
|
|
63
91
|
}
|
|
64
|
-
} else if (existingStream.pty === pty) {
|
|
65
|
-
// Same PTY (reconnection after browser refresh) - preserve output buffer
|
|
66
|
-
preservedOutput = [...existingStream.output];
|
|
67
92
|
}
|
|
68
|
-
|
|
93
|
+
existingStream.serializeAddon.dispose();
|
|
94
|
+
existingStream.headlessTerminal.dispose();
|
|
69
95
|
this.streams.delete(existingStreamId);
|
|
70
96
|
}
|
|
71
97
|
}
|
|
@@ -73,6 +99,9 @@ class TerminalStreamManager {
|
|
|
73
99
|
// Use provided streamId or generate a new one
|
|
74
100
|
const streamId = predefinedStreamId || `terminal-stream-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
75
101
|
|
|
102
|
+
// Create headless terminal
|
|
103
|
+
const { terminal: headlessTerminal, serializeAddon } = this.createHeadlessTerminal(cols, rows);
|
|
104
|
+
|
|
76
105
|
const stream: TerminalStream = {
|
|
77
106
|
streamId,
|
|
78
107
|
sessionId,
|
|
@@ -83,9 +112,9 @@ class TerminalStreamManager {
|
|
|
83
112
|
workingDirectory,
|
|
84
113
|
projectPath,
|
|
85
114
|
projectId,
|
|
86
|
-
output: preservedOutput,
|
|
87
115
|
processId: pty.pid,
|
|
88
|
-
|
|
116
|
+
headlessTerminal,
|
|
117
|
+
serializeAddon
|
|
89
118
|
};
|
|
90
119
|
|
|
91
120
|
this.streams.set(streamId, stream);
|
|
@@ -93,14 +122,14 @@ class TerminalStreamManager {
|
|
|
93
122
|
|
|
94
123
|
return streamId;
|
|
95
124
|
}
|
|
96
|
-
|
|
125
|
+
|
|
97
126
|
/**
|
|
98
127
|
* Get stream by ID
|
|
99
128
|
*/
|
|
100
129
|
getStream(streamId: string): TerminalStream | undefined {
|
|
101
130
|
return this.streams.get(streamId);
|
|
102
131
|
}
|
|
103
|
-
|
|
132
|
+
|
|
104
133
|
/**
|
|
105
134
|
* Get stream by session ID
|
|
106
135
|
*/
|
|
@@ -111,121 +140,65 @@ class TerminalStreamManager {
|
|
|
111
140
|
}
|
|
112
141
|
return undefined;
|
|
113
142
|
}
|
|
114
|
-
|
|
143
|
+
|
|
115
144
|
/**
|
|
116
|
-
* Add output to stream
|
|
145
|
+
* Add output to stream (writes to headless terminal)
|
|
117
146
|
*/
|
|
118
147
|
addOutput(streamId: string, output: string): void {
|
|
119
148
|
const stream = this.streams.get(streamId);
|
|
120
149
|
if (stream) {
|
|
121
|
-
stream.
|
|
122
|
-
|
|
123
|
-
// Keep only last 2000 entries to prevent memory overflow
|
|
124
|
-
if (stream.output.length > 2000) {
|
|
125
|
-
stream.output = stream.output.slice(-2000);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Also persist output to disk for background persistence
|
|
129
|
-
this.persistOutputToDisk(stream);
|
|
150
|
+
stream.headlessTerminal.write(output);
|
|
130
151
|
}
|
|
131
152
|
}
|
|
132
153
|
|
|
133
|
-
/** Pending write flag to coalesce rapid writes */
|
|
134
|
-
private pendingWrites = new Set<string>();
|
|
135
|
-
|
|
136
154
|
/**
|
|
137
|
-
*
|
|
155
|
+
* Get serialized terminal output for a stream
|
|
138
156
|
*/
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
this.pendingWrites.delete(stream.sessionId);
|
|
146
|
-
|
|
147
|
-
try {
|
|
148
|
-
const cacheFile = join(this.tempDir, `${stream.sessionId}.json`);
|
|
149
|
-
|
|
150
|
-
// Only save new output (from outputStartIndex onwards)
|
|
151
|
-
const newOutput = stream.outputStartIndex !== undefined
|
|
152
|
-
? stream.output.slice(stream.outputStartIndex)
|
|
153
|
-
: stream.output;
|
|
154
|
-
|
|
155
|
-
const cacheData = {
|
|
156
|
-
streamId: stream.streamId,
|
|
157
|
-
sessionId: stream.sessionId,
|
|
158
|
-
command: stream.command,
|
|
159
|
-
projectId: stream.projectId,
|
|
160
|
-
projectPath: stream.projectPath,
|
|
161
|
-
workingDirectory: stream.workingDirectory,
|
|
162
|
-
startedAt: stream.startedAt,
|
|
163
|
-
status: stream.status,
|
|
164
|
-
output: newOutput,
|
|
165
|
-
outputStartIndex: stream.outputStartIndex || 0,
|
|
166
|
-
lastUpdated: new Date().toISOString()
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
// Use Bun.write for non-blocking async disk write
|
|
170
|
-
Bun.write(cacheFile, JSON.stringify(cacheData)).catch(() => {
|
|
171
|
-
// Silently handle write errors
|
|
172
|
-
});
|
|
173
|
-
} catch {
|
|
174
|
-
// Silently handle errors
|
|
175
|
-
}
|
|
176
|
-
});
|
|
157
|
+
getSerializedOutput(streamId: string): string {
|
|
158
|
+
const stream = this.streams.get(streamId);
|
|
159
|
+
if (stream) {
|
|
160
|
+
return stream.serializeAddon.serialize();
|
|
161
|
+
}
|
|
162
|
+
return '';
|
|
177
163
|
}
|
|
178
164
|
|
|
179
165
|
/**
|
|
180
|
-
*
|
|
166
|
+
* Get serialized terminal output by session ID
|
|
181
167
|
*/
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const data = JSON.parse(readFileSync(cacheFile, 'utf-8'));
|
|
187
|
-
return data.output || [];
|
|
188
|
-
}
|
|
189
|
-
} catch (error) {
|
|
190
|
-
// Silently handle read errors
|
|
168
|
+
getSerializedOutputBySession(sessionId: string): string {
|
|
169
|
+
const streamId = this.sessionToStream.get(sessionId);
|
|
170
|
+
if (streamId) {
|
|
171
|
+
return this.getSerializedOutput(streamId);
|
|
191
172
|
}
|
|
192
|
-
return
|
|
173
|
+
return '';
|
|
193
174
|
}
|
|
194
|
-
|
|
175
|
+
|
|
195
176
|
/**
|
|
196
|
-
*
|
|
177
|
+
* Clear headless terminal buffer (sync with frontend clear)
|
|
197
178
|
*/
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
// If stream not in memory, try to load from cache
|
|
205
|
-
// This handles cases where server restarts or stream is cleaned from memory
|
|
206
|
-
const sessionId = this.getSessionIdByStreamId(streamId);
|
|
207
|
-
if (sessionId) {
|
|
208
|
-
const cachedOutput = this.loadCachedOutput(sessionId);
|
|
209
|
-
if (cachedOutput) {
|
|
210
|
-
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();
|
|
211
185
|
}
|
|
212
186
|
}
|
|
213
|
-
|
|
214
|
-
return [];
|
|
215
187
|
}
|
|
216
188
|
|
|
217
189
|
/**
|
|
218
|
-
*
|
|
190
|
+
* Resize headless terminal to match PTY dimensions
|
|
219
191
|
*/
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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);
|
|
224
198
|
}
|
|
225
199
|
}
|
|
226
|
-
return null;
|
|
227
200
|
}
|
|
228
|
-
|
|
201
|
+
|
|
229
202
|
/**
|
|
230
203
|
* Update stream status
|
|
231
204
|
*/
|
|
@@ -233,7 +206,7 @@ class TerminalStreamManager {
|
|
|
233
206
|
const stream = this.streams.get(streamId);
|
|
234
207
|
if (stream) {
|
|
235
208
|
stream.status = status;
|
|
236
|
-
|
|
209
|
+
|
|
237
210
|
// Clean up completed/cancelled streams after a delay
|
|
238
211
|
if (status === 'completed' || status === 'cancelled' || status === 'error') {
|
|
239
212
|
// Keep stream for 5 minutes for reconnection attempts
|
|
@@ -243,9 +216,9 @@ class TerminalStreamManager {
|
|
|
243
216
|
}
|
|
244
217
|
}
|
|
245
218
|
}
|
|
246
|
-
|
|
219
|
+
|
|
247
220
|
/**
|
|
248
|
-
* Remove stream
|
|
221
|
+
* Remove stream and dispose headless terminal
|
|
249
222
|
*/
|
|
250
223
|
removeStream(streamId: string): void {
|
|
251
224
|
const stream = this.streams.get(streamId);
|
|
@@ -254,33 +227,27 @@ class TerminalStreamManager {
|
|
|
254
227
|
if (stream.status === 'active' && stream.pty) {
|
|
255
228
|
try {
|
|
256
229
|
stream.pty.kill();
|
|
257
|
-
} catch
|
|
230
|
+
} catch {
|
|
258
231
|
// Silently handle error
|
|
259
232
|
}
|
|
260
233
|
}
|
|
261
234
|
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (existsSync(cacheFile)) {
|
|
266
|
-
unlinkSync(cacheFile);
|
|
267
|
-
}
|
|
268
|
-
} catch (error) {
|
|
269
|
-
// Silently handle error
|
|
270
|
-
}
|
|
235
|
+
// Dispose headless terminal
|
|
236
|
+
stream.serializeAddon.dispose();
|
|
237
|
+
stream.headlessTerminal.dispose();
|
|
271
238
|
|
|
272
239
|
// Remove from maps
|
|
273
240
|
this.streams.delete(streamId);
|
|
274
241
|
this.sessionToStream.delete(stream.sessionId);
|
|
275
242
|
}
|
|
276
243
|
}
|
|
277
|
-
|
|
244
|
+
|
|
278
245
|
/**
|
|
279
246
|
* Get stream status info
|
|
280
247
|
*/
|
|
281
248
|
getStreamStatus(streamId: string): {
|
|
282
249
|
status: string;
|
|
283
|
-
|
|
250
|
+
bufferLength: number;
|
|
284
251
|
startedAt: Date;
|
|
285
252
|
processId?: number;
|
|
286
253
|
} | null {
|
|
@@ -288,51 +255,35 @@ class TerminalStreamManager {
|
|
|
288
255
|
if (!stream) {
|
|
289
256
|
return null;
|
|
290
257
|
}
|
|
291
|
-
|
|
258
|
+
|
|
292
259
|
return {
|
|
293
260
|
status: stream.status,
|
|
294
|
-
|
|
261
|
+
bufferLength: stream.headlessTerminal.buffer.active.length,
|
|
295
262
|
startedAt: stream.startedAt,
|
|
296
263
|
processId: stream.processId
|
|
297
264
|
};
|
|
298
265
|
}
|
|
299
|
-
|
|
266
|
+
|
|
300
267
|
/**
|
|
301
|
-
* Clean up terminal
|
|
268
|
+
* Clean up terminal streams for a specific project
|
|
302
269
|
*/
|
|
303
|
-
|
|
304
|
-
let
|
|
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
|
-
}
|
|
270
|
+
cleanupProjectStreams(projectId: string): number {
|
|
271
|
+
let cleaned = 0;
|
|
323
272
|
|
|
324
|
-
// Also remove in-memory streams for this project
|
|
325
273
|
for (const [streamId, stream] of this.streams) {
|
|
326
274
|
if (stream.projectId === projectId) {
|
|
327
275
|
if (stream.status === 'active' && stream.pty) {
|
|
328
276
|
try { stream.pty.kill(); } catch {}
|
|
329
277
|
}
|
|
278
|
+
stream.serializeAddon.dispose();
|
|
279
|
+
stream.headlessTerminal.dispose();
|
|
330
280
|
this.streams.delete(streamId);
|
|
331
281
|
this.sessionToStream.delete(stream.sessionId);
|
|
282
|
+
cleaned++;
|
|
332
283
|
}
|
|
333
284
|
}
|
|
334
285
|
|
|
335
|
-
return
|
|
286
|
+
return cleaned;
|
|
336
287
|
}
|
|
337
288
|
|
|
338
289
|
/**
|
|
@@ -346,4 +297,4 @@ class TerminalStreamManager {
|
|
|
346
297
|
}
|
|
347
298
|
|
|
348
299
|
// Export singleton instance
|
|
349
|
-
export const terminalStreamManager = new TerminalStreamManager();
|
|
300
|
+
export const terminalStreamManager = new TerminalStreamManager();
|
|
@@ -106,9 +106,9 @@ export const crudHandler = createRouter()
|
|
|
106
106
|
|
|
107
107
|
const mode = data.mode ?? 'remove';
|
|
108
108
|
|
|
109
|
-
// Clean up terminal
|
|
110
|
-
const
|
|
111
|
-
debug.log('project', `Cleaned up ${
|
|
109
|
+
// Clean up terminal streams for this project
|
|
110
|
+
const cleanedStreams = terminalStreamManager.cleanupProjectStreams(data.id);
|
|
111
|
+
debug.log('project', `Cleaned up ${cleanedStreams} terminal streams for project ${data.id}`);
|
|
112
112
|
|
|
113
113
|
if (mode === 'full') {
|
|
114
114
|
// Full delete: remove sessions with blob cleanup, then the project itself
|
|
@@ -33,49 +33,38 @@ export const persistenceHandler = createRouter()
|
|
|
33
33
|
return status;
|
|
34
34
|
})
|
|
35
35
|
|
|
36
|
-
// Get missed output
|
|
36
|
+
// Get missed output (serialized terminal state)
|
|
37
37
|
.http('terminal:missed-output', {
|
|
38
38
|
data: t.Object({
|
|
39
39
|
sessionId: t.String(),
|
|
40
|
-
streamId: t.Optional(t.String())
|
|
41
|
-
fromIndex: t.Optional(t.Number())
|
|
40
|
+
streamId: t.Optional(t.String())
|
|
42
41
|
}),
|
|
43
42
|
response: t.Object({
|
|
44
43
|
sessionId: t.String(),
|
|
45
44
|
streamId: t.Union([t.String(), t.Null()]),
|
|
46
|
-
output: t.
|
|
47
|
-
outputCount: t.Number(),
|
|
45
|
+
output: t.String(),
|
|
48
46
|
status: t.String(),
|
|
49
|
-
fromIndex: t.Number(),
|
|
50
47
|
timestamp: t.String()
|
|
51
48
|
})
|
|
52
49
|
}, async ({ data }) => {
|
|
53
|
-
const { sessionId, streamId
|
|
50
|
+
const { sessionId, streamId } = data;
|
|
54
51
|
|
|
55
|
-
//
|
|
56
|
-
let output
|
|
52
|
+
// Get serialized terminal state from headless xterm
|
|
53
|
+
let output = '';
|
|
57
54
|
|
|
58
55
|
if (streamId) {
|
|
59
|
-
|
|
60
|
-
output = terminalStreamManager.getOutput(streamId, fromIndex);
|
|
56
|
+
output = terminalStreamManager.getSerializedOutput(streamId);
|
|
61
57
|
} else {
|
|
62
|
-
|
|
63
|
-
const cachedOutput = terminalStreamManager.loadCachedOutput(sessionId);
|
|
64
|
-
if (cachedOutput) {
|
|
65
|
-
output = cachedOutput.slice(fromIndex);
|
|
66
|
-
}
|
|
58
|
+
output = terminalStreamManager.getSerializedOutputBySession(sessionId);
|
|
67
59
|
}
|
|
68
60
|
|
|
69
|
-
// Get stream status if available
|
|
70
61
|
const streamStatus = streamId ? terminalStreamManager.getStreamStatus(streamId) : null;
|
|
71
62
|
|
|
72
63
|
return {
|
|
73
64
|
sessionId,
|
|
74
65
|
streamId: streamId || null,
|
|
75
66
|
output,
|
|
76
|
-
outputCount: output.length,
|
|
77
67
|
status: streamStatus?.status || 'unknown',
|
|
78
|
-
fromIndex,
|
|
79
68
|
timestamp: new Date().toISOString()
|
|
80
69
|
};
|
|
81
70
|
})
|
|
@@ -84,11 +73,10 @@ export const persistenceHandler = createRouter()
|
|
|
84
73
|
.on('terminal:reconnect', {
|
|
85
74
|
data: t.Object({
|
|
86
75
|
streamId: t.String(),
|
|
87
|
-
sessionId: t.String()
|
|
88
|
-
fromIndex: t.Optional(t.Number())
|
|
76
|
+
sessionId: t.String()
|
|
89
77
|
})
|
|
90
78
|
}, async ({ data, conn }) => {
|
|
91
|
-
const { streamId, sessionId
|
|
79
|
+
const { streamId, sessionId } = data;
|
|
92
80
|
const projectId = ws.getProjectId(conn);
|
|
93
81
|
|
|
94
82
|
const stream = terminalStreamManager.getStream(streamId);
|
|
@@ -102,17 +90,15 @@ export const persistenceHandler = createRouter()
|
|
|
102
90
|
}
|
|
103
91
|
|
|
104
92
|
try {
|
|
105
|
-
//
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
});
|
|
115
|
-
}
|
|
93
|
+
// Send serialized terminal state (frontend writes it to xterm to restore)
|
|
94
|
+
const serializedOutput = terminalStreamManager.getSerializedOutput(streamId);
|
|
95
|
+
|
|
96
|
+
if (serializedOutput) {
|
|
97
|
+
ws.emit.project(projectId, 'terminal:output', {
|
|
98
|
+
sessionId,
|
|
99
|
+
content: serializedOutput,
|
|
100
|
+
timestamp: new Date().toISOString()
|
|
101
|
+
});
|
|
116
102
|
}
|
|
117
103
|
|
|
118
104
|
if (stream.status === 'active') {
|