@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.
@@ -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
- const mcpServers = getEnabledMcpServers();
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...');
@@ -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) */
@@ -5,10 +5,12 @@
5
5
  * to avoid duplication and make it easier to add new servers.
6
6
  */
7
7
 
8
- import type { McpSdkServerConfigWithInstance, McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
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 { serverRegistry, serverFactories } from './servers';
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
- const factory = serverFactories[serverName as ServerName];
97
- enabledServers[serverName] = factory ? factory() : serverConfig.instance;
98
- debug.log('mcp', `✓ Enabled MCP server: ${serverName}`);
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 { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync } from 'fs';
8
- import { join } from 'path';
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
- outputStartIndex?: number; // Track where new output starts (for background output)
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
- constructor() {
32
- // Create temp directory for output caching
33
- if (!existsSync(this.tempDir)) {
34
- mkdirSync(this.tempDir, { recursive: true });
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
- outputStartIndex?: number
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 && existingStream.pty !== pty) {
58
- // 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) {
59
87
  try {
60
88
  existingStream.pty.kill();
61
- } catch (error) {
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
- // Remove the old stream
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
- outputStartIndex: outputStartIndex || 0
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.output.push(output);
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
- * Persist output to disk for cross-project persistence (async, coalesced)
155
+ * Get serialized terminal output for a stream
138
156
  */
139
- private persistOutputToDisk(stream: TerminalStream): void {
140
- // Coalesce rapid writes - only schedule one write per session per microtask
141
- if (this.pendingWrites.has(stream.sessionId)) return;
142
- this.pendingWrites.add(stream.sessionId);
143
-
144
- queueMicrotask(() => {
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
- * Load cached output from disk (public method for API access)
166
+ * Get serialized terminal output by session ID
181
167
  */
182
- loadCachedOutput(sessionId: string): string[] | null {
183
- try {
184
- const cacheFile = join(this.tempDir, `${sessionId}.json`);
185
- if (existsSync(cacheFile)) {
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 null;
173
+ return '';
193
174
  }
194
-
175
+
195
176
  /**
196
- * Get output from index
177
+ * Clear headless terminal buffer (sync with frontend clear)
197
178
  */
198
- getOutput(streamId: string, fromIndex: number = 0): string[] {
199
- const stream = this.streams.get(streamId);
200
- if (stream) {
201
- return stream.output.slice(fromIndex);
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
- * Get session ID from stream ID (helper method)
190
+ * Resize headless terminal to match PTY dimensions
219
191
  */
220
- private getSessionIdByStreamId(streamId: string): string | null {
221
- for (const [sessionId, sid] of this.sessionToStream.entries()) {
222
- if (sid === streamId) {
223
- 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);
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 (error) {
230
+ } catch {
258
231
  // Silently handle error
259
232
  }
260
233
  }
261
234
 
262
- // Clean up cache file
263
- try {
264
- const cacheFile = join(this.tempDir, `${stream.sessionId}.json`);
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
- messagesCount: number;
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
- messagesCount: stream.output.length,
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 cache files for a specific project
268
+ * Clean up terminal streams for a specific project
302
269
  */
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
- }
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 deleted;
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 cache for this project
110
- const cachedTerminals = terminalStreamManager.cleanupProjectCache(data.id);
111
- debug.log('project', `Cleaned up ${cachedTerminals} terminal cache files for project ${data.id}`);
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.Array(t.String()),
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, fromIndex = 0 } = data;
50
+ const { sessionId, streamId } = data;
54
51
 
55
- // Try to get output from stream manager (memory or cache)
56
- let output: string[] = [];
52
+ // Get serialized terminal state from headless xterm
53
+ let output = '';
57
54
 
58
55
  if (streamId) {
59
- // If streamId is provided, get output from that specific stream
60
- output = terminalStreamManager.getOutput(streamId, fromIndex);
56
+ output = terminalStreamManager.getSerializedOutput(streamId);
61
57
  } else {
62
- // Otherwise try to load cached output for the session
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, fromIndex = 0 } = data;
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
- // Broadcast missed output (frontend filters by sessionId for one-time replay)
106
- const existingOutput = terminalStreamManager.getOutput(streamId, fromIndex);
107
-
108
- if (existingOutput.length > 0) {
109
- for (const output of existingOutput) {
110
- ws.emit.project(projectId, 'terminal:output', {
111
- sessionId,
112
- content: output,
113
- timestamp: new Date().toISOString()
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') {