@myrialabs/clopen 0.0.7 → 0.0.8
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/index.ts +19 -10
- package/backend/lib/terminal/stream-manager.ts +6 -3
- package/backend/ws/terminal/session.ts +48 -0
- package/bun.lock +0 -3
- package/frontend/lib/services/terminal/project.service.ts +65 -10
- package/frontend/lib/services/terminal/terminal.service.ts +19 -0
- package/frontend/lib/stores/features/terminal.svelte.ts +10 -0
- package/package.json +1 -2
package/backend/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { debug } from '$shared/utils/logger';
|
|
|
14
14
|
import { findAvailablePort } from './lib/shared/port-utils';
|
|
15
15
|
import { networkInterfaces } from 'os';
|
|
16
16
|
import { resolve } from 'node:path';
|
|
17
|
+
import { statSync } from 'node:fs';
|
|
17
18
|
|
|
18
19
|
// Import WebSocket router
|
|
19
20
|
import { wsRouter } from './ws';
|
|
@@ -57,19 +58,27 @@ const app = new Elysia()
|
|
|
57
58
|
.use(wsRouter.asPlugin('/ws'));
|
|
58
59
|
|
|
59
60
|
if (!isDevelopment) {
|
|
60
|
-
// Production: serve static files
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
assets: 'dist',
|
|
65
|
-
prefix: '/',
|
|
66
|
-
}));
|
|
67
|
-
|
|
68
|
-
// SPA fallback: serve index.html for any unmatched route (client-side routing)
|
|
61
|
+
// Production: serve static files manually instead of @elysiajs/static.
|
|
62
|
+
// The static plugin tries to serve directories (like /) as files via Bun.file(),
|
|
63
|
+
// which hangs on some devices/platforms. Using statSync to verify the path is
|
|
64
|
+
// an actual file before serving avoids this issue.
|
|
69
65
|
const distDir = resolve(process.cwd(), 'dist');
|
|
70
66
|
const indexHtml = await Bun.file(resolve(distDir, 'index.html')).text();
|
|
71
67
|
|
|
72
|
-
app.
|
|
68
|
+
app.all('/*', ({ path }) => {
|
|
69
|
+
// Serve static files from dist/
|
|
70
|
+
if (path !== '/' && !path.includes('..')) {
|
|
71
|
+
const filePath = resolve(distDir, path.slice(1));
|
|
72
|
+
if (filePath.startsWith(distDir)) {
|
|
73
|
+
try {
|
|
74
|
+
if (statSync(filePath).isFile()) {
|
|
75
|
+
return new Response(Bun.file(filePath));
|
|
76
|
+
}
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// SPA fallback: serve cached index.html
|
|
73
82
|
return new Response(indexHtml, {
|
|
74
83
|
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
|
75
84
|
});
|
|
@@ -49,17 +49,20 @@ class TerminalStreamManager {
|
|
|
49
49
|
): string {
|
|
50
50
|
// Check if there's already a stream for this session
|
|
51
51
|
const existingStreamId = this.sessionToStream.get(sessionId);
|
|
52
|
+
let preservedOutput: string[] = [];
|
|
52
53
|
if (existingStreamId) {
|
|
53
54
|
const existingStream = this.streams.get(existingStreamId);
|
|
54
55
|
if (existingStream) {
|
|
55
|
-
// Clean up existing stream first
|
|
56
56
|
if (existingStream.pty && existingStream.pty !== pty) {
|
|
57
|
-
//
|
|
57
|
+
// Different PTY, kill the old one
|
|
58
58
|
try {
|
|
59
59
|
existingStream.pty.kill();
|
|
60
60
|
} catch (error) {
|
|
61
61
|
// Ignore error if PTY already killed
|
|
62
62
|
}
|
|
63
|
+
} else if (existingStream.pty === pty) {
|
|
64
|
+
// Same PTY (reconnection after browser refresh) - preserve output buffer
|
|
65
|
+
preservedOutput = [...existingStream.output];
|
|
63
66
|
}
|
|
64
67
|
// Remove the old stream
|
|
65
68
|
this.streams.delete(existingStreamId);
|
|
@@ -79,7 +82,7 @@ class TerminalStreamManager {
|
|
|
79
82
|
workingDirectory,
|
|
80
83
|
projectPath,
|
|
81
84
|
projectId,
|
|
82
|
-
output:
|
|
85
|
+
output: preservedOutput,
|
|
83
86
|
processId: pty.pid,
|
|
84
87
|
outputStartIndex: outputStartIndex || 0
|
|
85
88
|
};
|
|
@@ -179,6 +179,22 @@ export const sessionHandler = createRouter()
|
|
|
179
179
|
|
|
180
180
|
debug.log('terminal', `✅ Added fresh listeners to PTY session ${sessionId}`);
|
|
181
181
|
|
|
182
|
+
// Replay historical output for reconnection (e.g., after browser refresh)
|
|
183
|
+
// The stream preserves output from the old stream when reconnecting to the same PTY.
|
|
184
|
+
// Replay from outputStartIndex so frontend receives all output it doesn't have yet.
|
|
185
|
+
const historicalOutput = terminalStreamManager.getOutput(registeredStreamId, outputStartIndex);
|
|
186
|
+
if (historicalOutput.length > 0) {
|
|
187
|
+
debug.log('terminal', `📜 Replaying ${historicalOutput.length} historical output entries for session ${sessionId}`);
|
|
188
|
+
for (const output of historicalOutput) {
|
|
189
|
+
ws.emit.project(projectId, 'terminal:output', {
|
|
190
|
+
sessionId,
|
|
191
|
+
content: output,
|
|
192
|
+
projectId,
|
|
193
|
+
timestamp: new Date().toISOString()
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
182
198
|
// Broadcast terminal tab created to all project users
|
|
183
199
|
ws.emit.project(projectId, 'terminal:tab-created', {
|
|
184
200
|
sessionId,
|
|
@@ -379,4 +395,36 @@ export const sessionHandler = createRouter()
|
|
|
379
395
|
message: 'PTY not found'
|
|
380
396
|
};
|
|
381
397
|
}
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
// List active PTY sessions for a project
|
|
401
|
+
// Used after browser refresh to discover existing sessions
|
|
402
|
+
.http('terminal:list-sessions', {
|
|
403
|
+
data: t.Object({
|
|
404
|
+
projectId: t.String()
|
|
405
|
+
}),
|
|
406
|
+
response: t.Object({
|
|
407
|
+
sessions: t.Array(t.Object({
|
|
408
|
+
sessionId: t.String(),
|
|
409
|
+
pid: t.Number(),
|
|
410
|
+
cwd: t.String(),
|
|
411
|
+
createdAt: t.String(),
|
|
412
|
+
lastActivityAt: t.String()
|
|
413
|
+
}))
|
|
414
|
+
})
|
|
415
|
+
}, async ({ data }) => {
|
|
416
|
+
const { projectId } = data;
|
|
417
|
+
|
|
418
|
+
const allSessions = ptySessionManager.getAllSessions();
|
|
419
|
+
const projectSessions = allSessions
|
|
420
|
+
.filter(session => session.projectId === projectId)
|
|
421
|
+
.map(session => ({
|
|
422
|
+
sessionId: session.sessionId,
|
|
423
|
+
pid: session.pty.pid,
|
|
424
|
+
cwd: session.cwd,
|
|
425
|
+
createdAt: session.createdAt.toISOString(),
|
|
426
|
+
lastActivityAt: session.lastActivityAt.toISOString()
|
|
427
|
+
}));
|
|
428
|
+
|
|
429
|
+
return { sessions: projectSessions };
|
|
382
430
|
});
|
package/bun.lock
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
"@anthropic-ai/claude-agent-sdk": "^0.2.7",
|
|
9
9
|
"@anthropic-ai/sdk": "^0.62.0",
|
|
10
10
|
"@elysiajs/cors": "^1.4.0",
|
|
11
|
-
"@elysiajs/static": "^1.4.7",
|
|
12
11
|
"@iconify-json/lucide": "^1.2.57",
|
|
13
12
|
"@iconify-json/material-icon-theme": "^1.2.16",
|
|
14
13
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
@@ -77,8 +76,6 @@
|
|
|
77
76
|
|
|
78
77
|
"@elysiajs/cors": ["@elysiajs/cors@1.4.0", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-pb0SCzBfFbFSYA/U40HHO7R+YrcXBJXOWgL20eSViK33ol1e20ru2/KUaZYo5IMUn63yaTJI/bQERuQ+77ND8g=="],
|
|
79
78
|
|
|
80
|
-
"@elysiajs/static": ["@elysiajs/static@1.4.7", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-Go4kIXZ0G3iWfkAld07HmLglqIDMVXdyRKBQK/sVEjtpDdjHNb+rUIje73aDTWpZYg4PEVHUpi9v4AlNEwrQug=="],
|
|
81
|
-
|
|
82
79
|
"@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="],
|
|
83
80
|
|
|
84
81
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.8", "", { "os": "aix", "cpu": "ppc64" }, "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA=="],
|
|
@@ -98,28 +98,83 @@ class TerminalProjectManager {
|
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
100
|
* Create initial terminal sessions for a project
|
|
101
|
+
* First checks backend for existing PTY sessions (e.g., after browser refresh)
|
|
101
102
|
*/
|
|
102
103
|
private async createProjectTerminalSessions(projectId: string, projectPath: string): Promise<void> {
|
|
103
|
-
// Creating terminal session for project
|
|
104
|
-
|
|
105
104
|
const context = this.getOrCreateProjectContext(projectId, projectPath);
|
|
106
|
-
|
|
107
|
-
//
|
|
105
|
+
|
|
106
|
+
// Check backend for existing PTY sessions (survives browser refresh)
|
|
107
|
+
const existingBackendSessions = await terminalService.listProjectSessions(projectId);
|
|
108
|
+
|
|
109
|
+
if (existingBackendSessions.length > 0) {
|
|
110
|
+
debug.log('terminal', `Found ${existingBackendSessions.length} existing PTY sessions for project ${projectId}`);
|
|
111
|
+
|
|
112
|
+
// Sort by sessionId to maintain consistent order (terminal-1, terminal-2, etc.)
|
|
113
|
+
existingBackendSessions.sort((a, b) => a.sessionId.localeCompare(b.sessionId));
|
|
114
|
+
|
|
115
|
+
// Restore all existing sessions as tabs
|
|
116
|
+
for (const backendSession of existingBackendSessions) {
|
|
117
|
+
const sessionParts = backendSession.sessionId.split('-');
|
|
118
|
+
const terminalNumber = sessionParts[sessionParts.length - 1] || '1';
|
|
119
|
+
|
|
120
|
+
const terminalSession: TerminalSession = {
|
|
121
|
+
id: backendSession.sessionId,
|
|
122
|
+
name: `Terminal ${terminalNumber}`,
|
|
123
|
+
directory: backendSession.cwd || projectPath,
|
|
124
|
+
lines: [],
|
|
125
|
+
commandHistory: [],
|
|
126
|
+
isActive: false,
|
|
127
|
+
createdAt: new Date(backendSession.createdAt),
|
|
128
|
+
lastUsedAt: new Date(backendSession.lastActivityAt),
|
|
129
|
+
shellType: 'Unknown',
|
|
130
|
+
terminalBuffer: undefined,
|
|
131
|
+
projectId: projectId,
|
|
132
|
+
projectPath: projectPath
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
terminalStore.addSession(terminalSession);
|
|
136
|
+
terminalSessionManager.createSession(backendSession.sessionId, projectId, projectPath, backendSession.cwd || projectPath);
|
|
137
|
+
context.sessionIds.push(backendSession.sessionId);
|
|
138
|
+
|
|
139
|
+
// Update nextSessionId to avoid ID conflicts
|
|
140
|
+
const match = backendSession.sessionId.match(/terminal-(\d+)/);
|
|
141
|
+
if (match) {
|
|
142
|
+
terminalStore.updateNextSessionId(parseInt(match[1], 10) + 1);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Restore previously active session from sessionStorage, or default to first
|
|
147
|
+
let activeSessionId = existingBackendSessions[0].sessionId;
|
|
148
|
+
if (typeof sessionStorage !== 'undefined') {
|
|
149
|
+
try {
|
|
150
|
+
const savedActiveId = sessionStorage.getItem(`terminal-active-session-${projectId}`);
|
|
151
|
+
if (savedActiveId && context.sessionIds.includes(savedActiveId)) {
|
|
152
|
+
activeSessionId = savedActiveId;
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// sessionStorage not available
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
context.activeSessionId = activeSessionId;
|
|
159
|
+
terminalStore.switchToSession(context.activeSessionId);
|
|
160
|
+
this.persistContexts();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// No existing backend sessions, create 1 new terminal session
|
|
108
165
|
const sessionId = terminalStore.createNewSession(projectPath, projectPath, projectId);
|
|
109
|
-
|
|
110
|
-
// Update the session's directory to ensure it's correct
|
|
166
|
+
|
|
111
167
|
const session = terminalStore.getSession(sessionId);
|
|
112
168
|
if (session) {
|
|
113
169
|
session.directory = projectPath;
|
|
114
170
|
}
|
|
115
|
-
|
|
116
|
-
// Create a fresh session in terminalSessionManager with correct project association
|
|
171
|
+
|
|
117
172
|
terminalSessionManager.createSession(sessionId, projectId, projectPath, projectPath);
|
|
118
|
-
|
|
173
|
+
|
|
119
174
|
context.sessionIds.push(sessionId);
|
|
120
175
|
context.activeSessionId = sessionId;
|
|
121
176
|
terminalStore.switchToSession(sessionId);
|
|
122
|
-
|
|
177
|
+
|
|
123
178
|
this.persistContexts();
|
|
124
179
|
}
|
|
125
180
|
|
|
@@ -339,6 +339,25 @@ export class TerminalService {
|
|
|
339
339
|
}
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
/**
|
|
343
|
+
* List active PTY sessions for a project on the backend
|
|
344
|
+
* Used after browser refresh to discover existing sessions
|
|
345
|
+
*/
|
|
346
|
+
async listProjectSessions(projectId: string): Promise<Array<{
|
|
347
|
+
sessionId: string;
|
|
348
|
+
pid: number;
|
|
349
|
+
cwd: string;
|
|
350
|
+
createdAt: string;
|
|
351
|
+
lastActivityAt: string;
|
|
352
|
+
}>> {
|
|
353
|
+
try {
|
|
354
|
+
const data = await ws.http('terminal:list-sessions', { projectId }, 5000);
|
|
355
|
+
return data.sessions || [];
|
|
356
|
+
} catch {
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
342
361
|
/**
|
|
343
362
|
* Cleanup listeners for a session
|
|
344
363
|
*/
|
|
@@ -138,6 +138,16 @@ export const terminalStore = {
|
|
|
138
138
|
}));
|
|
139
139
|
|
|
140
140
|
terminalState.activeSessionId = sessionId;
|
|
141
|
+
|
|
142
|
+
// Persist active session ID for restoration after browser refresh
|
|
143
|
+
const session = terminalState.sessions.find(s => s.id === sessionId);
|
|
144
|
+
if (session?.projectId && typeof sessionStorage !== 'undefined') {
|
|
145
|
+
try {
|
|
146
|
+
sessionStorage.setItem(`terminal-active-session-${session.projectId}`, sessionId);
|
|
147
|
+
} catch {
|
|
148
|
+
// sessionStorage not available
|
|
149
|
+
}
|
|
150
|
+
}
|
|
141
151
|
},
|
|
142
152
|
|
|
143
153
|
async closeSession(sessionId: string): Promise<boolean> {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myrialabs/clopen",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"description": "All-in-one web workspace for Claude Code & OpenCode — chat, terminal, git, browser preview, checkpoints, and real-time collaboration",
|
|
5
5
|
"author": "Myria Labs",
|
|
6
6
|
"license": "MIT",
|
|
@@ -77,7 +77,6 @@
|
|
|
77
77
|
"@anthropic-ai/claude-agent-sdk": "^0.2.7",
|
|
78
78
|
"@anthropic-ai/sdk": "^0.62.0",
|
|
79
79
|
"@elysiajs/cors": "^1.4.0",
|
|
80
|
-
"@elysiajs/static": "^1.4.7",
|
|
81
80
|
"@iconify-json/lucide": "^1.2.57",
|
|
82
81
|
"@iconify-json/material-icon-theme": "^1.2.16",
|
|
83
82
|
"@modelcontextprotocol/sdk": "^1.26.0",
|