@vibecompany/247-cli 0.1.0 → 0.2.3

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.
Files changed (74) hide show
  1. package/agent/dist/config.d.ts +29 -0
  2. package/agent/dist/config.d.ts.map +1 -0
  3. package/agent/dist/config.js +56 -0
  4. package/agent/dist/config.js.map +1 -0
  5. package/agent/dist/db/environments.d.ts +65 -0
  6. package/agent/dist/db/environments.d.ts.map +1 -0
  7. package/agent/dist/db/environments.js +243 -0
  8. package/agent/dist/db/environments.js.map +1 -0
  9. package/agent/dist/db/history.d.ts +37 -0
  10. package/agent/dist/db/history.d.ts.map +1 -0
  11. package/agent/dist/db/history.js +98 -0
  12. package/agent/dist/db/history.js.map +1 -0
  13. package/agent/dist/db/index.d.ts +37 -0
  14. package/agent/dist/db/index.d.ts.map +1 -0
  15. package/agent/dist/db/index.js +225 -0
  16. package/agent/dist/db/index.js.map +1 -0
  17. package/agent/dist/db/schema.d.ts +70 -0
  18. package/agent/dist/db/schema.d.ts.map +1 -0
  19. package/agent/dist/db/schema.js +79 -0
  20. package/agent/dist/db/schema.js.map +1 -0
  21. package/agent/dist/db/sessions.d.ts +75 -0
  22. package/agent/dist/db/sessions.d.ts.map +1 -0
  23. package/agent/dist/db/sessions.js +244 -0
  24. package/agent/dist/db/sessions.js.map +1 -0
  25. package/agent/dist/editor.d.ts +18 -0
  26. package/agent/dist/editor.d.ts.map +1 -0
  27. package/agent/dist/editor.js +220 -0
  28. package/agent/dist/editor.js.map +1 -0
  29. package/agent/dist/environments.d.ts +59 -0
  30. package/agent/dist/environments.d.ts.map +1 -0
  31. package/agent/dist/environments.js +229 -0
  32. package/agent/dist/environments.js.map +1 -0
  33. package/agent/dist/git.d.ts +39 -0
  34. package/agent/dist/git.d.ts.map +1 -0
  35. package/agent/dist/git.js +436 -0
  36. package/agent/dist/git.js.map +1 -0
  37. package/agent/dist/index.d.ts +2 -0
  38. package/agent/dist/index.d.ts.map +1 -0
  39. package/agent/dist/index.js +17 -0
  40. package/agent/dist/index.js.map +1 -0
  41. package/agent/dist/server.d.ts +2 -0
  42. package/agent/dist/server.d.ts.map +1 -0
  43. package/agent/dist/server.js +1062 -0
  44. package/agent/dist/server.js.map +1 -0
  45. package/agent/dist/terminal.d.ts +14 -0
  46. package/agent/dist/terminal.d.ts.map +1 -0
  47. package/agent/dist/terminal.js +115 -0
  48. package/agent/dist/terminal.js.map +1 -0
  49. package/dist/commands/init.d.ts.map +1 -1
  50. package/dist/commands/init.js +25 -14
  51. package/dist/commands/init.js.map +1 -1
  52. package/dist/commands/profile.d.ts +3 -0
  53. package/dist/commands/profile.d.ts.map +1 -0
  54. package/dist/commands/profile.js +156 -0
  55. package/dist/commands/profile.js.map +1 -0
  56. package/dist/commands/start.d.ts.map +1 -1
  57. package/dist/commands/start.js +21 -10
  58. package/dist/commands/start.js.map +1 -1
  59. package/dist/index.d.ts +3 -1
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +6 -1
  62. package/dist/index.js.map +1 -1
  63. package/dist/lib/config.d.ts +30 -5
  64. package/dist/lib/config.d.ts.map +1 -1
  65. package/dist/lib/config.js +85 -12
  66. package/dist/lib/config.js.map +1 -1
  67. package/dist/lib/process.d.ts +2 -1
  68. package/dist/lib/process.d.ts.map +1 -1
  69. package/dist/lib/process.js +7 -3
  70. package/dist/lib/process.js.map +1 -1
  71. package/hooks/.claude-plugin/plugin.json +5 -0
  72. package/hooks/hooks/hooks.json +61 -0
  73. package/hooks/scripts/notify-status.sh +89 -0
  74. package/package.json +22 -5
@@ -0,0 +1,1062 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import { WebSocketServer, WebSocket } from 'ws';
4
+ import { createServer as createHttpServer } from 'http';
5
+ import httpProxy from 'http-proxy';
6
+ import { execSync } from 'child_process';
7
+ import { createTerminal } from './terminal.js';
8
+ import { initEditor, getOrStartEditor, stopEditor, getEditorStatus, getAllEditors, updateEditorActivity, shutdownAllEditors, } from './editor.js';
9
+ // Database imports
10
+ import { initDatabase, closeDatabase, migrateEnvironmentsFromJson, RETENTION_CONFIG, } from './db/index.js';
11
+ import { getEnvironmentsMetadata, getEnvironmentMetadata, getEnvironment, createEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentVariables, setSessionEnvironment, getSessionEnvironment, clearSessionEnvironment, ensureDefaultEnvironment, } from './db/environments.js';
12
+ import * as sessionsDb from './db/sessions.js';
13
+ import * as historyDb from './db/history.js';
14
+ import { cloneRepo, extractProjectName, listFiles, getFileContent, openFileInEditor, getChangesSummary, } from './git.js';
15
+ import { config } from './config.js';
16
+ // Store by tmux session name - single source of truth for status
17
+ const tmuxSessionStatus = new Map();
18
+ // Track active WebSocket connections per session
19
+ const activeConnections = new Map();
20
+ // Track WebSocket subscribers for status updates (real-time push)
21
+ const statusSubscribers = new Set();
22
+ // Broadcast status update to all subscribers
23
+ function broadcastStatusUpdate(session) {
24
+ if (statusSubscribers.size === 0)
25
+ return;
26
+ const message = { type: 'status-update', session };
27
+ const messageStr = JSON.stringify(message);
28
+ for (const ws of statusSubscribers) {
29
+ if (ws.readyState === WebSocket.OPEN) {
30
+ ws.send(messageStr);
31
+ }
32
+ }
33
+ console.log(`[Status WS] Broadcast status update for ${session.name}: ${session.status} to ${statusSubscribers.size} subscribers`);
34
+ }
35
+ // Broadcast session removed to all subscribers
36
+ function broadcastSessionRemoved(sessionName) {
37
+ if (statusSubscribers.size === 0)
38
+ return;
39
+ const message = { type: 'session-removed', sessionName };
40
+ const messageStr = JSON.stringify(message);
41
+ for (const ws of statusSubscribers) {
42
+ if (ws.readyState === WebSocket.OPEN) {
43
+ ws.send(messageStr);
44
+ }
45
+ }
46
+ console.log(`[Status WS] Broadcast session removed: ${sessionName}`);
47
+ }
48
+ // Broadcast session archived to all subscribers
49
+ function broadcastSessionArchived(sessionName, session) {
50
+ if (statusSubscribers.size === 0)
51
+ return;
52
+ const message = { type: 'session-archived', sessionName, session };
53
+ const messageStr = JSON.stringify(message);
54
+ for (const ws of statusSubscribers) {
55
+ if (ws.readyState === WebSocket.OPEN) {
56
+ ws.send(messageStr);
57
+ }
58
+ }
59
+ console.log(`[Status WS] Broadcast session archived: ${sessionName}`);
60
+ }
61
+ // Generate human-readable session names with project prefix
62
+ function generateSessionName(project) {
63
+ const adjectives = ['brave', 'swift', 'calm', 'bold', 'wise', 'keen', 'fair', 'wild', 'bright', 'cool'];
64
+ const nouns = ['lion', 'hawk', 'wolf', 'bear', 'fox', 'owl', 'deer', 'lynx', 'eagle', 'tiger'];
65
+ const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
66
+ const noun = nouns[Math.floor(Math.random() * nouns.length)];
67
+ const num = Math.floor(Math.random() * 100);
68
+ return `${project}--${adj}-${noun}-${num}`;
69
+ }
70
+ // Clean up stale status entries (called periodically)
71
+ function cleanupStatusMaps() {
72
+ const now = Date.now();
73
+ const STALE_THRESHOLD = RETENTION_CONFIG.sessionMaxAge;
74
+ let cleanedTmux = 0;
75
+ // Get active tmux sessions
76
+ let activeSessions = new Set();
77
+ try {
78
+ const output = execSync('tmux list-sessions -F "#{session_name}" 2>/dev/null', { encoding: 'utf-8' });
79
+ activeSessions = new Set(output.trim().split('\n').filter(Boolean));
80
+ }
81
+ catch {
82
+ // No tmux sessions exist
83
+ }
84
+ // Clean tmuxSessionStatus - remove if session doesn't exist OR is stale
85
+ for (const [sessionName, status] of tmuxSessionStatus) {
86
+ const sessionExists = activeSessions.has(sessionName);
87
+ const isStale = (now - status.lastActivity) > STALE_THRESHOLD;
88
+ if (!sessionExists || isStale) {
89
+ tmuxSessionStatus.delete(sessionName);
90
+ cleanedTmux++;
91
+ }
92
+ }
93
+ if (cleanedTmux > 0) {
94
+ console.log(`[Status Cleanup] Removed ${cleanedTmux} stale status entries from memory`);
95
+ }
96
+ // Also cleanup SQLite database (pass archivedMaxAge for archived session cleanup)
97
+ const dbSessionsCleaned = sessionsDb.cleanupStaleSessions(STALE_THRESHOLD, RETENTION_CONFIG.archivedMaxAge);
98
+ const dbHistoryCleaned = historyDb.cleanupOldHistory(RETENTION_CONFIG.historyMaxAge);
99
+ if (dbSessionsCleaned > 0 || dbHistoryCleaned > 0) {
100
+ console.log(`[DB Cleanup] Sessions: ${dbSessionsCleaned}, History: ${dbHistoryCleaned}`);
101
+ }
102
+ }
103
+ // Run cleanup every hour
104
+ setInterval(cleanupStatusMaps, 60 * 60 * 1000);
105
+ export async function createServer() {
106
+ const app = express();
107
+ app.use(cors());
108
+ app.use(express.json());
109
+ const server = createHttpServer(app);
110
+ const wss = new WebSocketServer({ noServer: true });
111
+ // Initialize editor manager (cleans up orphan code-server processes)
112
+ const typedConfig = config;
113
+ await initEditor(typedConfig.editor, config.projects.basePath);
114
+ // Initialize SQLite database
115
+ const db = initDatabase();
116
+ // Migrate environments from JSON if this is a fresh database
117
+ migrateEnvironmentsFromJson(db);
118
+ // Ensure default environment exists
119
+ ensureDefaultEnvironment();
120
+ // Reconcile sessions with active tmux sessions
121
+ let activeTmuxSessions = new Set();
122
+ try {
123
+ const output = execSync('tmux list-sessions -F "#{session_name}" 2>/dev/null', { encoding: 'utf-8' });
124
+ activeTmuxSessions = new Set(output.trim().split('\n').filter(Boolean));
125
+ }
126
+ catch {
127
+ // No tmux sessions exist
128
+ }
129
+ sessionsDb.reconcileWithTmux(activeTmuxSessions);
130
+ // Populate in-memory Map from database (for backward compatibility during transition)
131
+ const dbSessions = sessionsDb.getAllSessions();
132
+ for (const session of dbSessions) {
133
+ tmuxSessionStatus.set(session.name, sessionsDb.toHookStatus(session));
134
+ }
135
+ console.log(`[DB] Loaded ${dbSessions.length} sessions from database`);
136
+ // Create proxy for code-server
137
+ const editorProxy = httpProxy.createProxyServer({
138
+ ws: true,
139
+ changeOrigin: true,
140
+ });
141
+ editorProxy.on('error', (err, _req, res) => {
142
+ console.error('[Editor Proxy] HTTP Error:', err.message);
143
+ if (res && 'writeHead' in res) {
144
+ res.writeHead(502, { 'Content-Type': 'application/json' });
145
+ res.end(JSON.stringify({ error: 'Editor proxy error', message: err.message }));
146
+ }
147
+ });
148
+ // WebSocket proxy events for debugging
149
+ editorProxy.on('proxyReqWs', (proxyReq, _req, _socket) => {
150
+ console.log('[Editor Proxy] WS request to:', proxyReq.path);
151
+ });
152
+ editorProxy.on('open', (_proxySocket) => {
153
+ console.log('[Editor Proxy] WS connection opened');
154
+ });
155
+ editorProxy.on('close', (_res, _socket, _head) => {
156
+ console.log('[Editor Proxy] WS connection closed');
157
+ });
158
+ // WebSocket terminal handler
159
+ wss.on('connection', async (ws, req) => {
160
+ const url = new URL(req.url, `http://${req.headers.host}`);
161
+ const project = url.searchParams.get('project');
162
+ const urlSessionName = url.searchParams.get('session');
163
+ const environmentId = url.searchParams.get('environment'); // Environment to use
164
+ const sessionName = urlSessionName || generateSessionName(project || 'unknown');
165
+ // Validate project - if whitelist is empty, allow any project
166
+ const whitelist = config.projects.whitelist;
167
+ const hasWhitelist = whitelist && whitelist.length > 0;
168
+ const isAllowed = hasWhitelist ? whitelist.includes(project) : true;
169
+ if (!project || !isAllowed) {
170
+ ws.close(1008, 'Project not allowed');
171
+ return;
172
+ }
173
+ const projectPath = `${config.projects.basePath}/${project}`.replace('~', process.env.HOME);
174
+ console.log(`New terminal connection for project: ${project}`);
175
+ console.log(`Project path: ${projectPath}`);
176
+ if (environmentId) {
177
+ const env = getEnvironment(environmentId);
178
+ console.log(`Using environment: ${env?.name || environmentId}`);
179
+ }
180
+ // Verify path exists
181
+ const fs = await import('fs');
182
+ if (!fs.existsSync(projectPath)) {
183
+ console.error(`Path does not exist: ${projectPath}`);
184
+ ws.close(1008, 'Project path not found');
185
+ return;
186
+ }
187
+ // Get environment variables for this session
188
+ const envVars = getEnvironmentVariables(environmentId || undefined);
189
+ let terminal;
190
+ try {
191
+ terminal = createTerminal(projectPath, sessionName, envVars);
192
+ // Track which environment this session uses
193
+ if (environmentId) {
194
+ setSessionEnvironment(sessionName, environmentId);
195
+ }
196
+ }
197
+ catch (err) {
198
+ console.error('Failed to create terminal:', err);
199
+ // Clean up any partial state
200
+ clearSessionEnvironment(sessionName);
201
+ ws.close(1011, 'Failed to create terminal');
202
+ return;
203
+ }
204
+ // Track this connection
205
+ if (!activeConnections.has(sessionName)) {
206
+ activeConnections.set(sessionName, new Set());
207
+ }
208
+ activeConnections.get(sessionName).add(ws);
209
+ console.log(`[Connections] Added connection to '${sessionName}' (total: ${activeConnections.get(sessionName).size})`);
210
+ // If reconnecting to an existing session, send the scrollback history
211
+ if (terminal.isExistingSession()) {
212
+ console.log(`Reconnecting to existing session '${sessionName}', sending history...`);
213
+ terminal.captureHistory(10000)
214
+ .then((history) => {
215
+ if (history && ws.readyState === WebSocket.OPEN) {
216
+ ws.send(JSON.stringify({
217
+ type: 'history',
218
+ data: history,
219
+ lines: history.split('\n').length
220
+ }));
221
+ }
222
+ })
223
+ .catch((err) => {
224
+ console.error(`[Terminal] Failed to capture initial history for '${sessionName}':`, err);
225
+ });
226
+ }
227
+ else {
228
+ // New session created - register in DB and broadcast to subscribers
229
+ const now = Date.now();
230
+ const sessionProject = project;
231
+ // Store in database (may be undefined in test environments)
232
+ let createdAt = now;
233
+ try {
234
+ const dbSession = sessionsDb.upsertSession(sessionName, {
235
+ project: sessionProject,
236
+ status: 'init',
237
+ attentionReason: undefined,
238
+ lastEvent: 'SessionCreated',
239
+ lastActivity: now,
240
+ lastStatusChange: now,
241
+ environmentId: environmentId || undefined,
242
+ });
243
+ if (dbSession?.created_at) {
244
+ createdAt = dbSession.created_at;
245
+ }
246
+ }
247
+ catch (err) {
248
+ console.error(`[Session] Failed to persist session '${sessionName}':`, err);
249
+ }
250
+ // Update in-memory map
251
+ tmuxSessionStatus.set(sessionName, {
252
+ status: 'init',
253
+ lastEvent: 'SessionCreated',
254
+ lastActivity: now,
255
+ lastStatusChange: now,
256
+ project: sessionProject,
257
+ });
258
+ // Get environment info for the broadcast
259
+ const envMeta = environmentId ? getEnvironmentMetadata(environmentId) : undefined;
260
+ // Broadcast new session to all subscribers
261
+ broadcastStatusUpdate({
262
+ name: sessionName,
263
+ project: sessionProject,
264
+ status: 'init',
265
+ statusSource: 'hook',
266
+ lastEvent: 'SessionCreated',
267
+ lastStatusChange: now,
268
+ createdAt,
269
+ lastActivity: undefined,
270
+ environmentId: environmentId || undefined,
271
+ environment: envMeta ? {
272
+ id: envMeta.id,
273
+ name: envMeta.name,
274
+ provider: envMeta.provider,
275
+ icon: envMeta.icon,
276
+ isDefault: envMeta.isDefault,
277
+ } : undefined,
278
+ });
279
+ console.log(`[Session] New session '${sessionName}' created and broadcast to ${statusSubscribers.size} subscribers`);
280
+ }
281
+ // Forward terminal output to WebSocket - store handler for cleanup
282
+ const dataHandler = (data) => {
283
+ if (ws.readyState === WebSocket.OPEN) {
284
+ ws.send(data);
285
+ }
286
+ };
287
+ terminal.onData(dataHandler);
288
+ const exitHandler = ({ exitCode }) => {
289
+ console.log(`Terminal exited with code ${exitCode}`);
290
+ if (ws.readyState === WebSocket.OPEN) {
291
+ ws.close(1000, 'Terminal closed');
292
+ }
293
+ };
294
+ terminal.onExit(exitHandler);
295
+ // Handle incoming messages
296
+ ws.on('message', (data) => {
297
+ try {
298
+ const msg = JSON.parse(data.toString());
299
+ switch (msg.type) {
300
+ case 'input':
301
+ terminal.write(msg.data);
302
+ // Update status to 'working' when user sends input
303
+ // This helps track when the user provides input that might resume Claude
304
+ if (msg.data.includes('\r') || msg.data.includes('\n')) {
305
+ const existing = tmuxSessionStatus.get(sessionName);
306
+ const currentStatus = existing?.status;
307
+ // Only set to working if Claude was waiting for attention (input)
308
+ // Don't overwrite: working (already active), idle (no session)
309
+ if (currentStatus === 'needs_attention' && existing?.attentionReason === 'input') {
310
+ const now = Date.now();
311
+ tmuxSessionStatus.set(sessionName, {
312
+ status: 'working',
313
+ lastEvent: 'UserInput',
314
+ lastActivity: now,
315
+ lastStatusChange: now,
316
+ project,
317
+ });
318
+ console.log(`[Status] Updated '${sessionName}' to 'working' (user input)`);
319
+ }
320
+ }
321
+ break;
322
+ case 'resize':
323
+ terminal.resize(msg.cols, msg.rows);
324
+ break;
325
+ case 'start-claude':
326
+ terminal.write('claude\r');
327
+ break;
328
+ case 'ping':
329
+ ws.send(JSON.stringify({ type: 'pong' }));
330
+ break;
331
+ case 'request-history':
332
+ terminal.captureHistory(msg.lines || 10000)
333
+ .then((history) => {
334
+ if (ws.readyState === WebSocket.OPEN) {
335
+ ws.send(JSON.stringify({
336
+ type: 'history',
337
+ data: history,
338
+ lines: history.split('\n').length
339
+ }));
340
+ }
341
+ })
342
+ .catch((err) => {
343
+ console.error(`[Terminal] Failed to capture history for '${sessionName}':`, err);
344
+ });
345
+ break;
346
+ }
347
+ }
348
+ catch (err) {
349
+ console.error('Failed to parse message:', err);
350
+ }
351
+ });
352
+ ws.on('close', () => {
353
+ console.log(`Client disconnected, tmux session '${sessionName}' preserved`);
354
+ // Remove terminal event listeners to prevent memory leaks
355
+ try {
356
+ terminal.removeAllListeners?.('data');
357
+ terminal.removeAllListeners?.('exit');
358
+ }
359
+ catch {
360
+ // Terminal may not support removeAllListeners, ignore
361
+ }
362
+ // Detach from tmux instead of killing - session stays alive
363
+ terminal.detach();
364
+ // Remove from active connections
365
+ const connections = activeConnections.get(sessionName);
366
+ if (connections) {
367
+ connections.delete(ws);
368
+ console.log(`[Connections] Removed connection from '${sessionName}' (remaining: ${connections.size})`);
369
+ if (connections.size === 0) {
370
+ activeConnections.delete(sessionName);
371
+ }
372
+ }
373
+ });
374
+ ws.on('error', (err) => {
375
+ console.error('WebSocket error:', err);
376
+ });
377
+ });
378
+ // REST API endpoints
379
+ app.get('/api/projects', (_req, res) => {
380
+ res.json(config.projects.whitelist);
381
+ });
382
+ // Dynamic folder listing - scans basePath for directories
383
+ app.get('/api/folders', async (_req, res) => {
384
+ try {
385
+ const fs = await import('fs/promises');
386
+ const basePath = config.projects.basePath.replace('~', process.env.HOME);
387
+ const entries = await fs.readdir(basePath, { withFileTypes: true });
388
+ const folders = entries
389
+ .filter(entry => entry.isDirectory() && !entry.name.startsWith('.'))
390
+ .map(entry => entry.name)
391
+ .sort();
392
+ res.json(folders);
393
+ }
394
+ catch (err) {
395
+ console.error('Failed to list folders:', err);
396
+ res.status(500).json({ error: 'Failed to list folders' });
397
+ }
398
+ });
399
+ // Clone a git repository
400
+ app.post('/api/clone', async (req, res) => {
401
+ const { repoUrl, projectName } = req.body;
402
+ if (!repoUrl) {
403
+ return res.status(400).json({ error: 'Missing repoUrl' });
404
+ }
405
+ try {
406
+ const result = await cloneRepo(repoUrl, config.projects.basePath, projectName);
407
+ if (result.success) {
408
+ res.json({
409
+ success: true,
410
+ projectName: result.projectName,
411
+ path: result.path,
412
+ });
413
+ }
414
+ else {
415
+ res.status(400).json({
416
+ success: false,
417
+ error: result.error,
418
+ projectName: result.projectName,
419
+ });
420
+ }
421
+ }
422
+ catch (err) {
423
+ console.error('Clone error:', err);
424
+ res.status(500).json({ error: 'Internal server error during clone' });
425
+ }
426
+ });
427
+ // Preview what project name would be extracted from a URL
428
+ app.get('/api/clone/preview', (req, res) => {
429
+ const url = req.query.url;
430
+ if (!url) {
431
+ return res.status(400).json({ error: 'Missing url parameter' });
432
+ }
433
+ const projectName = extractProjectName(url);
434
+ res.json({ projectName });
435
+ });
436
+ // ========== Environment API Endpoints ==========
437
+ // List all environments (metadata only - no secret values sent to dashboard)
438
+ app.get('/api/environments', (_req, res) => {
439
+ res.json(getEnvironmentsMetadata());
440
+ });
441
+ // Get single environment metadata
442
+ app.get('/api/environments/:id', (req, res) => {
443
+ const metadata = getEnvironmentMetadata(req.params.id);
444
+ if (!metadata) {
445
+ return res.status(404).json({ error: 'Environment not found' });
446
+ }
447
+ res.json(metadata);
448
+ });
449
+ // Get full environment data (including secret values) - for local editing only
450
+ app.get('/api/environments/:id/full', (req, res) => {
451
+ const env = getEnvironment(req.params.id);
452
+ if (!env) {
453
+ return res.status(404).json({ error: 'Environment not found' });
454
+ }
455
+ res.json(env);
456
+ });
457
+ // Create environment
458
+ app.post('/api/environments', (req, res) => {
459
+ const { name, provider, icon, isDefault, variables } = req.body;
460
+ if (!name || !provider) {
461
+ return res.status(400).json({ error: 'Missing required fields: name, provider' });
462
+ }
463
+ try {
464
+ const env = createEnvironment({ name, provider, icon, isDefault, variables: variables ?? {} });
465
+ // Return metadata only (not the actual secrets)
466
+ res.status(201).json({
467
+ id: env.id,
468
+ name: env.name,
469
+ provider: env.provider,
470
+ icon: env.icon,
471
+ isDefault: env.isDefault,
472
+ variableKeys: Object.keys(env.variables),
473
+ createdAt: env.createdAt,
474
+ updatedAt: env.updatedAt,
475
+ });
476
+ }
477
+ catch (err) {
478
+ console.error('[Environments] Create error:', err);
479
+ res.status(500).json({ error: 'Failed to create environment' });
480
+ }
481
+ });
482
+ // Update environment
483
+ app.put('/api/environments/:id', (req, res) => {
484
+ const { name, provider, icon, isDefault, variables } = req.body;
485
+ const updated = updateEnvironment(req.params.id, { name, provider, icon, isDefault, variables });
486
+ if (!updated) {
487
+ return res.status(404).json({ error: 'Environment not found' });
488
+ }
489
+ // Return metadata only
490
+ res.json({
491
+ id: updated.id,
492
+ name: updated.name,
493
+ provider: updated.provider,
494
+ icon: updated.icon,
495
+ isDefault: updated.isDefault,
496
+ variableKeys: Object.keys(updated.variables),
497
+ createdAt: updated.createdAt,
498
+ updatedAt: updated.updatedAt,
499
+ });
500
+ });
501
+ // Delete environment
502
+ app.delete('/api/environments/:id', (req, res) => {
503
+ const deleted = deleteEnvironment(req.params.id);
504
+ if (!deleted) {
505
+ return res.status(404).json({ error: 'Environment not found' });
506
+ }
507
+ res.json({ success: true });
508
+ });
509
+ // Receive status updates from Claude Code hooks
510
+ // The hook script now sends pre-mapped status, making this endpoint simpler
511
+ app.post('/api/hooks/status', (req, res) => {
512
+ const { event, status, attention_reason, session_id, tmux_session, project, timestamp } = req.body;
513
+ if (!event) {
514
+ return res.status(400).json({ error: 'Missing event' });
515
+ }
516
+ // Validate status
517
+ const validStatuses = ['init', 'working', 'needs_attention', 'idle'];
518
+ const receivedStatus = validStatuses.includes(status) ? status : 'working';
519
+ // Validate attention_reason if provided
520
+ const validReasons = ['permission', 'input', 'plan_approval', 'task_complete'];
521
+ const receivedReason = attention_reason && validReasons.includes(attention_reason) ? attention_reason : undefined;
522
+ const now = Date.now();
523
+ // Store by tmux session name (REQUIRED for per-session status)
524
+ if (tmux_session) {
525
+ const existing = tmuxSessionStatus.get(tmux_session);
526
+ const statusChanged = !existing || existing.status !== receivedStatus;
527
+ const hookData = {
528
+ status: receivedStatus,
529
+ attentionReason: receivedReason,
530
+ lastEvent: event,
531
+ lastActivity: timestamp || now,
532
+ lastStatusChange: statusChanged ? now : existing.lastStatusChange,
533
+ project,
534
+ };
535
+ // Write to SQLite database first (persistent storage)
536
+ // The DB preserves the original createdAt from when the session was first seen
537
+ const sessionProject = tmux_session.split('--')[0] || project;
538
+ const dbSession = sessionsDb.upsertSession(tmux_session, {
539
+ project: sessionProject,
540
+ status: receivedStatus,
541
+ attentionReason: receivedReason,
542
+ lastEvent: event,
543
+ lastActivity: timestamp || now,
544
+ lastStatusChange: statusChanged ? now : existing?.lastStatusChange ?? now,
545
+ environmentId: getSessionEnvironment(tmux_session),
546
+ });
547
+ // Then update in-memory Map (for real-time performance)
548
+ tmuxSessionStatus.set(tmux_session, hookData);
549
+ // Broadcast status update to WebSocket subscribers
550
+ // Use createdAt from DB to ensure stable sorting on the frontend
551
+ const envId = getSessionEnvironment(tmux_session);
552
+ const envMeta = envId ? getEnvironmentMetadata(envId) : undefined;
553
+ broadcastStatusUpdate({
554
+ name: tmux_session,
555
+ project: sessionProject || project,
556
+ status: hookData.status,
557
+ attentionReason: hookData.attentionReason,
558
+ statusSource: 'hook',
559
+ lastEvent: hookData.lastEvent,
560
+ lastStatusChange: hookData.lastStatusChange,
561
+ createdAt: dbSession.created_at, // Use stable createdAt from DB, not current timestamp
562
+ lastActivity: undefined,
563
+ environmentId: envId,
564
+ environment: envMeta ? {
565
+ id: envMeta.id,
566
+ name: envMeta.name,
567
+ provider: envMeta.provider,
568
+ icon: envMeta.icon,
569
+ isDefault: envMeta.isDefault,
570
+ } : undefined,
571
+ });
572
+ console.log(`[Hook] ${tmux_session}: ${event} → ${receivedStatus}${receivedReason ? ` (${receivedReason})` : ''}`);
573
+ }
574
+ else {
575
+ // Warning: tmux_session is required for proper per-session status tracking
576
+ console.warn(`[Hook] WARNING: Missing tmux_session for ${event} (session_id=${session_id}, project=${project})`);
577
+ }
578
+ res.json({ received: true });
579
+ });
580
+ // Enhanced sessions endpoint with detailed info
581
+ // Combines Claude Code hooks (reliable) with tmux heuristics (instant fallback)
582
+ app.get('/api/sessions', async (_req, res) => {
583
+ const { exec } = await import('child_process');
584
+ const { promisify } = await import('util');
585
+ const execAsync = promisify(exec);
586
+ try {
587
+ // Get session list with creation time
588
+ const { stdout } = await execAsync('tmux list-sessions -F "#{session_name}|#{session_created}" 2>/dev/null');
589
+ const sessions = [];
590
+ for (const line of stdout.trim().split('\n').filter(Boolean)) {
591
+ const [name, created] = line.split('|');
592
+ // Extract project from session name (format: project--adjective-noun-number)
593
+ const [project] = name.split('--');
594
+ let status = 'init';
595
+ let attentionReason;
596
+ let statusSource = 'tmux';
597
+ let lastEvent;
598
+ let lastStatusChange;
599
+ // Use hook status if available, otherwise idle
600
+ const hookData = tmuxSessionStatus.get(name);
601
+ if (hookData) {
602
+ status = hookData.status;
603
+ attentionReason = hookData.attentionReason;
604
+ statusSource = 'hook';
605
+ lastEvent = hookData.lastEvent;
606
+ lastStatusChange = hookData.lastStatusChange;
607
+ }
608
+ // Get environment info for this session
609
+ const envId = getSessionEnvironment(name);
610
+ const envMeta = envId ? getEnvironmentMetadata(envId) : undefined;
611
+ sessions.push({
612
+ name,
613
+ project,
614
+ createdAt: parseInt(created) * 1000,
615
+ status,
616
+ attentionReason,
617
+ statusSource,
618
+ lastActivity: '',
619
+ lastEvent,
620
+ lastStatusChange,
621
+ environmentId: envId,
622
+ environment: envMeta ? {
623
+ id: envMeta.id,
624
+ name: envMeta.name,
625
+ provider: envMeta.provider,
626
+ icon: envMeta.icon,
627
+ isDefault: envMeta.isDefault,
628
+ } : undefined,
629
+ });
630
+ }
631
+ res.json(sessions);
632
+ }
633
+ catch {
634
+ res.json([]);
635
+ }
636
+ });
637
+ // Get terminal preview (last N lines from tmux pane)
638
+ app.get('/api/sessions/:sessionName/preview', async (req, res) => {
639
+ const { sessionName } = req.params;
640
+ const { exec } = await import('child_process');
641
+ const { promisify } = await import('util');
642
+ const execAsync = promisify(exec);
643
+ // Validate session name format to prevent injection
644
+ if (!/^[\w-]+$/.test(sessionName)) {
645
+ return res.status(400).json({ error: 'Invalid session name' });
646
+ }
647
+ try {
648
+ // Capture last 20 lines from the tmux pane
649
+ const { stdout } = await execAsync(`tmux capture-pane -t "${sessionName}" -p -S -20 2>/dev/null`);
650
+ // Split into lines and take last 15 non-empty lines for display
651
+ const allLines = stdout.split('\n');
652
+ const lines = allLines.slice(-16, -1).filter(line => line.trim() !== '' || allLines.indexOf(line) > allLines.length - 5);
653
+ res.json({
654
+ lines: lines.length > 0 ? lines : ['(empty terminal)'],
655
+ timestamp: Date.now()
656
+ });
657
+ }
658
+ catch {
659
+ res.status(404).json({ error: 'Session not found' });
660
+ }
661
+ });
662
+ // Kill a tmux session
663
+ app.delete('/api/sessions/:sessionName', async (req, res) => {
664
+ const { sessionName } = req.params;
665
+ const { exec } = await import('child_process');
666
+ const { promisify } = await import('util');
667
+ const execAsync = promisify(exec);
668
+ // Validate session name format to prevent injection
669
+ if (!/^[\w-]+$/.test(sessionName)) {
670
+ return res.status(400).json({ error: 'Invalid session name' });
671
+ }
672
+ try {
673
+ await execAsync(`tmux kill-session -t "${sessionName}" 2>/dev/null`);
674
+ console.log(`Killed tmux session: ${sessionName}`);
675
+ // Clean up from SQLite database
676
+ sessionsDb.deleteSession(sessionName);
677
+ sessionsDb.clearSessionEnvironmentId(sessionName);
678
+ // Clean up status tracking (in-memory) and broadcast removal
679
+ tmuxSessionStatus.delete(sessionName);
680
+ clearSessionEnvironment(sessionName); // Clean up environment tracking
681
+ broadcastSessionRemoved(sessionName);
682
+ res.json({ success: true, message: `Session ${sessionName} killed` });
683
+ }
684
+ catch {
685
+ res.status(404).json({ error: 'Session not found or already killed' });
686
+ }
687
+ });
688
+ // Archive a session (mark as done and keep in history)
689
+ app.post('/api/sessions/:sessionName/archive', async (req, res) => {
690
+ const { sessionName } = req.params;
691
+ const { exec } = await import('child_process');
692
+ const { promisify } = await import('util');
693
+ const execAsync = promisify(exec);
694
+ // Validate session name format to prevent injection
695
+ if (!/^[\w-]+$/.test(sessionName)) {
696
+ return res.status(400).json({ error: 'Invalid session name' });
697
+ }
698
+ // Archive the session in SQLite
699
+ const archivedSession = sessionsDb.archiveSession(sessionName);
700
+ if (!archivedSession) {
701
+ return res.status(404).json({ error: 'Session not found' });
702
+ }
703
+ // Kill the tmux session (terminal no longer needed)
704
+ try {
705
+ await execAsync(`tmux kill-session -t "${sessionName}" 2>/dev/null`);
706
+ console.log(`[Archive] Killed tmux session: ${sessionName}`);
707
+ }
708
+ catch {
709
+ // Session might already be dead, that's fine
710
+ console.log(`[Archive] Tmux session ${sessionName} was already gone`);
711
+ }
712
+ // Clean up in-memory status tracking
713
+ tmuxSessionStatus.delete(sessionName);
714
+ // Get environment info for the response
715
+ const envId = getSessionEnvironment(sessionName);
716
+ const envMeta = envId ? getEnvironmentMetadata(envId) : undefined;
717
+ // Broadcast session-archived event
718
+ const archivedInfo = {
719
+ name: sessionName,
720
+ project: archivedSession.project,
721
+ createdAt: archivedSession.created_at,
722
+ status: archivedSession.status,
723
+ attentionReason: archivedSession.attention_reason ?? undefined,
724
+ statusSource: 'hook',
725
+ lastEvent: archivedSession.last_event ?? undefined,
726
+ lastStatusChange: archivedSession.last_status_change,
727
+ archivedAt: archivedSession.archived_at ?? undefined,
728
+ environmentId: envId,
729
+ environment: envMeta ? {
730
+ id: envMeta.id,
731
+ name: envMeta.name,
732
+ provider: envMeta.provider,
733
+ icon: envMeta.icon,
734
+ isDefault: envMeta.isDefault,
735
+ } : undefined,
736
+ };
737
+ broadcastSessionArchived(sessionName, archivedInfo);
738
+ res.json({
739
+ success: true,
740
+ message: `Session ${sessionName} archived`,
741
+ session: archivedInfo,
742
+ });
743
+ });
744
+ // Get archived sessions
745
+ app.get('/api/sessions/archived', (_req, res) => {
746
+ const archivedSessions = sessionsDb.getArchivedSessions();
747
+ const sessions = archivedSessions.map((session) => {
748
+ const envId = getSessionEnvironment(session.name);
749
+ const envMeta = envId ? getEnvironmentMetadata(envId) : undefined;
750
+ return {
751
+ name: session.name,
752
+ project: session.project,
753
+ createdAt: session.created_at,
754
+ status: session.status,
755
+ attentionReason: session.attention_reason ?? undefined,
756
+ statusSource: 'hook',
757
+ lastEvent: session.last_event ?? undefined,
758
+ lastStatusChange: session.last_status_change,
759
+ archivedAt: session.archived_at ?? undefined,
760
+ environmentId: envId,
761
+ environment: envMeta ? {
762
+ id: envMeta.id,
763
+ name: envMeta.name,
764
+ provider: envMeta.provider,
765
+ icon: envMeta.icon,
766
+ isDefault: envMeta.isDefault,
767
+ } : undefined,
768
+ };
769
+ });
770
+ res.json(sessions);
771
+ });
772
+ // ========== Editor API Endpoints ==========
773
+ // Helper to check if project is allowed (whitelist empty = allow any)
774
+ const isProjectAllowed = (project) => {
775
+ const whitelist = config.projects.whitelist;
776
+ const hasWhitelist = whitelist && whitelist.length > 0;
777
+ return hasWhitelist ? whitelist.includes(project) : true;
778
+ };
779
+ // Get editor status for a project
780
+ app.get('/api/editor/:project/status', (req, res) => {
781
+ const { project } = req.params;
782
+ if (!isProjectAllowed(project)) {
783
+ return res.status(403).json({ error: 'Project not allowed' });
784
+ }
785
+ res.json(getEditorStatus(project));
786
+ });
787
+ // Start editor for a project
788
+ app.post('/api/editor/:project/start', async (req, res) => {
789
+ const { project } = req.params;
790
+ if (!isProjectAllowed(project)) {
791
+ return res.status(403).json({ error: 'Project not allowed' });
792
+ }
793
+ // Check if editor is enabled
794
+ if (!typedConfig.editor?.enabled) {
795
+ return res.status(400).json({ error: 'Editor is disabled in config' });
796
+ }
797
+ try {
798
+ const editor = await getOrStartEditor(project);
799
+ res.json({
800
+ success: true,
801
+ port: editor.port,
802
+ startedAt: editor.startedAt,
803
+ });
804
+ }
805
+ catch (err) {
806
+ console.error('[Editor] Failed to start:', err);
807
+ res.status(500).json({ error: 'Failed to start editor', message: err.message });
808
+ }
809
+ });
810
+ // Stop editor for a project
811
+ app.post('/api/editor/:project/stop', (req, res) => {
812
+ const { project } = req.params;
813
+ if (!isProjectAllowed(project)) {
814
+ return res.status(403).json({ error: 'Project not allowed' });
815
+ }
816
+ const stopped = stopEditor(project);
817
+ res.json({ success: stopped });
818
+ });
819
+ // List all running editors
820
+ app.get('/api/editors', (_req, res) => {
821
+ res.json(getAllEditors());
822
+ });
823
+ // ========== File Explorer API Endpoints ==========
824
+ // Get files tree with git status for a project
825
+ app.get('/api/files/:project', async (req, res) => {
826
+ const { project } = req.params;
827
+ if (!isProjectAllowed(project)) {
828
+ return res.status(403).json({ error: 'Project not allowed' });
829
+ }
830
+ try {
831
+ const projectPath = `${config.projects.basePath}/${project}`.replace('~', process.env.HOME);
832
+ // Check if project exists
833
+ const fs = await import('fs');
834
+ if (!fs.existsSync(projectPath)) {
835
+ return res.status(404).json({ error: 'Project not found' });
836
+ }
837
+ // Get file tree
838
+ const files = await listFiles(projectPath);
839
+ // Get changes summary
840
+ const summary = await getChangesSummary(projectPath);
841
+ res.json({
842
+ files,
843
+ summary,
844
+ });
845
+ }
846
+ catch (err) {
847
+ console.error('[Files] Failed to list files:', err);
848
+ res.status(500).json({ error: 'Failed to list files', message: err.message });
849
+ }
850
+ });
851
+ // Get content of a specific file
852
+ app.get('/api/files/:project/content', async (req, res) => {
853
+ const { project } = req.params;
854
+ const { path: filePath } = req.query;
855
+ if (!isProjectAllowed(project)) {
856
+ return res.status(403).json({ error: 'Project not allowed' });
857
+ }
858
+ if (!filePath || typeof filePath !== 'string') {
859
+ return res.status(400).json({ error: 'Missing path parameter' });
860
+ }
861
+ // Validate path to prevent directory traversal
862
+ if (filePath.includes('..') || filePath.startsWith('/')) {
863
+ return res.status(400).json({ error: 'Invalid path' });
864
+ }
865
+ try {
866
+ const projectPath = `${config.projects.basePath}/${project}`.replace('~', process.env.HOME);
867
+ const content = await getFileContent(projectPath, filePath);
868
+ res.json(content);
869
+ }
870
+ catch (err) {
871
+ console.error('[Files] Failed to get file content:', err);
872
+ res.status(500).json({ error: 'Failed to read file', message: err.message });
873
+ }
874
+ });
875
+ // Open a file in the local editor
876
+ app.post('/api/files/:project/open', async (req, res) => {
877
+ const { project } = req.params;
878
+ const { path: filePath } = req.body;
879
+ if (!isProjectAllowed(project)) {
880
+ return res.status(403).json({ error: 'Project not allowed' });
881
+ }
882
+ if (!filePath || typeof filePath !== 'string') {
883
+ return res.status(400).json({ error: 'Missing path in body' });
884
+ }
885
+ // Validate path
886
+ if (filePath.includes('..') || filePath.startsWith('/')) {
887
+ return res.status(400).json({ error: 'Invalid path' });
888
+ }
889
+ try {
890
+ const projectPath = `${config.projects.basePath}/${project}`.replace('~', process.env.HOME);
891
+ const result = await openFileInEditor(projectPath, filePath);
892
+ if (result.success) {
893
+ res.json({
894
+ success: true,
895
+ command: result.command,
896
+ });
897
+ }
898
+ else {
899
+ res.status(400).json(result);
900
+ }
901
+ }
902
+ catch (err) {
903
+ console.error('[Files] Failed to open file:', err);
904
+ res.status(500).json({ error: 'Failed to open file', message: err.message });
905
+ }
906
+ });
907
+ // ========== Editor Proxy Routes ==========
908
+ // Proxy HTTP requests to code-server
909
+ app.use('/editor/:project', async (req, res, _next) => {
910
+ const { project } = req.params;
911
+ // Validate project
912
+ if (!isProjectAllowed(project)) {
913
+ return res.status(403).json({ error: 'Project not allowed' });
914
+ }
915
+ // Check if editor is enabled
916
+ if (!typedConfig.editor?.enabled) {
917
+ return res.status(400).json({ error: 'Editor is disabled in config' });
918
+ }
919
+ try {
920
+ // Get or start the editor
921
+ const editor = await getOrStartEditor(project);
922
+ updateEditorActivity(project);
923
+ // Rewrite the URL to remove /editor/:project prefix
924
+ req.url = req.url.replace(`/editor/${project}`, '') || '/';
925
+ // Proxy to code-server
926
+ editorProxy.web(req, res, {
927
+ target: `http://127.0.0.1:${editor.port}`,
928
+ });
929
+ }
930
+ catch (err) {
931
+ console.error('[Editor Proxy] Failed:', err);
932
+ res.status(502).json({ error: 'Failed to proxy to editor' });
933
+ }
934
+ });
935
+ // Handle ALL WebSocket upgrades manually (noServer mode)
936
+ server.on('upgrade', async (req, socket, head) => {
937
+ const url = new URL(req.url, `http://${req.headers.host}`);
938
+ // Handle terminal WebSocket
939
+ if (url.pathname === '/terminal') {
940
+ wss.handleUpgrade(req, socket, head, (ws) => {
941
+ wss.emit('connection', ws, req);
942
+ });
943
+ return;
944
+ }
945
+ // Handle status WebSocket (real-time session status updates)
946
+ if (url.pathname === '/status') {
947
+ wss.handleUpgrade(req, socket, head, (ws) => {
948
+ console.log('[Status WS] New subscriber connected');
949
+ statusSubscribers.add(ws);
950
+ // Send initial session list
951
+ (async () => {
952
+ try {
953
+ const { exec } = await import('child_process');
954
+ const { promisify } = await import('util');
955
+ const execAsync = promisify(exec);
956
+ const { stdout } = await execAsync('tmux list-sessions -F "#{session_name}|#{session_created}" 2>/dev/null');
957
+ const sessions = [];
958
+ for (const line of stdout.trim().split('\n').filter(Boolean)) {
959
+ const [name, created] = line.split('|');
960
+ const [project] = name.split('--');
961
+ let status = 'init';
962
+ let attentionReason;
963
+ let statusSource = 'tmux';
964
+ let lastEvent;
965
+ let lastStatusChange;
966
+ // Use hook status if available, otherwise idle
967
+ const hookData = tmuxSessionStatus.get(name);
968
+ if (hookData) {
969
+ status = hookData.status;
970
+ attentionReason = hookData.attentionReason;
971
+ statusSource = 'hook';
972
+ lastEvent = hookData.lastEvent;
973
+ lastStatusChange = hookData.lastStatusChange;
974
+ }
975
+ sessions.push({
976
+ name,
977
+ project,
978
+ createdAt: parseInt(created) * 1000,
979
+ status,
980
+ attentionReason,
981
+ statusSource,
982
+ lastActivity: '',
983
+ lastEvent,
984
+ lastStatusChange,
985
+ environmentId: getSessionEnvironment(name),
986
+ });
987
+ }
988
+ const message = { type: 'sessions-list', sessions };
989
+ if (ws.readyState === WebSocket.OPEN) {
990
+ ws.send(JSON.stringify(message));
991
+ console.log(`[Status WS] Sent initial sessions list: ${sessions.length} sessions`);
992
+ }
993
+ }
994
+ catch (err) {
995
+ console.error('[Status WS] Failed to get initial sessions:', err);
996
+ if (ws.readyState === WebSocket.OPEN) {
997
+ try {
998
+ const message = { type: 'sessions-list', sessions: [] };
999
+ ws.send(JSON.stringify(message));
1000
+ }
1001
+ catch (sendErr) {
1002
+ console.error('[Status WS] Failed to send error response:', sendErr);
1003
+ }
1004
+ }
1005
+ }
1006
+ })();
1007
+ ws.on('close', () => {
1008
+ statusSubscribers.delete(ws);
1009
+ console.log(`[Status WS] Subscriber disconnected (remaining: ${statusSubscribers.size})`);
1010
+ });
1011
+ ws.on('error', (err) => {
1012
+ console.error('[Status WS] Error:', err);
1013
+ statusSubscribers.delete(ws);
1014
+ });
1015
+ });
1016
+ return;
1017
+ }
1018
+ // Handle editor WebSocket
1019
+ if (url.pathname.startsWith('/editor/')) {
1020
+ const pathParts = url.pathname.split('/');
1021
+ const project = pathParts[2];
1022
+ if (!project || !isProjectAllowed(project)) {
1023
+ socket.destroy();
1024
+ return;
1025
+ }
1026
+ if (!typedConfig.editor?.enabled) {
1027
+ socket.destroy();
1028
+ return;
1029
+ }
1030
+ try {
1031
+ const editor = await getOrStartEditor(project);
1032
+ updateEditorActivity(project);
1033
+ // Rewrite the URL - ensure path starts with /
1034
+ const rewrittenPath = url.pathname.replace(`/editor/${project}`, '') || '/';
1035
+ req.url = rewrittenPath + url.search;
1036
+ console.log(`[Editor WS] Proxying ${url.pathname} → ${req.url} to port ${editor.port}`);
1037
+ editorProxy.ws(req, socket, head, {
1038
+ target: `http://127.0.0.1:${editor.port}`,
1039
+ });
1040
+ }
1041
+ catch (err) {
1042
+ console.error('[Editor WS] Failed:', err);
1043
+ socket.destroy();
1044
+ }
1045
+ return;
1046
+ }
1047
+ // Unknown WebSocket path - destroy
1048
+ socket.destroy();
1049
+ });
1050
+ // Graceful shutdown handler
1051
+ const shutdown = () => {
1052
+ console.log('[Server] Shutting down...');
1053
+ shutdownAllEditors();
1054
+ closeDatabase(); // Close SQLite database
1055
+ server.close();
1056
+ process.exit(0);
1057
+ };
1058
+ process.on('SIGTERM', shutdown);
1059
+ process.on('SIGINT', shutdown);
1060
+ return server;
1061
+ }
1062
+ //# sourceMappingURL=server.js.map