@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 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 from dist/ using @elysiajs/static
61
- const { staticPlugin } = await import('@elysiajs/static');
62
-
63
- app.use(staticPlugin({
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.get('/*', () => {
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
- // If it's a different PTY, kill the old one
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
- // Create only 1 terminal session by default with correct project path and projectId
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.7",
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",