@seflless/ghosttown 1.6.2 → 2.0.0

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/src/cli.js CHANGED
@@ -50,6 +50,24 @@ import pty from '@lydell/node-pty';
50
50
  import { WebSocketServer } from 'ws';
51
51
  // ASCII art generator
52
52
  import { asciiArt } from '../bin/ascii.js';
53
+ // Session name utilities
54
+ import { displayNameExists, findSession, validateSessionName } from './session-utils.js';
55
+ // Session management - custom PTY session manager (replaces tmux)
56
+ import { SessionManager } from './session/session-manager.js';
57
+
58
+ // Global SessionManager instance (lazy-initialized)
59
+ let sessionManager = null;
60
+
61
+ /**
62
+ * Get or create the SessionManager instance
63
+ */
64
+ async function getSessionManager() {
65
+ if (!sessionManager) {
66
+ sessionManager = new SessionManager();
67
+ await sessionManager.init();
68
+ }
69
+ return sessionManager;
70
+ }
53
71
 
54
72
  const __filename = fileURLToPath(import.meta.url);
55
73
  const __dirname = path.dirname(__filename);
@@ -422,40 +440,43 @@ function isInsideGhosttownSession() {
422
440
  return sessionName && sessionName.startsWith('gt-');
423
441
  }
424
442
 
443
+ // Note: validateSessionName and displayNameExists are imported from session-utils.js
444
+ // CLI uses getTmuxSessionsForValidation() to adapt tmux sessions to the expected format
445
+ // Web server uses SessionManager directly
446
+
425
447
  /**
426
- * Validate a session name
427
- * Returns { valid: true } or { valid: false, error: string }
448
+ * Parse a ghosttown tmux session name into its components.
449
+ * Format: gt-<stable-id>-<display-name>
450
+ * DEPRECATED: Only used by legacy CLI commands that still use tmux
451
+ *
452
+ * @param {string} fullName - The full tmux session name
453
+ * @returns {{ stableId: string|null, displayName: string, fullName: string }|null}
428
454
  */
429
- function validateSessionName(name) {
430
- if (!name || name.length === 0) {
431
- return { valid: false, error: 'Session name cannot be empty' };
432
- }
433
- if (name.length > 50) {
434
- return { valid: false, error: 'Session name too long (max 50 chars)' };
435
- }
436
- if (/^[0-9]+$/.test(name)) {
437
- return {
438
- valid: false,
439
- error: 'Session name cannot be purely numeric (conflicts with auto-generated IDs)',
440
- };
441
- }
442
- // Reserved name for background server
443
- if (name === 'ghosttown-host') {
444
- return { valid: false, error: "'ghosttown-host' is a reserved name" };
455
+ function parseSessionName(fullName) {
456
+ if (!fullName || !fullName.startsWith('gt-')) {
457
+ return null;
445
458
  }
446
- // tmux session names cannot contain: colon, period
447
- // We also disallow spaces and special chars for URL safety
448
- if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
459
+ // Format: gt-<stable-id>-<display-name>
460
+ const match = fullName.match(/^gt-(\d+)-(.+)$/);
461
+ if (match) {
449
462
  return {
450
- valid: false,
451
- error: 'Session name can only contain letters, numbers, hyphens, and underscores',
463
+ stableId: match[1],
464
+ displayName: match[2],
465
+ fullName: fullName,
452
466
  };
453
467
  }
454
- return { valid: true };
468
+ // Legacy format: gt-<name> (no stable ID)
469
+ const legacyName = fullName.slice(3);
470
+ return {
471
+ stableId: null,
472
+ displayName: legacyName,
473
+ fullName: fullName,
474
+ };
455
475
  }
456
476
 
457
477
  /**
458
478
  * Check if a tmux session with the given name exists
479
+ * DEPRECATED: Only used by legacy CLI commands that still use tmux
459
480
  */
460
481
  function sessionExists(sessionName) {
461
482
  try {
@@ -473,10 +494,11 @@ function sessionExists(sessionName) {
473
494
  }
474
495
 
475
496
  /**
476
- * Check if a display name is already used by a ghosttown session
477
- * Display name is the user-facing part after gt-<id>-
497
+ * Get tmux sessions in a format compatible with displayNameExists().
498
+ * Returns array of objects with displayName property.
499
+ * DEPRECATED: Only used by legacy CLI commands that still use tmux
478
500
  */
479
- function displayNameExists(displayName) {
501
+ function getTmuxSessionsForValidation() {
480
502
  try {
481
503
  const output = execSync('tmux list-sessions -F "#{session_name}"', {
482
504
  encoding: 'utf8',
@@ -484,61 +506,54 @@ function displayNameExists(displayName) {
484
506
  });
485
507
  return output
486
508
  .split('\n')
487
- .filter((s) => s.trim())
488
- .some((name) => {
509
+ .filter((name) => name.startsWith('gt-'))
510
+ .map((name) => {
489
511
  const parsed = parseSessionName(name);
490
- return parsed && parsed.displayName === displayName;
512
+ return { displayName: parsed ? parsed.displayName : name.replace('gt-', '') };
491
513
  });
492
514
  } catch (err) {
493
- return false;
515
+ return [];
494
516
  }
495
517
  }
496
518
 
497
519
  /**
498
- * Parse a ghosttown session name into its components
499
- * Format: gt-<stable-id>-<display-name>
500
- * Returns { stableId, displayName, fullName } or null if not a valid gt session
501
- */
502
- function parseSessionName(fullName) {
503
- if (!fullName || !fullName.startsWith('gt-')) {
504
- return null;
505
- }
506
- // Format: gt-<stable-id>-<display-name>
507
- const match = fullName.match(/^gt-(\d+)-(.+)$/);
508
- if (match) {
509
- return {
510
- stableId: match[1],
511
- displayName: match[2],
512
- fullName: fullName,
513
- };
514
- }
515
- // Legacy format: gt-<name> (no stable ID)
516
- // Treat the whole thing after gt- as the display name, no stable ID
517
- const legacyName = fullName.slice(3);
518
- return {
519
- stableId: null,
520
- displayName: legacyName,
521
- fullName: fullName,
522
- };
523
- }
524
-
525
- /**
526
- * Find a ghosttown session by its stable ID
527
- * Returns the full session name or null if not found
520
+ * Find a tmux session by display name or stableId.
521
+ * Used by CLI commands (attach, rename, kill) that interact with tmux sessions.
522
+ *
523
+ * @param {string} identifier - Display name or stableId to search for
524
+ * @returns {{ displayName: string, stableId: string|null, fullName: string }|null}
528
525
  */
529
- function findSessionByStableId(stableId) {
526
+ function findSessionByDisplayName(identifier) {
530
527
  try {
531
528
  const output = execSync('tmux list-sessions -F "#{session_name}"', {
532
529
  encoding: 'utf8',
533
530
  stdio: ['pipe', 'pipe', 'pipe'],
534
531
  });
535
- const sessions = output.split('\n').filter((s) => s.trim() && s.startsWith('gt-'));
536
- for (const name of sessions) {
537
- const parsed = parseSessionName(name);
538
- if (parsed && parsed.stableId === stableId) {
539
- return name;
540
- }
532
+
533
+ const sessions = output
534
+ .split('\n')
535
+ .filter((name) => name.startsWith('gt-') && name !== SERVER_SESSION_NAME)
536
+ .map((fullName) => {
537
+ const parsed = parseSessionName(fullName);
538
+ return {
539
+ displayName: parsed ? parsed.displayName : fullName.replace('gt-', ''),
540
+ stableId: parsed ? parsed.stableId : null,
541
+ fullName,
542
+ };
543
+ });
544
+
545
+ // Try exact display name match first
546
+ const byName = sessions.find((s) => s.displayName === identifier);
547
+ if (byName) {
548
+ return byName;
549
+ }
550
+
551
+ // Try stableId match (for backwards compatibility)
552
+ const byId = sessions.find((s) => s.stableId === identifier);
553
+ if (byId) {
554
+ return byId;
541
555
  }
556
+
542
557
  return null;
543
558
  } catch (err) {
544
559
  return null;
@@ -766,7 +781,7 @@ function createTmuxSession(command, customName = null) {
766
781
  process.exit(1);
767
782
  }
768
783
  // Check if display name already exists
769
- if (displayNameExists(customName)) {
784
+ if (displayNameExists(getTmuxSessionsForValidation(), customName)) {
770
785
  console.log('');
771
786
  console.log(` ${RED}Error:${RESET} Session '${customName}' already exists.`);
772
787
  console.log('');
@@ -937,32 +952,13 @@ function attachToSession(sessionName) {
937
952
  const RED = '\x1b[31m';
938
953
  const BEIGE = '\x1b[38;2;255;220;150m';
939
954
 
940
- // Add gt- prefix if not present
941
- if (!sessionName.startsWith('gt-')) {
942
- sessionName = `gt-${sessionName}`;
943
- }
944
-
945
- // Check if session exists
946
- try {
947
- const output = execSync('tmux list-sessions -F "#{session_name}"', {
948
- encoding: 'utf8',
949
- stdio: ['pipe', 'pipe', 'pipe'],
950
- });
951
-
952
- const sessions = output.split('\n').filter((s) => s.trim());
953
- if (!sessions.includes(sessionName)) {
954
- console.log('');
955
- console.log(` ${RED}Error:${RESET} Session '${sessionName}' not found.`);
956
- console.log('');
957
- console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
958
- console.log('');
959
- process.exit(1);
960
- }
961
- } catch (err) {
955
+ // Look up session by display name
956
+ const found = findSessionByDisplayName(sessionName);
957
+ if (!found) {
962
958
  console.log('');
963
- console.log(` ${RED}Error:${RESET} No tmux sessions found.`);
959
+ console.log(` ${RED}Error:${RESET} Session '${sessionName}' not found.`);
964
960
  console.log('');
965
- console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
961
+ listSessionsInline();
966
962
  console.log('');
967
963
  process.exit(1);
968
964
  }
@@ -971,7 +967,7 @@ function attachToSession(sessionName) {
971
967
  // Use the same pattern as detachFromTmux which works
972
968
  const result = spawnSync(
973
969
  process.env.SHELL || '/bin/sh',
974
- ['-c', `tmux attach-session -t ${sessionName}`],
970
+ ['-c', `tmux attach-session -t ${found.fullName}`],
975
971
  {
976
972
  stdio: 'inherit',
977
973
  }
@@ -1087,57 +1083,57 @@ function killSession(sessionName) {
1087
1083
  }
1088
1084
 
1089
1085
  sessionName = getCurrentTmuxSessionName();
1090
- }
1091
-
1092
- // Add gt- prefix if not present
1093
- if (!sessionName.startsWith('gt-')) {
1094
- sessionName = `gt-${sessionName}`;
1095
- }
1086
+ // sessionName is now the full tmux name (e.g., gt-12-13)
1087
+ const parsed = parseSessionName(sessionName);
1096
1088
 
1097
- // Check if session exists
1098
- try {
1099
- const output = execSync('tmux list-sessions -F "#{session_name}"', {
1100
- encoding: 'utf8',
1101
- stdio: ['pipe', 'pipe', 'pipe'],
1102
- });
1089
+ // Kill the session
1090
+ try {
1091
+ execSync(`tmux kill-session -t ${sessionName}`, {
1092
+ stdio: 'pipe',
1093
+ });
1103
1094
 
1104
- const sessions = output.split('\n').filter((s) => s.trim());
1105
- if (!sessions.includes(sessionName)) {
1095
+ const displayName = parsed ? parsed.displayName : sessionName.replace('gt-', '');
1106
1096
  console.log('');
1107
- console.log(` ${RED}Error:${RESET} Session '${sessionName}' not found.`);
1097
+ console.log(` ${BOLD_YELLOW}Session ${displayName} has been killed.${RESET}`);
1108
1098
  console.log('');
1109
- console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
1099
+ console.log(` ${CYAN}To list remaining:${RESET} ${BEIGE}gt list${RESET}`);
1100
+ console.log('');
1101
+ process.exit(0);
1102
+ } catch (err) {
1103
+ const displayName = parsed ? parsed.displayName : sessionName.replace('gt-', '');
1104
+ console.log('');
1105
+ console.log(` ${RED}Error:${RESET} Failed to kill session '${displayName}'.`);
1110
1106
  console.log('');
1111
1107
  process.exit(1);
1112
1108
  }
1113
- } catch (err) {
1109
+ }
1110
+
1111
+ // User provided a session name - look it up by display name
1112
+ const found = findSessionByDisplayName(sessionName);
1113
+ if (!found) {
1114
1114
  console.log('');
1115
- console.log(` ${RED}Error:${RESET} No tmux sessions found.`);
1115
+ console.log(` ${RED}Error:${RESET} Session '${sessionName}' not found.`);
1116
1116
  console.log('');
1117
- console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
1117
+ listSessionsInline();
1118
1118
  console.log('');
1119
1119
  process.exit(1);
1120
1120
  }
1121
1121
 
1122
1122
  // Kill the session
1123
1123
  try {
1124
- execSync(`tmux kill-session -t ${sessionName}`, {
1124
+ execSync(`tmux kill-session -t ${found.fullName}`, {
1125
1125
  stdio: 'pipe',
1126
1126
  });
1127
1127
 
1128
- // Extract display name from session name
1129
- const parsed = parseSessionName(sessionName);
1130
- const displayName = parsed ? parsed.displayName : sessionName.replace('gt-', '');
1131
-
1132
1128
  console.log('');
1133
- console.log(` ${BOLD_YELLOW}Session ${displayName} has been killed.${RESET}`);
1129
+ console.log(` ${BOLD_YELLOW}Session ${found.displayName} has been killed.${RESET}`);
1134
1130
  console.log('');
1135
1131
  console.log(` ${CYAN}To list remaining:${RESET} ${BEIGE}gt list${RESET}`);
1136
1132
  console.log('');
1137
1133
  process.exit(0);
1138
1134
  } catch (err) {
1139
1135
  console.log('');
1140
- console.log(` ${RED}Error:${RESET} Failed to kill session '${sessionName}'.`);
1136
+ console.log(` ${RED}Error:${RESET} Failed to kill session '${found.displayName}'.`);
1141
1137
  console.log('');
1142
1138
  process.exit(1);
1143
1139
  }
@@ -1442,29 +1438,6 @@ function listSessionsInline() {
1442
1438
  }
1443
1439
  }
1444
1440
 
1445
- /**
1446
- * Find a ghosttown session by display name
1447
- * Returns the parsed session info or null if not found
1448
- */
1449
- function findSessionByDisplayName(displayName) {
1450
- try {
1451
- const output = execSync('tmux list-sessions -F "#{session_name}"', {
1452
- encoding: 'utf8',
1453
- stdio: ['pipe', 'pipe', 'pipe'],
1454
- });
1455
- const sessions = output.split('\n').filter((s) => s.trim() && s.startsWith('gt-'));
1456
- for (const fullName of sessions) {
1457
- const parsed = parseSessionName(fullName);
1458
- if (parsed && parsed.displayName === displayName) {
1459
- return parsed;
1460
- }
1461
- }
1462
- return null;
1463
- } catch (err) {
1464
- return null;
1465
- }
1466
- }
1467
-
1468
1441
  /**
1469
1442
  * Rename a ghosttown session
1470
1443
  * Usage:
@@ -1540,7 +1513,7 @@ function renameSession(renameArgs) {
1540
1513
  }
1541
1514
 
1542
1515
  // Check new display name doesn't conflict
1543
- if (displayNameExists(newDisplayName)) {
1516
+ if (displayNameExists(getTmuxSessionsForValidation(), newDisplayName)) {
1544
1517
  console.log('');
1545
1518
  console.log(` ${RED}Error:${RESET} Session '${newDisplayName}' already exists.`);
1546
1519
  console.log('');
@@ -1672,8 +1645,7 @@ Aliases:
1672
1645
  if (!sessionArg) {
1673
1646
  console.error('Error: attach requires a session name or ID');
1674
1647
  console.error('Usage: gt attach <name|id>');
1675
- handled = true;
1676
- break;
1648
+ process.exit(1);
1677
1649
  }
1678
1650
  handled = true;
1679
1651
  attachToSession(sessionArg);
@@ -1978,7 +1950,15 @@ function startWebServer(cliArgs) {
1978
1950
  color: #e5e5e5;
1979
1951
  font-size: 22px;
1980
1952
  font-weight: 600;
1981
- margin-bottom: 12px;
1953
+ margin-bottom: 4px;
1954
+ text-align: center;
1955
+ }
1956
+
1957
+ .session-card .subtitle {
1958
+ color: #888;
1959
+ font-size: 13px;
1960
+ font-weight: 400;
1961
+ margin-bottom: 16px;
1982
1962
  text-align: center;
1983
1963
  }
1984
1964
 
@@ -2382,6 +2362,7 @@ function startWebServer(cliArgs) {
2382
2362
  <div class="session-list-view" id="session-list-view">
2383
2363
  <div class="session-card">
2384
2364
  <h1>Ghost Town</h1>
2365
+ <p class="subtitle">Choose a terminal to connect to</p>
2385
2366
  <div class="error-message" id="error-message"></div>
2386
2367
  <div class="session-table-header" id="session-table-header" style="display: none;">
2387
2368
  <span class="header-name">Name</span>
@@ -2454,13 +2435,15 @@ function startWebServer(cliArgs) {
2454
2435
  }
2455
2436
 
2456
2437
  // Get session info from URL query parameters
2457
- // Returns { name, stableId } or null
2438
+ // Returns { sessionId, name, stableId } or null
2439
+ // Supports both new (?sessionId=uuid) and legacy (?session=name&id=stableId) formats
2458
2440
  function getSessionFromUrl() {
2459
2441
  const params = new URLSearchParams(window.location.search);
2442
+ const sessionId = params.get('sessionId');
2460
2443
  const name = params.get('session');
2461
2444
  const stableId = params.get('id');
2462
- if (name || stableId) {
2463
- return { name, stableId };
2445
+ if (sessionId || name || stableId) {
2446
+ return { sessionId, name, stableId };
2464
2447
  }
2465
2448
  return null;
2466
2449
  }
@@ -2469,9 +2452,13 @@ function startWebServer(cliArgs) {
2469
2452
  async function initApp() {
2470
2453
  const sessionInfo = getSessionFromUrl();
2471
2454
 
2472
- if (sessionInfo && sessionInfo.stableId) {
2473
- // Show terminal view using stable ID
2474
- requestedSessionName = sessionInfo.name; // Store for error messages
2455
+ if (sessionInfo && sessionInfo.sessionId) {
2456
+ // New format: use UUID sessionId
2457
+ requestedSessionName = sessionInfo.name || sessionInfo.sessionId;
2458
+ showTerminalView(sessionInfo.sessionId);
2459
+ } else if (sessionInfo && sessionInfo.stableId) {
2460
+ // Legacy format: use stable ID
2461
+ requestedSessionName = sessionInfo.name;
2475
2462
  showTerminalView(sessionInfo.stableId);
2476
2463
  } else if (sessionInfo && sessionInfo.name) {
2477
2464
  // Legacy URL with just name - show terminal view
@@ -3356,15 +3343,22 @@ function startWebServer(cliArgs) {
3356
3343
  res.end();
3357
3344
  return;
3358
3345
  }
3359
- try {
3360
- const { displayNumber, stableId } = createWebSession();
3361
- res.writeHead(302, { Location: `/?session=${displayNumber}&id=${stableId}` });
3362
- res.end();
3363
- } catch (err) {
3364
- console.error('Failed to create session:', err);
3365
- res.writeHead(500, { 'Content-Type': 'text/plain' });
3366
- res.end('Failed to create session');
3367
- }
3346
+ (async () => {
3347
+ try {
3348
+ const manager = await getSessionManager();
3349
+ // Create session without starting PTY - it will start on first WebSocket connect
3350
+ const newSession = await manager.createSession({ startProcess: false });
3351
+ // Use sessionId for new format, keep session param for backward compat
3352
+ res.writeHead(302, {
3353
+ Location: `/?sessionId=${newSession.id}&session=${encodeURIComponent(newSession.displayName)}`,
3354
+ });
3355
+ res.end();
3356
+ } catch (err) {
3357
+ console.error('Failed to create session:', err);
3358
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
3359
+ res.end('Failed to create session');
3360
+ }
3361
+ })();
3368
3362
  return;
3369
3363
  }
3370
3364
 
@@ -3403,7 +3397,7 @@ function startWebServer(cliArgs) {
3403
3397
  return;
3404
3398
  }
3405
3399
 
3406
- // API: List sessions (from tmux) - requires authentication
3400
+ // API: List sessions - requires authentication
3407
3401
  if (pathname === '/api/sessions' && req.method === 'GET') {
3408
3402
  if (!session) {
3409
3403
  res.writeHead(401, { 'Content-Type': 'application/json' });
@@ -3411,40 +3405,31 @@ function startWebServer(cliArgs) {
3411
3405
  return;
3412
3406
  }
3413
3407
  try {
3414
- const output = execSync(
3415
- 'tmux list-sessions -F "#{session_name}|#{session_activity}|#{session_attached}|#{session_windows}"',
3416
- { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
3417
- );
3418
-
3419
- const sessionList = output
3420
- .split('\n')
3421
- .filter((line) => line.startsWith('gt-'))
3422
- .map((line) => {
3423
- const [fullName, activity, attached, windows] = line.split('|');
3424
- const parsed = parseSessionName(fullName);
3425
- return {
3426
- name: parsed ? parsed.displayName : fullName.replace('gt-', ''),
3427
- stableId: parsed ? parsed.stableId : null,
3428
- fullName: fullName,
3429
- lastActivity: Number.parseInt(activity, 10) * 1000,
3430
- attached: attached === '1',
3431
- windows: Number.parseInt(windows, 10),
3432
- };
3433
- });
3408
+ const manager = await getSessionManager();
3409
+ const sessionList = await manager.listSessions();
3410
+
3411
+ // Map to API response format with backward-compatible fields
3412
+ const response = sessionList.map((s) => ({
3413
+ name: s.displayName,
3414
+ stableId: s.id.slice(0, 8), // Short ID for backward compat
3415
+ id: s.id, // Full UUID (new)
3416
+ lastActivity: s.lastActivity,
3417
+ attached: s.connectionCount > 0, // Backward compat: attached means has connections
3418
+ isRunning: s.isRunning,
3419
+ connectionCount: s.connectionCount,
3420
+ }));
3434
3421
 
3435
- // Sort by recent activity
3436
- sessionList.sort((a, b) => b.lastActivity - a.lastActivity);
3437
3422
  res.writeHead(200, { 'Content-Type': 'application/json' });
3438
- res.end(JSON.stringify({ sessions: sessionList }));
3423
+ res.end(JSON.stringify({ sessions: response }));
3439
3424
  } catch (err) {
3440
- // No tmux sessions or tmux not running
3425
+ console.error('Error listing sessions:', err);
3441
3426
  res.writeHead(200, { 'Content-Type': 'application/json' });
3442
3427
  res.end(JSON.stringify({ sessions: [] }));
3443
3428
  }
3444
3429
  return;
3445
3430
  }
3446
3431
 
3447
- // API: Create session (tmux session) - requires authentication
3432
+ // API: Create session - requires authentication
3448
3433
  if (pathname === '/api/sessions/create' && req.method === 'POST') {
3449
3434
  if (!session) {
3450
3435
  res.writeHead(401, { 'Content-Type': 'application/json' });
@@ -3452,10 +3437,18 @@ function startWebServer(cliArgs) {
3452
3437
  return;
3453
3438
  }
3454
3439
  try {
3455
- const { displayNumber, stableId } = createWebSession();
3440
+ const manager = await getSessionManager();
3441
+ const newSession = await manager.createSession();
3456
3442
  res.writeHead(200, { 'Content-Type': 'application/json' });
3457
- res.end(JSON.stringify({ name: String(displayNumber), stableId: stableId }));
3443
+ res.end(
3444
+ JSON.stringify({
3445
+ name: newSession.displayName,
3446
+ stableId: newSession.id.slice(0, 8), // Short ID for backward compat
3447
+ id: newSession.id, // Full UUID (new)
3448
+ })
3449
+ );
3458
3450
  } catch (err) {
3451
+ console.error('Error creating session:', err);
3459
3452
  res.writeHead(500, { 'Content-Type': 'application/json' });
3460
3453
  res.end(JSON.stringify({ error: 'Failed to create session' }));
3461
3454
  }
@@ -3472,85 +3465,123 @@ function startWebServer(cliArgs) {
3472
3465
 
3473
3466
  const cols = Number.parseInt(url.searchParams.get('cols') || '80');
3474
3467
  const rows = Number.parseInt(url.searchParams.get('rows') || '24');
3475
- const requestedSession = url.searchParams.get('session');
3476
- const requestedName = url.searchParams.get('name'); // Original name from URL (if different from session)
3468
+ // Support both new (sessionId) and legacy (session) parameters
3469
+ const sessionIdParam = url.searchParams.get('sessionId');
3470
+ const legacySession = url.searchParams.get('session');
3471
+ const requestedName = url.searchParams.get('name');
3472
+ const identifier = sessionIdParam || legacySession;
3477
3473
 
3478
- if (!requestedSession) {
3474
+ if (!identifier) {
3479
3475
  res.writeHead(400, { 'Content-Type': 'application/json' });
3480
3476
  res.end(JSON.stringify({ error: 'Session parameter required' }));
3481
3477
  return;
3482
3478
  }
3483
3479
 
3484
- // Look up session by stable ID first, then by display name
3485
- let sessionInfo = findSessionByStableIdWeb(requestedSession);
3486
- if (!sessionInfo) {
3487
- sessionInfo = findSessionByDisplayName(requestedSession);
3488
- }
3489
- if (!sessionInfo) {
3490
- // Build error message with both name and id if available
3491
- let errorMsg;
3492
- if (requestedName && requestedName !== requestedSession) {
3493
- errorMsg = `No session found named ${requestedName}, or with id ${requestedSession}`;
3494
- } else {
3495
- errorMsg = `No session found named ${requestedSession} or with id ${requestedSession}`;
3480
+ try {
3481
+ const manager = await getSessionManager();
3482
+
3483
+ // Look up session by UUID, short ID, or display name
3484
+ const sessionObj = await findSessionForWeb(identifier);
3485
+ if (!sessionObj) {
3486
+ let errorMsg;
3487
+ if (requestedName && requestedName !== identifier) {
3488
+ errorMsg = `No session found named ${requestedName}, or with id ${identifier}`;
3489
+ } else {
3490
+ errorMsg = `No session found named ${identifier} or with id ${identifier}`;
3491
+ }
3492
+ res.writeHead(404, { 'Content-Type': 'application/json' });
3493
+ res.end(JSON.stringify({ error: errorMsg }));
3494
+ return;
3496
3495
  }
3497
- res.writeHead(404, { 'Content-Type': 'application/json' });
3498
- res.end(JSON.stringify({ error: errorMsg }));
3499
- return;
3500
- }
3501
3496
 
3502
- // Generate unique connection ID
3503
- const connectionId = `sse-${Date.now()}-${Math.random().toString(36).slice(2)}`;
3497
+ // Generate unique connection ID
3498
+ const connectionId = `sse-${Date.now()}-${Math.random().toString(36).slice(2)}`;
3504
3499
 
3505
- // Set up SSE headers
3506
- res.writeHead(200, {
3507
- 'Content-Type': 'text/event-stream',
3508
- 'Cache-Control': 'no-cache',
3509
- Connection: 'keep-alive',
3510
- 'X-SSE-Connection-Id': connectionId,
3511
- });
3500
+ // Set up SSE headers first
3501
+ res.writeHead(200, {
3502
+ 'Content-Type': 'text/event-stream',
3503
+ 'Cache-Control': 'no-cache',
3504
+ Connection: 'keep-alive',
3505
+ 'X-SSE-Connection-Id': connectionId,
3506
+ });
3512
3507
 
3513
- // Send session info as first event
3514
- res.write(
3515
- `data: ${JSON.stringify({ type: 'session_info', name: sessionInfo.displayName, stableId: sessionInfo.stableId, connectionId })}\n\n`
3516
- );
3508
+ // Send session info as first event
3509
+ res.write(
3510
+ `data: ${JSON.stringify({ type: 'session_info', name: sessionObj.displayName, stableId: sessionObj.id.slice(0, 8), id: sessionObj.id, connectionId })}\n\n`
3511
+ );
3517
3512
 
3518
- // Create PTY attached to tmux session
3519
- const ptyProcess = createTmuxAttachPty(sessionInfo.fullName, cols, rows);
3513
+ // Timeout for closing connection after session exit (30 seconds)
3514
+ const SSE_EXIT_TIMEOUT_MS = 30000;
3515
+ let exitTimeout = null;
3516
+
3517
+ // Set up event handlers BEFORE connecting (to catch early output like shell prompt)
3518
+ const outputHandler = (sid, data) => {
3519
+ if (sid === sessionObj.id && !res.writableEnded) {
3520
+ // Clear exit timeout if session produces output (it restarted)
3521
+ if (exitTimeout) {
3522
+ clearTimeout(exitTimeout);
3523
+ exitTimeout = null;
3524
+ }
3525
+ const encoded = Buffer.from(data).toString('base64');
3526
+ res.write(`data: ${JSON.stringify({ type: 'output', data: encoded })}\n\n`);
3527
+ }
3528
+ };
3520
3529
 
3521
- // Store connection
3522
- sseConnections.set(connectionId, {
3523
- res,
3524
- pty: ptyProcess,
3525
- stableId: requestedSession,
3526
- sessionInfo,
3527
- });
3530
+ const exitHandler = (sid) => {
3531
+ if (sid === sessionObj.id && !res.writableEnded) {
3532
+ res.write(`data: ${JSON.stringify({ type: 'exit' })}\n\n`);
3533
+ // Set timeout to close connection if session doesn't restart
3534
+ exitTimeout = setTimeout(() => {
3535
+ if (!res.writableEnded) {
3536
+ res.write(
3537
+ `data: ${JSON.stringify({ type: 'timeout', message: 'Session exited and did not restart' })}\n\n`
3538
+ );
3539
+ res.end();
3540
+ const conn = sseConnections.get(connectionId);
3541
+ if (conn) {
3542
+ manager.off('session:output', conn.outputHandler);
3543
+ manager.off('session:exit', conn.exitHandler);
3544
+ sseConnections.delete(connectionId);
3545
+ }
3546
+ }
3547
+ }, SSE_EXIT_TIMEOUT_MS);
3548
+ }
3549
+ };
3528
3550
 
3529
- // Stream terminal output as SSE events
3530
- ptyProcess.onData((data) => {
3531
- if (!res.writableEnded) {
3532
- // Base64 encode to handle binary data safely
3533
- const encoded = Buffer.from(data).toString('base64');
3534
- res.write(`data: ${JSON.stringify({ type: 'output', data: encoded })}\n\n`);
3535
- }
3536
- });
3551
+ manager.on('session:output', outputHandler);
3552
+ manager.on('session:exit', exitHandler);
3537
3553
 
3538
- ptyProcess.onExit(() => {
3539
- if (!res.writableEnded) {
3540
- res.write(`data: ${JSON.stringify({ type: 'exit' })}\n\n`);
3541
- res.end();
3542
- }
3543
- sseConnections.delete(connectionId);
3544
- });
3554
+ // Store connection
3555
+ sseConnections.set(connectionId, {
3556
+ res,
3557
+ sessionId: sessionObj.id,
3558
+ outputHandler,
3559
+ exitHandler,
3560
+ exitTimeout: () => exitTimeout, // Getter for cleanup
3561
+ });
3545
3562
 
3546
- // Handle client disconnect
3547
- req.on('close', () => {
3548
- const conn = sseConnections.get(connectionId);
3549
- if (conn) {
3550
- conn.pty.kill();
3551
- sseConnections.delete(connectionId);
3552
- }
3553
- });
3563
+ // Connect to session (respawns PTY if needed) - after handlers are set up
3564
+ await manager.connectToSession(sessionObj.id);
3565
+ await manager.resizeSession(sessionObj.id, cols, rows);
3566
+
3567
+ // Handle client disconnect
3568
+ req.on('close', () => {
3569
+ const conn = sseConnections.get(connectionId);
3570
+ if (conn) {
3571
+ // Clear any pending exit timeout
3572
+ const timeout = conn.exitTimeout?.();
3573
+ if (timeout) clearTimeout(timeout);
3574
+ manager.off('session:output', conn.outputHandler);
3575
+ manager.off('session:exit', conn.exitHandler);
3576
+ sseConnections.delete(connectionId);
3577
+ // Note: We do NOT kill the PTY - session persists for reconnection
3578
+ }
3579
+ });
3580
+ } catch (err) {
3581
+ console.error('SSE connection error:', err);
3582
+ res.writeHead(500, { 'Content-Type': 'application/json' });
3583
+ res.end(JSON.stringify({ error: err.message }));
3584
+ }
3554
3585
 
3555
3586
  return;
3556
3587
  }
@@ -3567,7 +3598,7 @@ function startWebServer(cliArgs) {
3567
3598
  req.on('data', (chunk) => {
3568
3599
  body += chunk;
3569
3600
  });
3570
- req.on('end', () => {
3601
+ req.on('end', async () => {
3571
3602
  try {
3572
3603
  const { connectionId, data, type, cols, rows } = JSON.parse(body);
3573
3604
 
@@ -3578,12 +3609,14 @@ function startWebServer(cliArgs) {
3578
3609
  return;
3579
3610
  }
3580
3611
 
3612
+ const manager = await getSessionManager();
3613
+
3581
3614
  if (type === 'resize' && cols && rows) {
3582
- conn.pty.resize(cols, rows);
3615
+ await manager.resizeSession(conn.sessionId, cols, rows);
3583
3616
  } else if (data) {
3584
3617
  // Decode base64 input
3585
3618
  const decoded = Buffer.from(data, 'base64').toString('utf8');
3586
- conn.pty.write(decoded);
3619
+ manager.write(conn.sessionId, decoded);
3587
3620
  }
3588
3621
 
3589
3622
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -3620,56 +3653,35 @@ function startWebServer(cliArgs) {
3620
3653
  // WebSocket Server
3621
3654
  // ============================================================================
3622
3655
 
3623
- // Track active WebSocket connections to tmux sessions
3624
- // ws -> { pty, sessionName }
3656
+ // Track active WebSocket connections to sessions
3657
+ // ws -> { sessionId, outputHandler, exitHandler }
3625
3658
  const wsConnections = new Map();
3626
3659
 
3627
3660
  // Track active SSE connections (fallback for mobile)
3628
- // connectionId -> { res, pty, stableId, sessionInfo }
3661
+ // connectionId -> { res, sessionId, outputHandler, exitHandler }
3629
3662
  const sseConnections = new Map();
3630
3663
 
3631
3664
  /**
3632
- * Find a ghosttown session by stable ID (for web connections)
3633
- * Returns { fullName, displayName, stableId } or null if not found
3665
+ * Find a session by various identifiers (UUID, short ID, or display name)
3666
+ * Returns session object or null if not found
3634
3667
  */
3635
- function findSessionByStableIdWeb(stableId) {
3636
- try {
3637
- const output = execSync('tmux list-sessions -F "#{session_name}"', {
3638
- encoding: 'utf8',
3639
- stdio: ['pipe', 'pipe', 'pipe'],
3640
- });
3641
- const sessions = output.split('\n').filter((s) => s.trim() && s.startsWith('gt-'));
3642
- for (const fullName of sessions) {
3643
- const parsed = parseSessionName(fullName);
3644
- if (parsed && parsed.stableId === stableId) {
3645
- return parsed;
3646
- }
3647
- }
3648
- return null;
3649
- } catch (err) {
3650
- return null;
3651
- }
3652
- }
3668
+ async function findSessionForWeb(identifier) {
3669
+ const manager = await getSessionManager();
3670
+ const sessions = await manager.listSessions();
3653
3671
 
3654
- /**
3655
- * Create a PTY that attaches to a tmux session
3656
- */
3657
- function createTmuxAttachPty(fullSessionName, cols, rows) {
3658
- const ptyProcess = pty.spawn('tmux', ['attach-session', '-t', fullSessionName], {
3659
- name: 'xterm-256color',
3660
- cols: cols,
3661
- rows: rows,
3662
- cwd: homedir(),
3663
- env: {
3664
- ...process.env,
3665
- TERM: 'xterm-256color',
3666
- COLORTERM: 'truecolor',
3667
- LANG: 'en_US.UTF-8',
3668
- LC_ALL: 'en_US.UTF-8',
3669
- },
3670
- });
3672
+ // Try exact UUID match first
3673
+ let session = sessions.find((s) => s.id === identifier);
3674
+ if (session) return manager.getSession(session.id);
3671
3675
 
3672
- return ptyProcess;
3676
+ // Try short ID prefix match
3677
+ session = sessions.find((s) => s.id.startsWith(identifier));
3678
+ if (session) return manager.getSession(session.id);
3679
+
3680
+ // Try display name match
3681
+ session = sessions.find((s) => s.displayName === identifier);
3682
+ if (session) return manager.getSession(session.id);
3683
+
3684
+ return null;
3673
3685
  }
3674
3686
 
3675
3687
  const wss = new WebSocketServer({ noServer: true });
@@ -3700,99 +3712,130 @@ function startWebServer(cliArgs) {
3700
3712
  }
3701
3713
  });
3702
3714
 
3703
- wss.on('connection', (ws, req) => {
3715
+ wss.on('connection', async (ws, req) => {
3704
3716
  const url = new URL(req.url, `http://${req.headers.host}`);
3705
3717
  const cols = Number.parseInt(url.searchParams.get('cols') || '80');
3706
3718
  const rows = Number.parseInt(url.searchParams.get('rows') || '24');
3707
- const requestedSession = url.searchParams.get('session');
3719
+
3720
+ // Support both new (sessionId) and legacy (session) parameters
3721
+ const sessionId = url.searchParams.get('sessionId');
3722
+ const legacySession = url.searchParams.get('session');
3708
3723
  const requestedName = url.searchParams.get('name'); // Original name from URL (if different from session)
3724
+ const identifier = sessionId || legacySession;
3709
3725
 
3710
- // Session parameter is required (should be stable ID)
3711
- if (!requestedSession) {
3726
+ // Session parameter is required
3727
+ if (!identifier) {
3712
3728
  ws.send(JSON.stringify({ type: 'error', message: 'Session parameter required' }));
3713
3729
  ws.close();
3714
3730
  return;
3715
3731
  }
3716
3732
 
3717
- // Look up session by stable ID first, then by display name (for legacy URLs)
3718
- let sessionInfo = findSessionByStableIdWeb(requestedSession);
3719
- if (!sessionInfo) {
3720
- // Try looking up by display name
3721
- sessionInfo = findSessionByDisplayName(requestedSession);
3722
- }
3723
- if (!sessionInfo) {
3724
- // Build error message with both name and id if available
3725
- let errorMsg;
3726
- if (requestedName && requestedName !== requestedSession) {
3727
- errorMsg = `No session found named ${requestedName}, or with id ${requestedSession}`;
3728
- } else {
3729
- errorMsg = `No session found named ${requestedSession} or with id ${requestedSession}`;
3733
+ try {
3734
+ const manager = await getSessionManager();
3735
+
3736
+ // Look up session by UUID, short ID, or display name
3737
+ const session = await findSessionForWeb(identifier);
3738
+
3739
+ if (!session) {
3740
+ // Build error message with both name and id if available
3741
+ let errorMsg;
3742
+ if (requestedName && requestedName !== identifier) {
3743
+ errorMsg = `No session found named ${requestedName}, or with id ${identifier}`;
3744
+ } else {
3745
+ errorMsg = `No session found named ${identifier} or with id ${identifier}`;
3746
+ }
3747
+ ws.send(JSON.stringify({ type: 'error', message: errorMsg }));
3748
+ ws.close();
3749
+ return;
3730
3750
  }
3731
- ws.send(JSON.stringify({ type: 'error', message: errorMsg }));
3732
- ws.close();
3733
- return;
3734
- }
3735
3751
 
3736
- // Create a PTY that attaches to the tmux session
3737
- const ptyProcess = createTmuxAttachPty(sessionInfo.fullName, cols, rows);
3752
+ // Set up event handlers BEFORE connecting (to catch early output like shell prompt)
3753
+ const outputHandler = (sid, data) => {
3754
+ if (sid === session.id && ws.readyState === ws.OPEN) {
3755
+ ws.send(data);
3756
+ }
3757
+ };
3738
3758
 
3739
- wsConnections.set(ws, { pty: ptyProcess, stableId: requestedSession, sessionInfo });
3759
+ const exitHandler = (sid) => {
3760
+ if (sid === session.id && ws.readyState === ws.OPEN) {
3761
+ ws.send(JSON.stringify({ type: 'session_exit' }));
3762
+ // Don't close - allow reconnect
3763
+ }
3764
+ };
3740
3765
 
3741
- // Send session info with current display name (may have been renamed)
3742
- ws.send(
3743
- JSON.stringify({
3744
- type: 'session_info',
3745
- name: sessionInfo.displayName,
3746
- stableId: sessionInfo.stableId,
3747
- })
3748
- );
3766
+ manager.on('session:output', outputHandler);
3767
+ manager.on('session:exit', exitHandler);
3749
3768
 
3750
- ptyProcess.onData((data) => {
3751
- if (ws.readyState === ws.OPEN) {
3752
- ws.send(data);
3753
- }
3754
- });
3769
+ wsConnections.set(ws, { sessionId: session.id, outputHandler, exitHandler });
3755
3770
 
3756
- ptyProcess.onExit(() => {
3757
- if (ws.readyState === ws.OPEN) {
3758
- ws.close();
3771
+ // Connect to session (respawns PTY if needed after server restart)
3772
+ await manager.connectToSession(session.id);
3773
+
3774
+ // Resize to requested dimensions
3775
+ await manager.resizeSession(session.id, cols, rows);
3776
+
3777
+ // Send session info with current display name
3778
+ ws.send(
3779
+ JSON.stringify({
3780
+ type: 'session_info',
3781
+ name: session.displayName,
3782
+ stableId: session.id.slice(0, 8), // Backward compat
3783
+ id: session.id, // Full UUID (new)
3784
+ })
3785
+ );
3786
+
3787
+ // Replay scrollback buffer for reconnecting clients
3788
+ const scrollback = await manager.getScrollback(session.id);
3789
+ if (scrollback) {
3790
+ ws.send(scrollback);
3759
3791
  }
3760
- wsConnections.delete(ws);
3761
- });
3762
3792
 
3763
- ws.on('message', (data) => {
3764
- const message = data.toString('utf8');
3793
+ ws.on('message', (data) => {
3794
+ const message = data.toString('utf8');
3765
3795
 
3766
- if (message.startsWith('{')) {
3767
- try {
3768
- const msg = JSON.parse(message);
3769
- if (msg.type === 'resize') {
3770
- ptyProcess.resize(msg.cols, msg.rows);
3771
- return;
3796
+ if (message.startsWith('{')) {
3797
+ try {
3798
+ const msg = JSON.parse(message);
3799
+ if (msg.type === 'resize') {
3800
+ manager.resizeSession(session.id, msg.cols, msg.rows);
3801
+ return;
3802
+ }
3803
+ } catch (e) {
3804
+ // Not JSON, treat as input
3772
3805
  }
3773
- } catch (e) {
3774
- // Not JSON, treat as input
3775
3806
  }
3776
- }
3777
3807
 
3778
- ptyProcess.write(message);
3779
- });
3808
+ // Write to PTY
3809
+ try {
3810
+ manager.write(session.id, message);
3811
+ } catch {
3812
+ // PTY may have exited, that's ok
3813
+ }
3814
+ });
3780
3815
 
3781
- ws.on('close', () => {
3782
- const conn = wsConnections.get(ws);
3783
- if (conn) {
3784
- conn.pty.kill();
3785
- wsConnections.delete(ws);
3786
- }
3787
- });
3816
+ ws.on('close', () => {
3817
+ const conn = wsConnections.get(ws);
3818
+ if (conn) {
3819
+ manager.off('session:output', conn.outputHandler);
3820
+ manager.off('session:exit', conn.exitHandler);
3821
+ wsConnections.delete(ws);
3822
+ // Note: We do NOT kill the PTY - session persists for reconnection
3823
+ }
3824
+ });
3788
3825
 
3789
- ws.on('error', () => {
3790
- const conn = wsConnections.get(ws);
3791
- if (conn) {
3792
- conn.pty.kill();
3793
- wsConnections.delete(ws);
3794
- }
3795
- });
3826
+ ws.on('error', () => {
3827
+ const conn = wsConnections.get(ws);
3828
+ if (conn) {
3829
+ manager.off('session:output', conn.outputHandler);
3830
+ manager.off('session:exit', conn.exitHandler);
3831
+ wsConnections.delete(ws);
3832
+ }
3833
+ });
3834
+ } catch (err) {
3835
+ console.error('WebSocket connection error:', err);
3836
+ ws.send(JSON.stringify({ type: 'error', message: err.message }));
3837
+ ws.close();
3838
+ }
3796
3839
  });
3797
3840
 
3798
3841
  // ============================================================================