@myrialabs/clopen 0.2.12 → 0.2.14
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/snapshot/snapshot-service.ts +9 -7
- package/backend/terminal/stream-manager.ts +106 -155
- package/backend/ws/projects/crud.ts +3 -3
- package/backend/ws/snapshot/timeline.ts +6 -2
- package/backend/ws/terminal/persistence.ts +19 -33
- package/backend/ws/terminal/session.ts +37 -19
- package/bin/clopen.ts +376 -99
- package/bun.lock +6 -0
- package/frontend/components/chat/input/ChatInput.svelte +8 -0
- package/frontend/components/chat/input/components/LoadingIndicator.svelte +2 -2
- 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/checkpoint/TimelineModal.svelte +3 -1
- package/frontend/components/common/overlay/Dialog.svelte +2 -2
- package/frontend/components/git/ChangesSection.svelte +104 -13
- package/frontend/components/preview/browser/BrowserPreview.svelte +7 -0
- package/frontend/components/preview/browser/components/Canvas.svelte +8 -0
- package/frontend/components/settings/engines/AIEnginesSettings.svelte +1 -1
- package/frontend/components/settings/general/AuthModeSettings.svelte +2 -2
- package/frontend/components/terminal/Terminal.svelte +5 -1
- package/frontend/components/tunnel/TunnelInactive.svelte +4 -4
- package/frontend/services/chat/chat.service.ts +52 -11
- 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/frontend/stores/ui/settings-modal.svelte.ts +1 -1
- package/frontend/stores/ui/theme.svelte.ts +11 -11
- package/frontend/stores/ui/workspace.svelte.ts +1 -1
- package/index.html +2 -2
- package/package.json +4 -2
- package/shared/utils/anonymous-user.ts +4 -4
|
@@ -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
|
}
|
|
@@ -479,9 +479,6 @@ export class SnapshotService {
|
|
|
479
479
|
let restoredFiles = 0;
|
|
480
480
|
let skippedFiles = 0;
|
|
481
481
|
|
|
482
|
-
// Update in-memory baseline as we restore
|
|
483
|
-
const baseline = this.sessionBaselines.get(sessionId) || {};
|
|
484
|
-
|
|
485
482
|
for (const [filepath, expectedHash] of expectedState) {
|
|
486
483
|
// Check conflict resolution
|
|
487
484
|
if (conflictResolutions && conflictResolutions[filepath] === 'keep') {
|
|
@@ -509,7 +506,6 @@ export class SnapshotService {
|
|
|
509
506
|
// File should not exist at the target → delete it
|
|
510
507
|
try {
|
|
511
508
|
await fs.unlink(fullPath);
|
|
512
|
-
delete baseline[filepath];
|
|
513
509
|
debug.log('snapshot', `Deleted: ${filepath}`);
|
|
514
510
|
restoredFiles++;
|
|
515
511
|
} catch {
|
|
@@ -522,7 +518,6 @@ export class SnapshotService {
|
|
|
522
518
|
const dir = path.dirname(fullPath);
|
|
523
519
|
await fs.mkdir(dir, { recursive: true });
|
|
524
520
|
await fs.writeFile(fullPath, content);
|
|
525
|
-
baseline[filepath] = expectedHash;
|
|
526
521
|
debug.log('snapshot', `Restored: ${filepath}`);
|
|
527
522
|
restoredFiles++;
|
|
528
523
|
} catch (err) {
|
|
@@ -532,8 +527,15 @@ export class SnapshotService {
|
|
|
532
527
|
}
|
|
533
528
|
}
|
|
534
529
|
|
|
535
|
-
//
|
|
536
|
-
|
|
530
|
+
// Force re-initialize baseline from actual disk state.
|
|
531
|
+
// This is critical because:
|
|
532
|
+
// 1. Files already at expected state were skipped (baseline not updated for them)
|
|
533
|
+
// 2. After server restart, baseline starts empty — only restored files get entries
|
|
534
|
+
// 3. Files not mentioned in any snapshot are missing from the partial baseline
|
|
535
|
+
// Without this, subsequent captures would compute oldHash='' for files missing
|
|
536
|
+
// from the baseline, causing future restores to incorrectly delete those files.
|
|
537
|
+
this.sessionBaselines.delete(sessionId);
|
|
538
|
+
await this.initializeSessionBaseline(projectPath, sessionId);
|
|
537
539
|
|
|
538
540
|
debug.log('snapshot', `Restore complete: ${restoredFiles} restored, ${skippedFiles} skipped`);
|
|
539
541
|
return { restoredFiles, skippedFiles };
|
|
@@ -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
|
|
@@ -126,9 +126,13 @@ export const timelineHandler = createRouter()
|
|
|
126
126
|
const isOnActivePath = activePathIds.has(cp.id);
|
|
127
127
|
const isCurrent = cp.id === activeCheckpointId;
|
|
128
128
|
|
|
129
|
-
// Orphaned =
|
|
129
|
+
// Orphaned = in the "future" relative to the current checkpoint.
|
|
130
|
+
// At initial state: ALL checkpoints are orphaned (we've gone back before any messages).
|
|
131
|
+
// Otherwise: only descendants of the active checkpoint that are not on the active path.
|
|
130
132
|
let isOrphaned = false;
|
|
131
|
-
if (
|
|
133
|
+
if (isAtInitialState) {
|
|
134
|
+
isOrphaned = true;
|
|
135
|
+
} else if (activeCheckpointId && !isOnActivePath) {
|
|
132
136
|
isOrphaned = isDescendant(cp.id, activeCheckpointId, childrenMap);
|
|
133
137
|
}
|
|
134
138
|
|