@myrialabs/clopen 0.0.6 → 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/.env.example CHANGED
@@ -3,4 +3,10 @@ NODE_ENV=production
3
3
 
4
4
  # Server configuration
5
5
  HOST=localhost
6
- PORT=9141
6
+
7
+ # Production port (single port — Elysia serves everything)
8
+ PORT=9141
9
+
10
+ # Development ports (two ports — Vite proxies to Elysia)
11
+ PORT_BACKEND=9151
12
+ PORT_FRONTEND=9141
package/backend/index.ts CHANGED
@@ -11,7 +11,7 @@ import { loggerMiddleware } from './middleware/logger';
11
11
  import { initializeDatabase, closeDatabase } from './lib/database';
12
12
  import { disposeAllEngines } from './lib/engine';
13
13
  import { debug } from '$shared/utils/logger';
14
- import { isPortInUse } from './lib/shared/port-utils';
14
+ import { findAvailablePort } from './lib/shared/port-utils';
15
15
  import { networkInterfaces } from 'os';
16
16
  import { resolve } from 'node:path';
17
17
  import { statSync } from 'node:fs';
@@ -22,9 +22,8 @@ import { wsRouter } from './ws';
22
22
  /**
23
23
  * Clopen - Elysia Backend Server
24
24
  *
25
- * Single port: 9141 for everything.
26
- * Development: Vite embedded as middleware (no separate server)
27
- * Production: Serves frontend static files + API
25
+ * Development: Elysia runs on port 9151, Vite dev server proxies /api and /ws from port 9141
26
+ * Production: Elysia runs on port 9141, serves static files from dist/ + API + WebSocket
28
27
  */
29
28
 
30
29
  function getLocalIps(): string[] {
@@ -48,8 +47,8 @@ const app = new Elysia()
48
47
  .use(errorHandlerMiddleware)
49
48
  .use(loggerMiddleware)
50
49
 
51
- // Health check endpoint
52
- .get('/health', () => ({
50
+ // API routes
51
+ .get('/api/health', () => ({
53
52
  status: 'ok',
54
53
  timestamp: new Date().toISOString(),
55
54
  environment: SERVER_ENV.NODE_ENV
@@ -58,20 +57,11 @@ const app = new Elysia()
58
57
  // Mount WebSocket router (all functionality now via WebSocket)
59
58
  .use(wsRouter.asPlugin('/ws'));
60
59
 
61
- if (isDevelopment) {
62
- // Development: Embed Vite as middleware no separate port
63
- // Uses Vite's direct APIs (transformIndexHtml, transformRequest) for speed,
64
- // with Node.js compat middleware only as fallback for edge cases.
65
- const { initViteDev, handleDevRequest, closeViteDev } = await import('./lib/vite-dev');
66
- const vite = await initViteDev();
67
-
68
- app.all('/*', async ({ request }) => handleDevRequest(vite, request));
69
-
70
- // Store cleanup function for graceful shutdown
71
- (globalThis as any).__closeViteDev = closeViteDev;
72
- } else {
73
- // Production: Read index.html once at startup as a string so Content-Length
74
- // is always correct. Bun.file() streaming can hang on some platforms.
60
+ if (!isDevelopment) {
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.
75
65
  const distDir = resolve(process.cwd(), 'dist');
76
66
  const indexHtml = await Bun.file(resolve(distDir, 'index.html')).text();
77
67
 
@@ -93,15 +83,14 @@ if (isDevelopment) {
93
83
  headers: { 'Content-Type': 'text/html; charset=utf-8' }
94
84
  });
95
85
  });
96
-
97
86
  }
98
87
 
99
88
  // Start server with proper initialization sequence
100
89
  async function startServer() {
101
- // Strict check: refuse to start if port is already in use
102
- if (await isPortInUse(PORT)) {
103
- console.error(`❌ Port ${PORT} is already in use. Please close the existing process first.`);
104
- process.exit(1);
90
+ // Find available port auto-increment if desired port is in use
91
+ const actualPort = await findAvailablePort(PORT);
92
+ if (actualPort !== PORT) {
93
+ debug.log('server', `⚠️ Port ${PORT} in use, using ${actualPort} instead`);
105
94
  }
106
95
 
107
96
  // Initialize database first before accepting connections
@@ -114,14 +103,14 @@ async function startServer() {
114
103
 
115
104
  // Start listening after database is ready
116
105
  app.listen({
117
- port: PORT,
106
+ port: actualPort,
118
107
  hostname: HOST
119
108
  }, () => {
120
- console.log(`🚀 Clopen running at http://localhost:${PORT}`);
109
+ console.log(`🚀 Clopen running at http://localhost:${actualPort}`);
121
110
  if (HOST === '0.0.0.0') {
122
111
  const ips = getLocalIps();
123
112
  for (const ip of ips) {
124
- console.log(`🌐 Network access: http://${ip}:${PORT}`);
113
+ console.log(`🌐 Network access: http://${ip}:${actualPort}`);
125
114
  }
126
115
  }
127
116
  });
@@ -136,10 +125,6 @@ startServer().catch((error) => {
136
125
  async function gracefulShutdown() {
137
126
  console.log('\n🛑 Shutting down server...');
138
127
  try {
139
- // Close Vite dev server if running
140
- if (isDevelopment && (globalThis as any).__closeViteDev) {
141
- await (globalThis as any).__closeViteDev();
142
- }
143
128
  // Dispose all AI engines
144
129
  await disposeAllEngines();
145
130
  // Stop accepting new connections
@@ -18,11 +18,18 @@ import { readFileSync } from 'fs';
18
18
  import { join } from 'path';
19
19
 
20
20
  // ── Server configuration (read once at import time) ─────────────────
21
+ const isDev = process.env.NODE_ENV !== 'production';
22
+
21
23
  export const SERVER_ENV = {
22
24
  NODE_ENV: (process.env.NODE_ENV || 'development') as string,
23
- PORT: process.env.PORT ? parseInt(process.env.PORT) : 9141,
25
+ /** Backend port — dev: PORT_BACKEND (default 9151), prod: PORT (default 9141) */
26
+ PORT: isDev
27
+ ? (process.env.PORT_BACKEND ? parseInt(process.env.PORT_BACKEND) : 9151)
28
+ : (process.env.PORT ? parseInt(process.env.PORT) : 9141),
29
+ /** Frontend port — only used in dev for Vite proxy coordination */
30
+ PORT_FRONTEND: process.env.PORT_FRONTEND ? parseInt(process.env.PORT_FRONTEND) : 9141,
24
31
  HOST: (process.env.HOST || 'localhost') as string,
25
- isDevelopment: process.env.NODE_ENV !== 'production',
32
+ isDevelopment: isDev,
26
33
  } as const;
27
34
 
28
35
  // ── .env parsing ────────────────────────────────────────────────────
@@ -23,3 +23,13 @@ export async function isPortInUse(port: number): Promise<boolean> {
23
23
  return false; // Connection refused = port is free
24
24
  }
25
25
  }
26
+
27
+ /** Find an available port starting from the given port, incrementing on collision */
28
+ export async function findAvailablePort(startPort: number, maxAttempts = 8): Promise<number> {
29
+ let port = startPort;
30
+ for (let i = 0; i < maxAttempts; i++) {
31
+ if (!(await isPortInUse(port))) return port;
32
+ port++;
33
+ }
34
+ throw new Error(`No available port found starting from ${startPort} (tried ${maxAttempts} ports)`);
35
+ }
@@ -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",
@@ -45,6 +44,7 @@
45
44
  "@types/bun": "^1.2.18",
46
45
  "@types/qrcode": "^1.5.6",
47
46
  "@types/xterm": "^3.0.0",
47
+ "concurrently": "^9.2.1",
48
48
  "eslint": "^9.31.0",
49
49
  "eslint-plugin-svelte": "^3.10.1",
50
50
  "globals": "^16.3.0",
@@ -76,8 +76,6 @@
76
76
 
77
77
  "@elysiajs/cors": ["@elysiajs/cors@1.4.0", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-pb0SCzBfFbFSYA/U40HHO7R+YrcXBJXOWgL20eSViK33ol1e20ru2/KUaZYo5IMUn63yaTJI/bQERuQ+77ND8g=="],
78
78
 
79
- "@elysiajs/static": ["@elysiajs/static@1.4.7", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-Go4kIXZ0G3iWfkAld07HmLglqIDMVXdyRKBQK/sVEjtpDdjHNb+rUIje73aDTWpZYg4PEVHUpi9v4AlNEwrQug=="],
80
-
81
79
  "@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="],
82
80
 
83
81
  "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.8", "", { "os": "aix", "cpu": "ppc64" }, "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA=="],
@@ -488,7 +486,7 @@
488
486
 
489
487
  "chromium-bidi": ["chromium-bidi@11.0.0", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-cM3DI+OOb89T3wO8cpPSro80Q9eKYJ7hGVXoGS3GkDPxnYSqiv+6xwpIf6XERyJ9Tdsl09hmNmY94BkgZdVekw=="],
490
488
 
491
- "cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
489
+ "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
492
490
 
493
491
  "cloudflared": ["cloudflared@0.7.1", "", { "bin": { "cloudflared": "lib/cloudflared.js" } }, "sha512-jJn1Gu9Tf4qnIu8tfiHZ25Hs8rNcRYSVf8zAd97wvYdOCzftm1CTs1S/RPhijjGi8gUT1p9yzfDi9zYlU/0RwA=="],
494
492
 
@@ -506,6 +504,8 @@
506
504
 
507
505
  "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
508
506
 
507
+ "concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="],
508
+
509
509
  "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
510
510
 
511
511
  "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
@@ -1008,6 +1008,8 @@
1008
1008
 
1009
1009
  "rx.mini": ["rx.mini@1.4.0", "", {}, "sha512-8w5cSc1mwNja7fl465DXOkVvIOkpvh2GW4jo31nAIvX4WTXCsRnKJGUfiDBzWtYRInEcHAUYIZfzusjIrea8gA=="],
1010
1010
 
1011
+ "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
1012
+
1011
1013
  "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
1012
1014
 
1013
1015
  "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
@@ -1030,6 +1032,8 @@
1030
1032
 
1031
1033
  "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
1032
1034
 
1035
+ "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
1036
+
1033
1037
  "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
1034
1038
 
1035
1039
  "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
@@ -1070,7 +1074,7 @@
1070
1074
 
1071
1075
  "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
1072
1076
 
1073
- "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
1077
+ "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
1074
1078
 
1075
1079
  "svelte": ["svelte@5.36.12", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-c3mWT+b0yBLl3gPGSHiy4pdSQCsPNTjLC0tVoOhrGJ6PPfCzD/RQpAmAfJtQZ304CAae2ph+L3C4aqds3R3seQ=="],
1076
1080
 
@@ -1102,6 +1106,8 @@
1102
1106
 
1103
1107
  "token-types": ["token-types@6.0.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-MD9MjpVNhVyH4fyd5rKphjvt/1qj+PtQUz65aFqAZA6XniWAuSFRjLk3e2VALEFlh9OwBpXUN7rfeqSnT/Fmkw=="],
1104
1108
 
1109
+ "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
1110
+
1105
1111
  "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
1106
1112
 
1107
1113
  "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -1162,7 +1168,7 @@
1162
1168
 
1163
1169
  "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
1164
1170
 
1165
- "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
1171
+ "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
1166
1172
 
1167
1173
  "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
1168
1174
 
@@ -1170,15 +1176,15 @@
1170
1176
 
1171
1177
  "xterm": ["xterm@5.3.0", "", {}, "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg=="],
1172
1178
 
1173
- "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
1179
+ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
1174
1180
 
1175
1181
  "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
1176
1182
 
1177
1183
  "yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
1178
1184
 
1179
- "yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
1185
+ "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
1180
1186
 
1181
- "yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
1187
+ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
1182
1188
 
1183
1189
  "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
1184
1190
 
@@ -1218,8 +1224,6 @@
1218
1224
 
1219
1225
  "@puppeteer/browsers/tar-fs": ["tar-fs@3.1.1", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg=="],
1220
1226
 
1221
- "@puppeteer/browsers/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
1222
-
1223
1227
  "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" }, "bundled": true }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="],
1224
1228
 
1225
1229
  "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="],
@@ -1250,6 +1254,8 @@
1250
1254
 
1251
1255
  "body-parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
1252
1256
 
1257
+ "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
1258
+
1253
1259
  "eslint/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
1254
1260
 
1255
1261
  "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
@@ -1280,6 +1286,8 @@
1280
1286
 
1281
1287
  "puppeteer-core/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
1282
1288
 
1289
+ "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
1290
+
1283
1291
  "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
1284
1292
 
1285
1293
  "router/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -1322,32 +1330,32 @@
1322
1330
 
1323
1331
  "werift-ice/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
1324
1332
 
1325
- "yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
1326
-
1327
1333
  "yauzl/buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
1328
1334
 
1329
1335
  "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
1330
1336
 
1331
1337
  "@puppeteer/browsers/tar-fs/tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
1332
1338
 
1333
- "@puppeteer/browsers/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
1334
-
1335
- "@puppeteer/browsers/yargs/y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
1336
-
1337
- "@puppeteer/browsers/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
1338
-
1339
1339
  "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
1340
1340
 
1341
1341
  "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
1342
1342
 
1343
1343
  "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
1344
1344
 
1345
- "yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
1345
+ "qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
1346
+
1347
+ "qrcode/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
1348
+
1349
+ "qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
1350
+
1351
+ "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
1352
+
1353
+ "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
1346
1354
 
1347
- "@puppeteer/browsers/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
1355
+ "qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
1348
1356
 
1349
- "yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
1357
+ "qrcode/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
1350
1358
 
1351
- "yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
1359
+ "qrcode/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
1352
1360
  }
1353
1361
  }
@@ -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.6",
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",
@@ -45,9 +45,11 @@
45
45
  "bun": ">=1.2.12"
46
46
  },
47
47
  "scripts": {
48
- "dev": "bun --watch backend/index.ts",
48
+ "dev": "bun scripts/dev.ts",
49
+ "dev:backend": "bun --watch backend/index.ts",
50
+ "dev:frontend": "bunx vite dev",
49
51
  "build": "vite build",
50
- "start": "bun backend/index.ts",
52
+ "start": "NODE_ENV=production bun backend/index.ts",
51
53
  "check": "svelte-check --tsconfig ./tsconfig.json",
52
54
  "lint": "eslint .",
53
55
  "lint:fix": "eslint . --fix",
@@ -60,6 +62,7 @@
60
62
  "@types/bun": "^1.2.18",
61
63
  "@types/qrcode": "^1.5.6",
62
64
  "@types/xterm": "^3.0.0",
65
+ "concurrently": "^9.2.1",
63
66
  "eslint": "^9.31.0",
64
67
  "eslint-plugin-svelte": "^3.10.1",
65
68
  "globals": "^16.3.0",
@@ -74,7 +77,6 @@
74
77
  "@anthropic-ai/claude-agent-sdk": "^0.2.7",
75
78
  "@anthropic-ai/sdk": "^0.62.0",
76
79
  "@elysiajs/cors": "^1.4.0",
77
- "@elysiajs/static": "^1.4.7",
78
80
  "@iconify-json/lucide": "^1.2.57",
79
81
  "@iconify-json/material-icon-theme": "^1.2.16",
80
82
  "@modelcontextprotocol/sdk": "^1.26.0",
package/scripts/dev.ts ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Development script — runs backend and frontend concurrently.
5
+ * Resolves available ports before spawning, then passes them via env
6
+ * so Vite proxy and Elysia backend use the same coordinated ports.
7
+ */
8
+
9
+ import concurrently from 'concurrently';
10
+ import { findAvailablePort } from '../backend/lib/shared/port-utils';
11
+
12
+ const desiredBackend = process.env.PORT_BACKEND ? parseInt(process.env.PORT_BACKEND) : 9151;
13
+ const desiredFrontend = process.env.PORT_FRONTEND ? parseInt(process.env.PORT_FRONTEND) : 9141;
14
+
15
+ // Resolve available ports
16
+ const backendPort = await findAvailablePort(desiredBackend);
17
+ let frontendPort = await findAvailablePort(desiredFrontend);
18
+
19
+ // Ensure they don't collide
20
+ if (frontendPort === backendPort) {
21
+ frontendPort = await findAvailablePort(frontendPort + 1);
22
+ }
23
+
24
+ if (backendPort !== desiredBackend) {
25
+ console.log(`⚠️ Backend port ${desiredBackend} in use, using ${backendPort}`);
26
+ }
27
+ if (frontendPort !== desiredFrontend) {
28
+ console.log(`⚠️ Frontend port ${desiredFrontend} in use, using ${frontendPort}`);
29
+ }
30
+
31
+ console.log(`Backend: http://localhost:${backendPort}`);
32
+ console.log(`Frontend: http://localhost:${frontendPort}`);
33
+ console.log();
34
+
35
+ const portEnv = {
36
+ PORT_BACKEND: String(backendPort),
37
+ PORT_FRONTEND: String(frontendPort),
38
+ };
39
+
40
+ concurrently([
41
+ { command: 'bun --watch backend/index.ts', name: 'backend', prefixColor: 'blue', env: portEnv },
42
+ { command: 'bunx vite dev', name: 'frontend', prefixColor: 'green', env: portEnv },
43
+ ], {
44
+ killOthersOn: ['failure'],
45
+ });
package/vite.config.ts CHANGED
@@ -3,9 +3,26 @@ import { defineConfig } from 'vite';
3
3
  import tailwindcss from '@tailwindcss/vite';
4
4
  import { resolve } from 'path';
5
5
 
6
+ const frontendPort = parseInt(process.env.PORT_FRONTEND || '9141');
7
+ const backendPort = parseInt(process.env.PORT_BACKEND || '9151');
8
+
6
9
  export default defineConfig({
7
10
  plugins: [tailwindcss(), svelte()],
8
11
  publicDir: 'static',
12
+ server: {
13
+ port: frontendPort,
14
+ strictPort: false,
15
+ proxy: {
16
+ '/api': {
17
+ target: `http://localhost:${backendPort}`,
18
+ changeOrigin: true,
19
+ },
20
+ '/ws': {
21
+ target: `ws://localhost:${backendPort}`,
22
+ ws: true,
23
+ },
24
+ },
25
+ },
9
26
  build: {
10
27
  outDir: 'dist',
11
28
  emptyOutDir: true,
@@ -1,295 +0,0 @@
1
- /**
2
- * Vite Dev Server Integration
3
- *
4
- * Embeds Vite as middleware inside the Elysia/Bun server.
5
- * Uses Vite's direct APIs (transformIndexHtml, transformRequest) for speed,
6
- * with Node.js compat middleware adapter only for Vite internals (HMR, pre-bundled deps).
7
- */
8
-
9
- import { createServer as createViteServer, type ViteDevServer, type Connect } from 'vite';
10
- import { IncomingMessage, ServerResponse } from 'node:http';
11
- import { resolve } from 'node:path';
12
- import { statSync } from 'node:fs';
13
-
14
- let vite: ViteDevServer | null = null;
15
-
16
- const PUBLIC_DIR = resolve(process.cwd(), 'static');
17
- const INDEX_PATH = resolve(process.cwd(), 'index.html');
18
-
19
- // Safety timeout for the Node.js compat middleware adapter (ms).
20
- // Prevents hanging if Vite middleware never calls res.end() or next().
21
- const MIDDLEWARE_TIMEOUT = 10000;
22
-
23
- // ============================================================================
24
- // Lifecycle
25
- // ============================================================================
26
-
27
- export async function initViteDev(): Promise<ViteDevServer> {
28
- vite = await createViteServer({
29
- configFile: './vite.config.ts',
30
- server: { middlewareMode: true },
31
- appType: 'spa'
32
- });
33
-
34
- return vite;
35
- }
36
-
37
- export function getViteDev(): ViteDevServer | null {
38
- return vite;
39
- }
40
-
41
- export async function closeViteDev(): Promise<void> {
42
- if (vite) {
43
- await vite.close();
44
- vite = null;
45
- }
46
- }
47
-
48
- // ============================================================================
49
- // Main Request Handler
50
- // ============================================================================
51
-
52
- export async function handleDevRequest(viteServer: ViteDevServer, request: Request): Promise<Response> {
53
- const url = new URL(request.url);
54
- const pathname = url.pathname;
55
-
56
- // 1. Static public files
57
- const publicFile = servePublicFile(pathname);
58
- if (publicFile) return publicFile;
59
-
60
- // 2. HTML / SPA routes
61
- if (isHtmlRequest(pathname)) {
62
- return serveHtml(viteServer, pathname);
63
- }
64
-
65
- // 3. Module requests (skip Vite internals like /__vite_hmr)
66
- if (!pathname.startsWith('/__')) {
67
- const moduleResponse = await serveModule(viteServer, pathname + url.search, request);
68
- if (moduleResponse) return moduleResponse;
69
- }
70
-
71
- // 4. Fallback: Vite connect middleware (for HMR, pre-bundled deps, etc.)
72
- const middlewareResponse = await pipeViteMiddleware(viteServer.middlewares, request);
73
- if (middlewareResponse) return middlewareResponse;
74
-
75
- return new Response('Not Found', { status: 404 });
76
- }
77
-
78
- // ============================================================================
79
- // Static Public Files
80
- // ============================================================================
81
-
82
- function servePublicFile(pathname: string): Response | null {
83
- if (pathname === '/' || pathname.includes('..')) return null;
84
-
85
- const filePath = resolve(PUBLIC_DIR, pathname.slice(1));
86
- if (!filePath.startsWith(PUBLIC_DIR)) return null;
87
-
88
- // Use statSync to verify the path is a regular file, not a directory.
89
- // Bun.file().exists() returns inconsistent results for directories across
90
- // platforms (Linux/macOS/Windows) and Bun versions, which can cause
91
- // Response(Bun.file(directory)) to hang indefinitely.
92
- try {
93
- if (!statSync(filePath).isFile()) return null;
94
- } catch {
95
- return null;
96
- }
97
-
98
- const file = Bun.file(filePath);
99
- return new Response(file, {
100
- headers: {
101
- 'Content-Type': file.type || 'application/octet-stream',
102
- 'Cache-Control': 'public, max-age=3600'
103
- }
104
- });
105
- }
106
-
107
- // ============================================================================
108
- // HTML / SPA
109
- // ============================================================================
110
-
111
- function isHtmlRequest(pathname: string): boolean {
112
- if (pathname === '/') return true;
113
- if (pathname.startsWith('/@') || pathname.startsWith('/__')) return false;
114
- if (pathname.startsWith('/node_modules/')) return false;
115
- const lastSegment = pathname.split('/').pop() || '';
116
- return !lastSegment.includes('.');
117
- }
118
-
119
- async function serveHtml(viteServer: ViteDevServer, pathname: string): Promise<Response> {
120
- const rawHtml = await Bun.file(INDEX_PATH).text();
121
- const html = await viteServer.transformIndexHtml(pathname, rawHtml);
122
- return new Response(html, {
123
- headers: { 'Content-Type': 'text/html; charset=utf-8' }
124
- });
125
- }
126
-
127
- // ============================================================================
128
- // Module Requests
129
- // ============================================================================
130
-
131
- function getModuleContentType(url: string): string {
132
- const pathname = url.split('?')[0];
133
- // Raw CSS only when explicitly requested with ?direct (e.g. <link> tags).
134
- // CSS imported via JS is transformed to a JS module by Vite (for HMR).
135
- if (pathname.endsWith('.css') && url.includes('direct')) {
136
- return 'text/css';
137
- }
138
- return 'application/javascript';
139
- }
140
-
141
- async function serveModule(viteServer: ViteDevServer, url: string, request: Request): Promise<Response | null> {
142
- try {
143
- const result = await viteServer.transformRequest(url);
144
- if (!result) return null;
145
-
146
- if (result.etag) {
147
- const ifNoneMatch = request.headers.get('if-none-match');
148
- if (ifNoneMatch === result.etag) {
149
- return new Response(null, { status: 304 });
150
- }
151
- }
152
-
153
- const headers: Record<string, string> = {
154
- 'Content-Type': getModuleContentType(url),
155
- };
156
- if (result.etag) {
157
- headers['ETag'] = result.etag;
158
- }
159
-
160
- return new Response(result.code, { headers });
161
- } catch {
162
- return null;
163
- }
164
- }
165
-
166
- // ============================================================================
167
- // Vite Connect Middleware Adapter
168
- // ============================================================================
169
-
170
- const MOCK_SOCKET = {
171
- remoteAddress: '127.0.0.1',
172
- remotePort: 0,
173
- remoteFamily: 'IPv4',
174
- encrypted: false,
175
- writable: true,
176
- readable: true,
177
- destroy() {},
178
- end() {},
179
- write() { return true; },
180
- on() { return this; },
181
- once() { return this; },
182
- off() { return this; },
183
- emit() { return false; },
184
- addListener() { return this; },
185
- removeListener() { return this; },
186
- setTimeout() { return this; },
187
- setNoDelay() { return this; },
188
- setKeepAlive() { return this; },
189
- ref() { return this; },
190
- unref() { return this; },
191
- address() { return { address: '127.0.0.1', family: 'IPv4', port: 0 }; }
192
- };
193
-
194
- /**
195
- * Bridges Web API Request → Node.js IncomingMessage/ServerResponse → Web API Response.
196
- * Required because Vite's connect middleware uses the Node.js HTTP API.
197
- */
198
- function pipeViteMiddleware(
199
- middleware: Connect.Server,
200
- request: Request
201
- ): Promise<Response | null> {
202
- return new Promise((resolve) => {
203
- const url = new URL(request.url);
204
-
205
- const req = new IncomingMessage(MOCK_SOCKET as any);
206
- req.method = request.method;
207
- req.url = url.pathname + url.search;
208
- req.headers = request.headers.toJSON();
209
- req.push(null);
210
-
211
- const res = new ServerResponse(req);
212
- let ended = false;
213
- const chunks: Uint8Array[] = [];
214
-
215
- const safetyTimer = setTimeout(() => {
216
- if (!ended) {
217
- ended = true;
218
- resolve(null);
219
- }
220
- }, MIDDLEWARE_TIMEOUT);
221
-
222
- function finalize() {
223
- if (ended) return;
224
- ended = true;
225
- clearTimeout(safetyTimer);
226
-
227
- const nodeHeaders = res.getHeaders();
228
- const h = new Headers();
229
- for (const key in nodeHeaders) {
230
- // Skip hop-by-hop headers — Bun sets correct Content-Length/Transfer-Encoding
231
- // based on the actual body. Forwarding stale values from ServerResponse
232
- // causes browser to wait for data that never arrives.
233
- const lower = key.toLowerCase();
234
- if (lower === 'transfer-encoding' || lower === 'content-length' ||
235
- lower === 'connection' || lower === 'keep-alive') continue;
236
-
237
- const v = nodeHeaders[key];
238
- if (v === undefined) continue;
239
- if (Array.isArray(v)) {
240
- for (let i = 0; i < v.length; i++) h.append(key, v[i]);
241
- } else {
242
- h.set(key, String(v));
243
- }
244
- }
245
-
246
- const body = chunks.length === 0
247
- ? null
248
- : Buffer.concat(chunks);
249
-
250
- resolve(new Response(body, { status: res.statusCode, headers: h }));
251
- }
252
-
253
- res.write = function (chunk: any, encodingOrCb?: any, cb?: any): boolean {
254
- if (chunk != null) {
255
- chunks.push(
256
- typeof chunk === 'string'
257
- ? Buffer.from(chunk, typeof encodingOrCb === 'string' ? encodingOrCb as BufferEncoding : 'utf-8')
258
- : chunk instanceof Uint8Array ? chunk : Buffer.from(chunk)
259
- );
260
- }
261
- const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb;
262
- if (typeof callback === 'function') callback();
263
- return true;
264
- };
265
-
266
- res.end = function (data?: any, encodingOrCb?: any, cb?: any): ServerResponse {
267
- if (ended) return res;
268
-
269
- if (typeof data === 'function') {
270
- data();
271
- } else {
272
- if (data != null) {
273
- chunks.push(
274
- typeof data === 'string'
275
- ? Buffer.from(data, typeof encodingOrCb === 'string' ? encodingOrCb as BufferEncoding : 'utf-8')
276
- : data instanceof Uint8Array ? data : Buffer.from(data)
277
- );
278
- }
279
- const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb;
280
- if (typeof callback === 'function') callback();
281
- }
282
-
283
- finalize();
284
- return res;
285
- } as any;
286
-
287
- middleware(req as any, res as any, () => {
288
- if (!ended) {
289
- ended = true;
290
- clearTimeout(safetyTimer);
291
- resolve(null);
292
- }
293
- });
294
- });
295
- }