@seflless/ghosttown 1.6.2 → 1.10.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
@@ -8,7 +8,7 @@
8
8
  * ghosttown [options] [command]
9
9
  *
10
10
  * Options:
11
- * -p, --port <port> Port to listen on (default: 8080, or PORT env var)
11
+ * -p, --port <port> Port to listen on (default: auto-discover available port)
12
12
  * -n, --name <name> Give the session a custom name
13
13
  * -k, --kill [session] Kill a session (current if inside one, or specify by name)
14
14
  * -ka, --kill-all Kill all ghosttown sessions
@@ -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);
@@ -73,6 +91,64 @@ const SESSION_EXPIRATION_MS = 3 * 24 * 60 * 60 * 1000;
73
91
  const SESSION_FILE_DIR = path.join(homedir(), '.config', 'ghosttown');
74
92
  const SESSION_FILE_PATH = path.join(SESSION_FILE_DIR, 'sessions.json');
75
93
 
94
+ // Server config file for port discovery (allows CLI to find running server)
95
+ const SERVER_CONFIG_PATH = path.join(SESSION_FILE_DIR, 'server.json');
96
+
97
+ /**
98
+ * Write server config to disk for port discovery
99
+ */
100
+ function writeServerConfig(port) {
101
+ try {
102
+ if (!fs.existsSync(SESSION_FILE_DIR)) {
103
+ fs.mkdirSync(SESSION_FILE_DIR, { recursive: true });
104
+ }
105
+ fs.writeFileSync(
106
+ SERVER_CONFIG_PATH,
107
+ JSON.stringify({ port, pid: process.pid, startedAt: Date.now() }, null, 2)
108
+ );
109
+ } catch (err) {
110
+ console.error('Warning: Failed to write server config:', err.message);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Remove server config file on shutdown
116
+ */
117
+ function removeServerConfig() {
118
+ try {
119
+ if (fs.existsSync(SERVER_CONFIG_PATH)) {
120
+ fs.unlinkSync(SERVER_CONFIG_PATH);
121
+ }
122
+ } catch (err) {
123
+ // Ignore errors during cleanup
124
+ }
125
+ }
126
+
127
+ // Preferred ports to try in order before falling back to auto-discovery (port 0)
128
+ // 64057 - strongest visual mapping to "GHOST"
129
+ // 6057 - subtle and clean
130
+ // 4040 - "ghost server not found" energy (404)
131
+ // 1313 - haunted house number energy
132
+ const PREFERRED_PORTS = [64057, 6057, 4040, 1313];
133
+
134
+ /**
135
+ * Find an available port from the preferred list, or return 0 for auto-discovery
136
+ */
137
+ function findPreferredPort() {
138
+ for (const port of PREFERRED_PORTS) {
139
+ try {
140
+ // Quick check if port is in use using lsof
141
+ execSync(`lsof -i:${port}`, { stdio: 'pipe' });
142
+ // If lsof succeeds, port is in use, try next
143
+ } catch (e) {
144
+ // lsof returns non-zero if port is free - use this port
145
+ return port;
146
+ }
147
+ }
148
+ // All preferred ports in use, fall back to auto-discovery
149
+ return 0;
150
+ }
151
+
76
152
  // In-memory session store: Map<token, { username, createdAt, lastActivity }>
77
153
  const authSessions = new Map();
78
154
 
@@ -422,40 +498,43 @@ function isInsideGhosttownSession() {
422
498
  return sessionName && sessionName.startsWith('gt-');
423
499
  }
424
500
 
501
+ // Note: validateSessionName and displayNameExists are imported from session-utils.js
502
+ // CLI uses getTmuxSessionsForValidation() to adapt tmux sessions to the expected format
503
+ // Web server uses SessionManager directly
504
+
425
505
  /**
426
- * Validate a session name
427
- * Returns { valid: true } or { valid: false, error: string }
506
+ * Parse a ghosttown tmux session name into its components.
507
+ * Format: gt-<stable-id>-<display-name>
508
+ * DEPRECATED: Only used by legacy CLI commands that still use tmux
509
+ *
510
+ * @param {string} fullName - The full tmux session name
511
+ * @returns {{ stableId: string|null, displayName: string, fullName: string }|null}
428
512
  */
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" };
513
+ function parseSessionName(fullName) {
514
+ if (!fullName || !fullName.startsWith('gt-')) {
515
+ return null;
445
516
  }
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)) {
517
+ // Format: gt-<stable-id>-<display-name>
518
+ const match = fullName.match(/^gt-(\d+)-(.+)$/);
519
+ if (match) {
449
520
  return {
450
- valid: false,
451
- error: 'Session name can only contain letters, numbers, hyphens, and underscores',
521
+ stableId: match[1],
522
+ displayName: match[2],
523
+ fullName: fullName,
452
524
  };
453
525
  }
454
- return { valid: true };
526
+ // Legacy format: gt-<name> (no stable ID)
527
+ const legacyName = fullName.slice(3);
528
+ return {
529
+ stableId: null,
530
+ displayName: legacyName,
531
+ fullName: fullName,
532
+ };
455
533
  }
456
534
 
457
535
  /**
458
536
  * Check if a tmux session with the given name exists
537
+ * DEPRECATED: Only used by legacy CLI commands that still use tmux
459
538
  */
460
539
  function sessionExists(sessionName) {
461
540
  try {
@@ -473,10 +552,11 @@ function sessionExists(sessionName) {
473
552
  }
474
553
 
475
554
  /**
476
- * Check if a display name is already used by a ghosttown session
477
- * Display name is the user-facing part after gt-<id>-
555
+ * Get tmux sessions in a format compatible with displayNameExists().
556
+ * Returns array of objects with displayName property.
557
+ * DEPRECATED: Only used by legacy CLI commands that still use tmux
478
558
  */
479
- function displayNameExists(displayName) {
559
+ function getTmuxSessionsForValidation() {
480
560
  try {
481
561
  const output = execSync('tmux list-sessions -F "#{session_name}"', {
482
562
  encoding: 'utf8',
@@ -484,61 +564,54 @@ function displayNameExists(displayName) {
484
564
  });
485
565
  return output
486
566
  .split('\n')
487
- .filter((s) => s.trim())
488
- .some((name) => {
567
+ .filter((name) => name.startsWith('gt-'))
568
+ .map((name) => {
489
569
  const parsed = parseSessionName(name);
490
- return parsed && parsed.displayName === displayName;
570
+ return { displayName: parsed ? parsed.displayName : name.replace('gt-', '') };
491
571
  });
492
572
  } catch (err) {
493
- return false;
573
+ return [];
494
574
  }
495
575
  }
496
576
 
497
577
  /**
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
578
+ * Find a tmux session by display name or stableId.
579
+ * Used by CLI commands (attach, rename, kill) that interact with tmux sessions.
580
+ *
581
+ * @param {string} identifier - Display name or stableId to search for
582
+ * @returns {{ displayName: string, stableId: string|null, fullName: string }|null}
528
583
  */
529
- function findSessionByStableId(stableId) {
584
+ function findSessionByDisplayName(identifier) {
530
585
  try {
531
586
  const output = execSync('tmux list-sessions -F "#{session_name}"', {
532
587
  encoding: 'utf8',
533
588
  stdio: ['pipe', 'pipe', 'pipe'],
534
589
  });
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
- }
590
+
591
+ const sessions = output
592
+ .split('\n')
593
+ .filter((name) => name.startsWith('gt-') && name !== SERVER_SESSION_NAME)
594
+ .map((fullName) => {
595
+ const parsed = parseSessionName(fullName);
596
+ return {
597
+ displayName: parsed ? parsed.displayName : fullName.replace('gt-', ''),
598
+ stableId: parsed ? parsed.stableId : null,
599
+ fullName,
600
+ };
601
+ });
602
+
603
+ // Try exact display name match first
604
+ const byName = sessions.find((s) => s.displayName === identifier);
605
+ if (byName) {
606
+ return byName;
607
+ }
608
+
609
+ // Try stableId match (for backwards compatibility)
610
+ const byId = sessions.find((s) => s.stableId === identifier);
611
+ if (byId) {
612
+ return byId;
541
613
  }
614
+
542
615
  return null;
543
616
  } catch (err) {
544
617
  return null;
@@ -621,22 +694,41 @@ function getServerStatus() {
621
694
  const isRunning = output.split('\n').includes(SERVER_SESSION_NAME);
622
695
 
623
696
  if (!isRunning) {
697
+ // Also clean up stale server.json if tmux session doesn't exist
698
+ removeServerConfig();
624
699
  return { running: false, port: null, useHttp: false };
625
700
  }
626
701
 
627
- // Get the port from tmux environment
628
- let port = 8080;
702
+ // Get the port - prefer server.json (for auto port discovery), fall back to tmux env
703
+ let port = null;
629
704
  try {
630
- const envOutput = execSync(`tmux show-environment -t ${SERVER_SESSION_NAME} GHOSTTOWN_PORT`, {
631
- encoding: 'utf8',
632
- stdio: ['pipe', 'pipe', 'pipe'],
633
- });
634
- const parsedPort = Number.parseInt(envOutput.split('=')[1], 10);
635
- if (!Number.isNaN(parsedPort)) {
636
- port = parsedPort;
705
+ if (fs.existsSync(SERVER_CONFIG_PATH)) {
706
+ const config = JSON.parse(fs.readFileSync(SERVER_CONFIG_PATH, 'utf8'));
707
+ if (config.port) {
708
+ port = config.port;
709
+ }
637
710
  }
638
711
  } catch (e) {
639
- // Use default port
712
+ // Failed to read server.json, try tmux env
713
+ }
714
+
715
+ // Fall back to tmux environment if server.json doesn't have the port
716
+ if (port === null) {
717
+ try {
718
+ const envOutput = execSync(
719
+ `tmux show-environment -t ${SERVER_SESSION_NAME} GHOSTTOWN_PORT`,
720
+ {
721
+ encoding: 'utf8',
722
+ stdio: ['pipe', 'pipe', 'pipe'],
723
+ }
724
+ );
725
+ const parsedPort = Number.parseInt(envOutput.split('=')[1], 10);
726
+ if (!Number.isNaN(parsedPort) && parsedPort > 0) {
727
+ port = parsedPort;
728
+ }
729
+ } catch (e) {
730
+ // No port info available
731
+ }
640
732
  }
641
733
 
642
734
  // Get the protocol from tmux environment (default to HTTPS if not set)
@@ -766,7 +858,7 @@ function createTmuxSession(command, customName = null) {
766
858
  process.exit(1);
767
859
  }
768
860
  // Check if display name already exists
769
- if (displayNameExists(customName)) {
861
+ if (displayNameExists(getTmuxSessionsForValidation(), customName)) {
770
862
  console.log('');
771
863
  console.log(` ${RED}Error:${RESET} Session '${customName}' already exists.`);
772
864
  console.log('');
@@ -937,32 +1029,13 @@ function attachToSession(sessionName) {
937
1029
  const RED = '\x1b[31m';
938
1030
  const BEIGE = '\x1b[38;2;255;220;150m';
939
1031
 
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) {
1032
+ // Look up session by display name
1033
+ const found = findSessionByDisplayName(sessionName);
1034
+ if (!found) {
962
1035
  console.log('');
963
- console.log(` ${RED}Error:${RESET} No tmux sessions found.`);
1036
+ console.log(` ${RED}Error:${RESET} Session '${sessionName}' not found.`);
964
1037
  console.log('');
965
- console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
1038
+ listSessionsInline();
966
1039
  console.log('');
967
1040
  process.exit(1);
968
1041
  }
@@ -971,7 +1044,7 @@ function attachToSession(sessionName) {
971
1044
  // Use the same pattern as detachFromTmux which works
972
1045
  const result = spawnSync(
973
1046
  process.env.SHELL || '/bin/sh',
974
- ['-c', `tmux attach-session -t ${sessionName}`],
1047
+ ['-c', `tmux attach-session -t ${found.fullName}`],
975
1048
  {
976
1049
  stdio: 'inherit',
977
1050
  }
@@ -1087,57 +1160,57 @@ function killSession(sessionName) {
1087
1160
  }
1088
1161
 
1089
1162
  sessionName = getCurrentTmuxSessionName();
1090
- }
1091
-
1092
- // Add gt- prefix if not present
1093
- if (!sessionName.startsWith('gt-')) {
1094
- sessionName = `gt-${sessionName}`;
1095
- }
1163
+ // sessionName is now the full tmux name (e.g., gt-12-13)
1164
+ const parsed = parseSessionName(sessionName);
1096
1165
 
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
- });
1166
+ // Kill the session
1167
+ try {
1168
+ execSync(`tmux kill-session -t ${sessionName}`, {
1169
+ stdio: 'pipe',
1170
+ });
1103
1171
 
1104
- const sessions = output.split('\n').filter((s) => s.trim());
1105
- if (!sessions.includes(sessionName)) {
1172
+ const displayName = parsed ? parsed.displayName : sessionName.replace('gt-', '');
1106
1173
  console.log('');
1107
- console.log(` ${RED}Error:${RESET} Session '${sessionName}' not found.`);
1174
+ console.log(` ${BOLD_YELLOW}Session ${displayName} has been killed.${RESET}`);
1108
1175
  console.log('');
1109
- console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
1176
+ console.log(` ${CYAN}To list remaining:${RESET} ${BEIGE}gt list${RESET}`);
1177
+ console.log('');
1178
+ process.exit(0);
1179
+ } catch (err) {
1180
+ const displayName = parsed ? parsed.displayName : sessionName.replace('gt-', '');
1181
+ console.log('');
1182
+ console.log(` ${RED}Error:${RESET} Failed to kill session '${displayName}'.`);
1110
1183
  console.log('');
1111
1184
  process.exit(1);
1112
1185
  }
1113
- } catch (err) {
1186
+ }
1187
+
1188
+ // User provided a session name - look it up by display name
1189
+ const found = findSessionByDisplayName(sessionName);
1190
+ if (!found) {
1114
1191
  console.log('');
1115
- console.log(` ${RED}Error:${RESET} No tmux sessions found.`);
1192
+ console.log(` ${RED}Error:${RESET} Session '${sessionName}' not found.`);
1116
1193
  console.log('');
1117
- console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
1194
+ listSessionsInline();
1118
1195
  console.log('');
1119
1196
  process.exit(1);
1120
1197
  }
1121
1198
 
1122
1199
  // Kill the session
1123
1200
  try {
1124
- execSync(`tmux kill-session -t ${sessionName}`, {
1201
+ execSync(`tmux kill-session -t ${found.fullName}`, {
1125
1202
  stdio: 'pipe',
1126
1203
  });
1127
1204
 
1128
- // Extract display name from session name
1129
- const parsed = parseSessionName(sessionName);
1130
- const displayName = parsed ? parsed.displayName : sessionName.replace('gt-', '');
1131
-
1132
1205
  console.log('');
1133
- console.log(` ${BOLD_YELLOW}Session ${displayName} has been killed.${RESET}`);
1206
+ console.log(` ${BOLD_YELLOW}Session ${found.displayName} has been killed.${RESET}`);
1134
1207
  console.log('');
1135
1208
  console.log(` ${CYAN}To list remaining:${RESET} ${BEIGE}gt list${RESET}`);
1136
1209
  console.log('');
1137
1210
  process.exit(0);
1138
1211
  } catch (err) {
1139
1212
  console.log('');
1140
- console.log(` ${RED}Error:${RESET} Failed to kill session '${sessionName}'.`);
1213
+ console.log(` ${RED}Error:${RESET} Failed to kill session '${found.displayName}'.`);
1141
1214
  console.log('');
1142
1215
  process.exit(1);
1143
1216
  }
@@ -1205,7 +1278,7 @@ function killAllSessions() {
1205
1278
  * @param {number} port - Port to listen on
1206
1279
  * @param {boolean} useHttp - Use HTTP instead of HTTPS (default: false, meaning HTTPS is used)
1207
1280
  */
1208
- function startServerInBackground(port = 8080, useHttp = false) {
1281
+ async function startServerInBackground(port = 0, useHttp = false) {
1209
1282
  // Check if tmux is installed
1210
1283
  if (!checkTmuxInstalled()) {
1211
1284
  printTmuxInstallHelp();
@@ -1223,7 +1296,7 @@ function startServerInBackground(port = 8080, useHttp = false) {
1223
1296
  console.log('');
1224
1297
  console.log(` ${RED}Error:${RESET} A server is already running (port ${status.port}).`);
1225
1298
  console.log('');
1226
- if (status.port !== port) {
1299
+ if (port !== 0 && status.port !== port) {
1227
1300
  console.log(` To switch to port ${port}:`);
1228
1301
  console.log(` ${BEIGE}gt stop && gt start -p ${port}${RESET}`);
1229
1302
  } else {
@@ -1234,18 +1307,20 @@ function startServerInBackground(port = 8080, useHttp = false) {
1234
1307
  process.exit(1);
1235
1308
  }
1236
1309
 
1237
- // Check if port is already in use by another process
1238
- try {
1239
- execSync(`lsof -i:${port}`, { stdio: 'pipe' });
1240
- // If lsof succeeds, port is in use
1241
- console.log('');
1242
- console.log(` ${RED}Error:${RESET} Port ${port} is already in use.`);
1243
- console.log('');
1244
- console.log(` Try a different port: ${BEIGE}gt start -p <port>${RESET}`);
1245
- console.log('');
1246
- process.exit(1);
1247
- } catch (e) {
1248
- // lsof returns non-zero if port is free - this is what we want
1310
+ // Check if specific port is already in use by another process (skip for port 0 auto-discovery)
1311
+ if (port !== 0) {
1312
+ try {
1313
+ execSync(`lsof -i:${port}`, { stdio: 'pipe' });
1314
+ // If lsof succeeds, port is in use
1315
+ console.log('');
1316
+ console.log(` ${RED}Error:${RESET} Port ${port} is already in use.`);
1317
+ console.log('');
1318
+ console.log(` Try a different port: ${BEIGE}gt start -p <port>${RESET}`);
1319
+ console.log('');
1320
+ process.exit(1);
1321
+ } catch (e) {
1322
+ // lsof returns non-zero if port is free - this is what we want
1323
+ }
1249
1324
  }
1250
1325
 
1251
1326
  try {
@@ -1284,6 +1359,27 @@ function startServerInBackground(port = 8080, useHttp = false) {
1284
1359
  { stdio: 'pipe' }
1285
1360
  );
1286
1361
 
1362
+ // Wait for server to start and get actual port from server.json
1363
+ // The server writes to server.json once it starts listening
1364
+ let actualPort = port;
1365
+ let attempts = 0;
1366
+ const maxAttempts = 30; // 3 seconds max wait
1367
+ while (attempts < maxAttempts) {
1368
+ try {
1369
+ if (fs.existsSync(SERVER_CONFIG_PATH)) {
1370
+ const config = JSON.parse(fs.readFileSync(SERVER_CONFIG_PATH, 'utf8'));
1371
+ if (config.port) {
1372
+ actualPort = config.port;
1373
+ break;
1374
+ }
1375
+ }
1376
+ } catch (e) {
1377
+ // Ignore read errors, keep waiting
1378
+ }
1379
+ await new Promise((resolve) => setTimeout(resolve, 100));
1380
+ attempts++;
1381
+ }
1382
+
1287
1383
  // Get local IPs for display
1288
1384
  const localIPs = getLocalIPs();
1289
1385
  const protocol = useHttp ? 'http' : 'https';
@@ -1291,9 +1387,11 @@ function startServerInBackground(port = 8080, useHttp = false) {
1291
1387
  console.log('');
1292
1388
  console.log(` ${BOLD_YELLOW}Ghosttown server started!${RESET}`);
1293
1389
  console.log('');
1294
- console.log(` ${CYAN}Open:${RESET} ${BEIGE}${protocol}://localhost:${port}${RESET}`);
1390
+ console.log(` ${CYAN}Open:${RESET} ${BEIGE}${protocol}://localhost:${actualPort}${RESET}`);
1295
1391
  if (localIPs.length > 0) {
1296
- console.log(` ${CYAN}Network:${RESET} ${BEIGE}${protocol}://${localIPs[0]}:${port}${RESET}`);
1392
+ console.log(
1393
+ ` ${CYAN}Network:${RESET} ${BEIGE}${protocol}://${localIPs[0]}:${actualPort}${RESET}`
1394
+ );
1297
1395
  }
1298
1396
  console.log('');
1299
1397
  console.log(` ${CYAN}To stop:${RESET} ${BEIGE}gt stop${RESET}`);
@@ -1314,38 +1412,53 @@ function startServerInBackground(port = 8080, useHttp = false) {
1314
1412
  * Stop the background server
1315
1413
  */
1316
1414
  function stopServer() {
1317
- // Check if tmux is installed
1318
- if (!checkTmuxInstalled()) {
1319
- printTmuxInstallHelp();
1320
- }
1321
-
1322
1415
  const RESET = '\x1b[0m';
1323
1416
  const RED = '\x1b[31m';
1324
1417
  const BOLD_YELLOW = '\x1b[1;93m';
1325
1418
  const BEIGE = '\x1b[38;2;255;220;150m';
1326
1419
 
1327
- const status = getServerStatus();
1420
+ let stopped = false;
1328
1421
 
1329
- if (!status.running) {
1330
- console.log('');
1331
- console.log(` ${BEIGE}No server is currently running.${RESET}`);
1332
- console.log('');
1333
- console.log(' To start: gt start');
1334
- console.log('');
1335
- process.exit(0);
1422
+ // First, try to stop using PID from server.json (works for foreground servers)
1423
+ try {
1424
+ if (fs.existsSync(SERVER_CONFIG_PATH)) {
1425
+ const config = JSON.parse(fs.readFileSync(SERVER_CONFIG_PATH, 'utf8'));
1426
+ if (config.pid) {
1427
+ try {
1428
+ process.kill(config.pid, 'SIGTERM');
1429
+ stopped = true;
1430
+ } catch (e) {
1431
+ // Process doesn't exist or can't be killed
1432
+ }
1433
+ }
1434
+ }
1435
+ } catch (e) {
1436
+ // Ignore errors reading config
1336
1437
  }
1337
1438
 
1338
- try {
1339
- execSync(`tmux kill-session -t ${SERVER_SESSION_NAME}`, { stdio: 'pipe' });
1439
+ // Also try to kill tmux session (for background servers started with `gt start`)
1440
+ if (checkTmuxInstalled()) {
1441
+ try {
1442
+ execSync(`tmux kill-session -t ${SERVER_SESSION_NAME}`, { stdio: 'pipe' });
1443
+ stopped = true;
1444
+ } catch (err) {
1445
+ // tmux session doesn't exist or can't be killed
1446
+ }
1447
+ }
1340
1448
 
1449
+ // Clean up server.json
1450
+ removeServerConfig();
1451
+
1452
+ if (stopped) {
1341
1453
  console.log('');
1342
1454
  console.log(` ${BOLD_YELLOW}Server stopped.${RESET}`);
1343
1455
  console.log('');
1344
- } catch (err) {
1456
+ } else {
1345
1457
  console.log('');
1346
- console.log(` ${RED}Error:${RESET} Failed to stop server.`);
1458
+ console.log(` ${BEIGE}No server is currently running.${RESET}`);
1459
+ console.log('');
1460
+ console.log(' To start: gt start');
1347
1461
  console.log('');
1348
- process.exit(1);
1349
1462
  }
1350
1463
 
1351
1464
  process.exit(0);
@@ -1442,29 +1555,6 @@ function listSessionsInline() {
1442
1555
  }
1443
1556
  }
1444
1557
 
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
1558
  /**
1469
1559
  * Rename a ghosttown session
1470
1560
  * Usage:
@@ -1540,7 +1630,7 @@ function renameSession(renameArgs) {
1540
1630
  }
1541
1631
 
1542
1632
  // Check new display name doesn't conflict
1543
- if (displayNameExists(newDisplayName)) {
1633
+ if (displayNameExists(getTmuxSessionsForValidation(), newDisplayName)) {
1544
1634
  console.log('');
1545
1635
  console.log(` ${RED}Error:${RESET} Session '${newDisplayName}' already exists.`);
1546
1636
  console.log('');
@@ -1571,7 +1661,7 @@ function renameSession(renameArgs) {
1571
1661
  // Parse CLI arguments
1572
1662
  // ============================================================================
1573
1663
 
1574
- function parseArgs(argv) {
1664
+ async function parseArgs(argv) {
1575
1665
  const args = argv.slice(2);
1576
1666
  let port = null;
1577
1667
  let command = null;
@@ -1589,7 +1679,7 @@ function parseArgs(argv) {
1589
1679
  Usage: ghosttown [options] [command]
1590
1680
 
1591
1681
  Options:
1592
- -p, --port <port> Port to listen on (default: 8080, or PORT env var)
1682
+ -p, --port <port> Port to listen on (default: auto-discover available port)
1593
1683
  -n, --name <name> Give the session a custom name (use with a command)
1594
1684
  --http Use HTTP instead of HTTPS (default is HTTPS)
1595
1685
  --no-auth Disable authentication (binds to localhost only)
@@ -1599,7 +1689,7 @@ Options:
1599
1689
  -h, --help Show this help message
1600
1690
 
1601
1691
  Commands:
1602
- start [-p <port>] Start the server in the background (default port: 8080)
1692
+ start [-p <port>] Start the server in the background (auto-discovers available port)
1603
1693
  stop Stop the background server
1604
1694
  status Show server status and URLs
1605
1695
  list List all ghosttown tmux sessions
@@ -1672,8 +1762,7 @@ Aliases:
1672
1762
  if (!sessionArg) {
1673
1763
  console.error('Error: attach requires a session name or ID');
1674
1764
  console.error('Usage: gt attach <name|id>');
1675
- handled = true;
1676
- break;
1765
+ process.exit(1);
1677
1766
  }
1678
1767
  handled = true;
1679
1768
  attachToSession(sessionArg);
@@ -1708,7 +1797,7 @@ Aliases:
1708
1797
  // Handle start command
1709
1798
  else if (arg === 'start') {
1710
1799
  // Parse remaining args after 'start' for -p and --http flags
1711
- let startPort = 8080;
1800
+ let startPort = findPreferredPort(); // Try preferred ports, fall back to auto-discovery
1712
1801
  let startUseHttp = false;
1713
1802
  const remainingArgs = args.slice(i + 1);
1714
1803
  for (let j = 0; j < remainingArgs.length; j++) {
@@ -1728,7 +1817,7 @@ Aliases:
1728
1817
  }
1729
1818
  }
1730
1819
  handled = true;
1731
- startServerInBackground(startPort, startUseHttp);
1820
+ await startServerInBackground(startPort, startUseHttp);
1732
1821
  // startServerInBackground exits, so this won't be reached
1733
1822
  }
1734
1823
 
@@ -1800,7 +1889,41 @@ Aliases:
1800
1889
  // ============================================================================
1801
1890
 
1802
1891
  function startWebServer(cliArgs) {
1803
- const HTTP_PORT = cliArgs.port || process.env.PORT || 8080;
1892
+ // ANSI color codes for formatted output
1893
+ const RESET = '\x1b[0m';
1894
+ const RED = '\x1b[31m';
1895
+ const CYAN = '\x1b[36m';
1896
+ const BEIGE = '\x1b[38;2;255;220;150m';
1897
+
1898
+ // Check if a server is already running (prevents multiple instances)
1899
+ try {
1900
+ if (fs.existsSync(SERVER_CONFIG_PATH)) {
1901
+ const config = JSON.parse(fs.readFileSync(SERVER_CONFIG_PATH, 'utf8'));
1902
+ // Check if the PID is still alive
1903
+ if (config.pid) {
1904
+ try {
1905
+ process.kill(config.pid, 0); // Signal 0 = just check if process exists
1906
+ // Process exists - server already running
1907
+ console.log('');
1908
+ console.log(` ${RED}Error:${RESET} A gt server is already running.`);
1909
+ console.log('');
1910
+ console.log(` ${CYAN}To stop:${RESET} ${BEIGE}gt stop${RESET}`);
1911
+ console.log(` ${CYAN}To check:${RESET} ${BEIGE}gt status${RESET}`);
1912
+ console.log('');
1913
+ process.exit(1);
1914
+ } catch (e) {
1915
+ // Process doesn't exist - stale config, clean it up
1916
+ removeServerConfig();
1917
+ }
1918
+ }
1919
+ }
1920
+ } catch (e) {
1921
+ // Ignore errors reading config
1922
+ }
1923
+
1924
+ // Try preferred ports (64057, 6057, 4040) in order, fall back to auto-discovery (port 0)
1925
+ // if all are in use. User can still override with -p flag or PORT env var.
1926
+ const HTTP_PORT = cliArgs.port || process.env.PORT || findPreferredPort();
1804
1927
  const USE_HTTPS = !cliArgs.useHttp;
1805
1928
  const NO_AUTH = cliArgs.noAuth || false;
1806
1929
  const backgroundMode = cliArgs.serverBackgroundMode || false;
@@ -1978,7 +2101,15 @@ function startWebServer(cliArgs) {
1978
2101
  color: #e5e5e5;
1979
2102
  font-size: 22px;
1980
2103
  font-weight: 600;
1981
- margin-bottom: 12px;
2104
+ margin-bottom: 4px;
2105
+ text-align: center;
2106
+ }
2107
+
2108
+ .session-card .subtitle {
2109
+ color: #888;
2110
+ font-size: 13px;
2111
+ font-weight: 400;
2112
+ margin-bottom: 16px;
1982
2113
  text-align: center;
1983
2114
  }
1984
2115
 
@@ -2382,6 +2513,7 @@ function startWebServer(cliArgs) {
2382
2513
  <div class="session-list-view" id="session-list-view">
2383
2514
  <div class="session-card">
2384
2515
  <h1>Ghost Town</h1>
2516
+ <p class="subtitle">Choose a terminal to connect to</p>
2385
2517
  <div class="error-message" id="error-message"></div>
2386
2518
  <div class="session-table-header" id="session-table-header" style="display: none;">
2387
2519
  <span class="header-name">Name</span>
@@ -2454,13 +2586,15 @@ function startWebServer(cliArgs) {
2454
2586
  }
2455
2587
 
2456
2588
  // Get session info from URL query parameters
2457
- // Returns { name, stableId } or null
2589
+ // Returns { sessionId, name, stableId } or null
2590
+ // Supports both new (?sessionId=uuid) and legacy (?session=name&id=stableId) formats
2458
2591
  function getSessionFromUrl() {
2459
2592
  const params = new URLSearchParams(window.location.search);
2593
+ const sessionId = params.get('sessionId');
2460
2594
  const name = params.get('session');
2461
2595
  const stableId = params.get('id');
2462
- if (name || stableId) {
2463
- return { name, stableId };
2596
+ if (sessionId || name || stableId) {
2597
+ return { sessionId, name, stableId };
2464
2598
  }
2465
2599
  return null;
2466
2600
  }
@@ -2469,9 +2603,13 @@ function startWebServer(cliArgs) {
2469
2603
  async function initApp() {
2470
2604
  const sessionInfo = getSessionFromUrl();
2471
2605
 
2472
- if (sessionInfo && sessionInfo.stableId) {
2473
- // Show terminal view using stable ID
2474
- requestedSessionName = sessionInfo.name; // Store for error messages
2606
+ if (sessionInfo && sessionInfo.sessionId) {
2607
+ // New format: use UUID sessionId
2608
+ requestedSessionName = sessionInfo.name || sessionInfo.sessionId;
2609
+ showTerminalView(sessionInfo.sessionId);
2610
+ } else if (sessionInfo && sessionInfo.stableId) {
2611
+ // Legacy format: use stable ID
2612
+ requestedSessionName = sessionInfo.name;
2475
2613
  showTerminalView(sessionInfo.stableId);
2476
2614
  } else if (sessionInfo && sessionInfo.name) {
2477
2615
  // Legacy URL with just name - show terminal view
@@ -3356,15 +3494,22 @@ function startWebServer(cliArgs) {
3356
3494
  res.end();
3357
3495
  return;
3358
3496
  }
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
- }
3497
+ (async () => {
3498
+ try {
3499
+ const manager = await getSessionManager();
3500
+ // Create session without starting PTY - it will start on first WebSocket connect
3501
+ const newSession = await manager.createSession({ startProcess: false });
3502
+ // Use sessionId for new format, keep session param for backward compat
3503
+ res.writeHead(302, {
3504
+ Location: `/?sessionId=${newSession.id}&session=${encodeURIComponent(newSession.displayName)}`,
3505
+ });
3506
+ res.end();
3507
+ } catch (err) {
3508
+ console.error('Failed to create session:', err);
3509
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
3510
+ res.end('Failed to create session');
3511
+ }
3512
+ })();
3368
3513
  return;
3369
3514
  }
3370
3515
 
@@ -3403,7 +3548,7 @@ function startWebServer(cliArgs) {
3403
3548
  return;
3404
3549
  }
3405
3550
 
3406
- // API: List sessions (from tmux) - requires authentication
3551
+ // API: List sessions - requires authentication
3407
3552
  if (pathname === '/api/sessions' && req.method === 'GET') {
3408
3553
  if (!session) {
3409
3554
  res.writeHead(401, { 'Content-Type': 'application/json' });
@@ -3411,40 +3556,31 @@ function startWebServer(cliArgs) {
3411
3556
  return;
3412
3557
  }
3413
3558
  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
- });
3559
+ const manager = await getSessionManager();
3560
+ const sessionList = await manager.listSessions();
3561
+
3562
+ // Map to API response format with backward-compatible fields
3563
+ const response = sessionList.map((s) => ({
3564
+ name: s.displayName,
3565
+ stableId: s.id.slice(0, 8), // Short ID for backward compat
3566
+ id: s.id, // Full UUID (new)
3567
+ lastActivity: s.lastActivity,
3568
+ attached: s.connectionCount > 0, // Backward compat: attached means has connections
3569
+ isRunning: s.isRunning,
3570
+ connectionCount: s.connectionCount,
3571
+ }));
3434
3572
 
3435
- // Sort by recent activity
3436
- sessionList.sort((a, b) => b.lastActivity - a.lastActivity);
3437
3573
  res.writeHead(200, { 'Content-Type': 'application/json' });
3438
- res.end(JSON.stringify({ sessions: sessionList }));
3574
+ res.end(JSON.stringify({ sessions: response }));
3439
3575
  } catch (err) {
3440
- // No tmux sessions or tmux not running
3576
+ console.error('Error listing sessions:', err);
3441
3577
  res.writeHead(200, { 'Content-Type': 'application/json' });
3442
3578
  res.end(JSON.stringify({ sessions: [] }));
3443
3579
  }
3444
3580
  return;
3445
3581
  }
3446
3582
 
3447
- // API: Create session (tmux session) - requires authentication
3583
+ // API: Create session - requires authentication
3448
3584
  if (pathname === '/api/sessions/create' && req.method === 'POST') {
3449
3585
  if (!session) {
3450
3586
  res.writeHead(401, { 'Content-Type': 'application/json' });
@@ -3452,10 +3588,18 @@ function startWebServer(cliArgs) {
3452
3588
  return;
3453
3589
  }
3454
3590
  try {
3455
- const { displayNumber, stableId } = createWebSession();
3591
+ const manager = await getSessionManager();
3592
+ const newSession = await manager.createSession();
3456
3593
  res.writeHead(200, { 'Content-Type': 'application/json' });
3457
- res.end(JSON.stringify({ name: String(displayNumber), stableId: stableId }));
3594
+ res.end(
3595
+ JSON.stringify({
3596
+ name: newSession.displayName,
3597
+ stableId: newSession.id.slice(0, 8), // Short ID for backward compat
3598
+ id: newSession.id, // Full UUID (new)
3599
+ })
3600
+ );
3458
3601
  } catch (err) {
3602
+ console.error('Error creating session:', err);
3459
3603
  res.writeHead(500, { 'Content-Type': 'application/json' });
3460
3604
  res.end(JSON.stringify({ error: 'Failed to create session' }));
3461
3605
  }
@@ -3472,85 +3616,123 @@ function startWebServer(cliArgs) {
3472
3616
 
3473
3617
  const cols = Number.parseInt(url.searchParams.get('cols') || '80');
3474
3618
  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)
3619
+ // Support both new (sessionId) and legacy (session) parameters
3620
+ const sessionIdParam = url.searchParams.get('sessionId');
3621
+ const legacySession = url.searchParams.get('session');
3622
+ const requestedName = url.searchParams.get('name');
3623
+ const identifier = sessionIdParam || legacySession;
3477
3624
 
3478
- if (!requestedSession) {
3625
+ if (!identifier) {
3479
3626
  res.writeHead(400, { 'Content-Type': 'application/json' });
3480
3627
  res.end(JSON.stringify({ error: 'Session parameter required' }));
3481
3628
  return;
3482
3629
  }
3483
3630
 
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}`;
3631
+ try {
3632
+ const manager = await getSessionManager();
3633
+
3634
+ // Look up session by UUID, short ID, or display name
3635
+ const sessionObj = await findSessionForWeb(identifier);
3636
+ if (!sessionObj) {
3637
+ let errorMsg;
3638
+ if (requestedName && requestedName !== identifier) {
3639
+ errorMsg = `No session found named ${requestedName}, or with id ${identifier}`;
3640
+ } else {
3641
+ errorMsg = `No session found named ${identifier} or with id ${identifier}`;
3642
+ }
3643
+ res.writeHead(404, { 'Content-Type': 'application/json' });
3644
+ res.end(JSON.stringify({ error: errorMsg }));
3645
+ return;
3496
3646
  }
3497
- res.writeHead(404, { 'Content-Type': 'application/json' });
3498
- res.end(JSON.stringify({ error: errorMsg }));
3499
- return;
3500
- }
3501
3647
 
3502
- // Generate unique connection ID
3503
- const connectionId = `sse-${Date.now()}-${Math.random().toString(36).slice(2)}`;
3648
+ // Generate unique connection ID
3649
+ const connectionId = `sse-${Date.now()}-${Math.random().toString(36).slice(2)}`;
3504
3650
 
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
- });
3651
+ // Set up SSE headers first
3652
+ res.writeHead(200, {
3653
+ 'Content-Type': 'text/event-stream',
3654
+ 'Cache-Control': 'no-cache',
3655
+ Connection: 'keep-alive',
3656
+ 'X-SSE-Connection-Id': connectionId,
3657
+ });
3512
3658
 
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
- );
3659
+ // Send session info as first event
3660
+ res.write(
3661
+ `data: ${JSON.stringify({ type: 'session_info', name: sessionObj.displayName, stableId: sessionObj.id.slice(0, 8), id: sessionObj.id, connectionId })}\n\n`
3662
+ );
3517
3663
 
3518
- // Create PTY attached to tmux session
3519
- const ptyProcess = createTmuxAttachPty(sessionInfo.fullName, cols, rows);
3664
+ // Timeout for closing connection after session exit (30 seconds)
3665
+ const SSE_EXIT_TIMEOUT_MS = 30000;
3666
+ let exitTimeout = null;
3667
+
3668
+ // Set up event handlers BEFORE connecting (to catch early output like shell prompt)
3669
+ const outputHandler = (sid, data) => {
3670
+ if (sid === sessionObj.id && !res.writableEnded) {
3671
+ // Clear exit timeout if session produces output (it restarted)
3672
+ if (exitTimeout) {
3673
+ clearTimeout(exitTimeout);
3674
+ exitTimeout = null;
3675
+ }
3676
+ const encoded = Buffer.from(data).toString('base64');
3677
+ res.write(`data: ${JSON.stringify({ type: 'output', data: encoded })}\n\n`);
3678
+ }
3679
+ };
3520
3680
 
3521
- // Store connection
3522
- sseConnections.set(connectionId, {
3523
- res,
3524
- pty: ptyProcess,
3525
- stableId: requestedSession,
3526
- sessionInfo,
3527
- });
3681
+ const exitHandler = (sid) => {
3682
+ if (sid === sessionObj.id && !res.writableEnded) {
3683
+ res.write(`data: ${JSON.stringify({ type: 'exit' })}\n\n`);
3684
+ // Set timeout to close connection if session doesn't restart
3685
+ exitTimeout = setTimeout(() => {
3686
+ if (!res.writableEnded) {
3687
+ res.write(
3688
+ `data: ${JSON.stringify({ type: 'timeout', message: 'Session exited and did not restart' })}\n\n`
3689
+ );
3690
+ res.end();
3691
+ const conn = sseConnections.get(connectionId);
3692
+ if (conn) {
3693
+ manager.off('session:output', conn.outputHandler);
3694
+ manager.off('session:exit', conn.exitHandler);
3695
+ sseConnections.delete(connectionId);
3696
+ }
3697
+ }
3698
+ }, SSE_EXIT_TIMEOUT_MS);
3699
+ }
3700
+ };
3528
3701
 
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
- });
3702
+ manager.on('session:output', outputHandler);
3703
+ manager.on('session:exit', exitHandler);
3537
3704
 
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
- });
3705
+ // Store connection
3706
+ sseConnections.set(connectionId, {
3707
+ res,
3708
+ sessionId: sessionObj.id,
3709
+ outputHandler,
3710
+ exitHandler,
3711
+ exitTimeout: () => exitTimeout, // Getter for cleanup
3712
+ });
3545
3713
 
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
- });
3714
+ // Connect to session (respawns PTY if needed) - after handlers are set up
3715
+ await manager.connectToSession(sessionObj.id);
3716
+ await manager.resizeSession(sessionObj.id, cols, rows);
3717
+
3718
+ // Handle client disconnect
3719
+ req.on('close', () => {
3720
+ const conn = sseConnections.get(connectionId);
3721
+ if (conn) {
3722
+ // Clear any pending exit timeout
3723
+ const timeout = conn.exitTimeout?.();
3724
+ if (timeout) clearTimeout(timeout);
3725
+ manager.off('session:output', conn.outputHandler);
3726
+ manager.off('session:exit', conn.exitHandler);
3727
+ sseConnections.delete(connectionId);
3728
+ // Note: We do NOT kill the PTY - session persists for reconnection
3729
+ }
3730
+ });
3731
+ } catch (err) {
3732
+ console.error('SSE connection error:', err);
3733
+ res.writeHead(500, { 'Content-Type': 'application/json' });
3734
+ res.end(JSON.stringify({ error: err.message }));
3735
+ }
3554
3736
 
3555
3737
  return;
3556
3738
  }
@@ -3567,7 +3749,7 @@ function startWebServer(cliArgs) {
3567
3749
  req.on('data', (chunk) => {
3568
3750
  body += chunk;
3569
3751
  });
3570
- req.on('end', () => {
3752
+ req.on('end', async () => {
3571
3753
  try {
3572
3754
  const { connectionId, data, type, cols, rows } = JSON.parse(body);
3573
3755
 
@@ -3578,12 +3760,14 @@ function startWebServer(cliArgs) {
3578
3760
  return;
3579
3761
  }
3580
3762
 
3763
+ const manager = await getSessionManager();
3764
+
3581
3765
  if (type === 'resize' && cols && rows) {
3582
- conn.pty.resize(cols, rows);
3766
+ await manager.resizeSession(conn.sessionId, cols, rows);
3583
3767
  } else if (data) {
3584
3768
  // Decode base64 input
3585
3769
  const decoded = Buffer.from(data, 'base64').toString('utf8');
3586
- conn.pty.write(decoded);
3770
+ manager.write(conn.sessionId, decoded);
3587
3771
  }
3588
3772
 
3589
3773
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -3620,56 +3804,35 @@ function startWebServer(cliArgs) {
3620
3804
  // WebSocket Server
3621
3805
  // ============================================================================
3622
3806
 
3623
- // Track active WebSocket connections to tmux sessions
3624
- // ws -> { pty, sessionName }
3807
+ // Track active WebSocket connections to sessions
3808
+ // ws -> { sessionId, outputHandler, exitHandler }
3625
3809
  const wsConnections = new Map();
3626
3810
 
3627
3811
  // Track active SSE connections (fallback for mobile)
3628
- // connectionId -> { res, pty, stableId, sessionInfo }
3812
+ // connectionId -> { res, sessionId, outputHandler, exitHandler }
3629
3813
  const sseConnections = new Map();
3630
3814
 
3631
3815
  /**
3632
- * Find a ghosttown session by stable ID (for web connections)
3633
- * Returns { fullName, displayName, stableId } or null if not found
3816
+ * Find a session by various identifiers (UUID, short ID, or display name)
3817
+ * Returns session object or null if not found
3634
3818
  */
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
- }
3819
+ async function findSessionForWeb(identifier) {
3820
+ const manager = await getSessionManager();
3821
+ const sessions = await manager.listSessions();
3653
3822
 
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
- });
3823
+ // Try exact UUID match first
3824
+ let session = sessions.find((s) => s.id === identifier);
3825
+ if (session) return manager.getSession(session.id);
3671
3826
 
3672
- return ptyProcess;
3827
+ // Try short ID prefix match
3828
+ session = sessions.find((s) => s.id.startsWith(identifier));
3829
+ if (session) return manager.getSession(session.id);
3830
+
3831
+ // Try display name match
3832
+ session = sessions.find((s) => s.displayName === identifier);
3833
+ if (session) return manager.getSession(session.id);
3834
+
3835
+ return null;
3673
3836
  }
3674
3837
 
3675
3838
  const wss = new WebSocketServer({ noServer: true });
@@ -3700,106 +3863,137 @@ function startWebServer(cliArgs) {
3700
3863
  }
3701
3864
  });
3702
3865
 
3703
- wss.on('connection', (ws, req) => {
3866
+ wss.on('connection', async (ws, req) => {
3704
3867
  const url = new URL(req.url, `http://${req.headers.host}`);
3705
3868
  const cols = Number.parseInt(url.searchParams.get('cols') || '80');
3706
3869
  const rows = Number.parseInt(url.searchParams.get('rows') || '24');
3707
- const requestedSession = url.searchParams.get('session');
3870
+
3871
+ // Support both new (sessionId) and legacy (session) parameters
3872
+ const sessionId = url.searchParams.get('sessionId');
3873
+ const legacySession = url.searchParams.get('session');
3708
3874
  const requestedName = url.searchParams.get('name'); // Original name from URL (if different from session)
3875
+ const identifier = sessionId || legacySession;
3709
3876
 
3710
- // Session parameter is required (should be stable ID)
3711
- if (!requestedSession) {
3877
+ // Session parameter is required
3878
+ if (!identifier) {
3712
3879
  ws.send(JSON.stringify({ type: 'error', message: 'Session parameter required' }));
3713
3880
  ws.close();
3714
3881
  return;
3715
3882
  }
3716
3883
 
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}`;
3884
+ try {
3885
+ const manager = await getSessionManager();
3886
+
3887
+ // Look up session by UUID, short ID, or display name
3888
+ const session = await findSessionForWeb(identifier);
3889
+
3890
+ if (!session) {
3891
+ // Build error message with both name and id if available
3892
+ let errorMsg;
3893
+ if (requestedName && requestedName !== identifier) {
3894
+ errorMsg = `No session found named ${requestedName}, or with id ${identifier}`;
3895
+ } else {
3896
+ errorMsg = `No session found named ${identifier} or with id ${identifier}`;
3897
+ }
3898
+ ws.send(JSON.stringify({ type: 'error', message: errorMsg }));
3899
+ ws.close();
3900
+ return;
3730
3901
  }
3731
- ws.send(JSON.stringify({ type: 'error', message: errorMsg }));
3732
- ws.close();
3733
- return;
3734
- }
3735
3902
 
3736
- // Create a PTY that attaches to the tmux session
3737
- const ptyProcess = createTmuxAttachPty(sessionInfo.fullName, cols, rows);
3903
+ // Set up event handlers BEFORE connecting (to catch early output like shell prompt)
3904
+ const outputHandler = (sid, data) => {
3905
+ if (sid === session.id && ws.readyState === ws.OPEN) {
3906
+ ws.send(data);
3907
+ }
3908
+ };
3738
3909
 
3739
- wsConnections.set(ws, { pty: ptyProcess, stableId: requestedSession, sessionInfo });
3910
+ const exitHandler = (sid) => {
3911
+ if (sid === session.id && ws.readyState === ws.OPEN) {
3912
+ ws.send(JSON.stringify({ type: 'session_exit' }));
3913
+ // Don't close - allow reconnect
3914
+ }
3915
+ };
3740
3916
 
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
- );
3917
+ manager.on('session:output', outputHandler);
3918
+ manager.on('session:exit', exitHandler);
3749
3919
 
3750
- ptyProcess.onData((data) => {
3751
- if (ws.readyState === ws.OPEN) {
3752
- ws.send(data);
3753
- }
3754
- });
3920
+ wsConnections.set(ws, { sessionId: session.id, outputHandler, exitHandler });
3755
3921
 
3756
- ptyProcess.onExit(() => {
3757
- if (ws.readyState === ws.OPEN) {
3758
- ws.close();
3922
+ // Connect to session (respawns PTY if needed after server restart)
3923
+ await manager.connectToSession(session.id);
3924
+
3925
+ // Resize to requested dimensions
3926
+ await manager.resizeSession(session.id, cols, rows);
3927
+
3928
+ // Send session info with current display name
3929
+ ws.send(
3930
+ JSON.stringify({
3931
+ type: 'session_info',
3932
+ name: session.displayName,
3933
+ stableId: session.id.slice(0, 8), // Backward compat
3934
+ id: session.id, // Full UUID (new)
3935
+ })
3936
+ );
3937
+
3938
+ // Replay scrollback buffer for reconnecting clients
3939
+ const scrollback = await manager.getScrollback(session.id);
3940
+ if (scrollback) {
3941
+ ws.send(scrollback);
3759
3942
  }
3760
- wsConnections.delete(ws);
3761
- });
3762
3943
 
3763
- ws.on('message', (data) => {
3764
- const message = data.toString('utf8');
3944
+ ws.on('message', (data) => {
3945
+ const message = data.toString('utf8');
3765
3946
 
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;
3947
+ if (message.startsWith('{')) {
3948
+ try {
3949
+ const msg = JSON.parse(message);
3950
+ if (msg.type === 'resize') {
3951
+ manager.resizeSession(session.id, msg.cols, msg.rows);
3952
+ return;
3953
+ }
3954
+ } catch (e) {
3955
+ // Not JSON, treat as input
3772
3956
  }
3773
- } catch (e) {
3774
- // Not JSON, treat as input
3775
3957
  }
3776
- }
3777
3958
 
3778
- ptyProcess.write(message);
3779
- });
3959
+ // Write to PTY
3960
+ try {
3961
+ manager.write(session.id, message);
3962
+ } catch {
3963
+ // PTY may have exited, that's ok
3964
+ }
3965
+ });
3780
3966
 
3781
- ws.on('close', () => {
3782
- const conn = wsConnections.get(ws);
3783
- if (conn) {
3784
- conn.pty.kill();
3785
- wsConnections.delete(ws);
3786
- }
3787
- });
3967
+ ws.on('close', () => {
3968
+ const conn = wsConnections.get(ws);
3969
+ if (conn) {
3970
+ manager.off('session:output', conn.outputHandler);
3971
+ manager.off('session:exit', conn.exitHandler);
3972
+ wsConnections.delete(ws);
3973
+ // Note: We do NOT kill the PTY - session persists for reconnection
3974
+ }
3975
+ });
3788
3976
 
3789
- ws.on('error', () => {
3790
- const conn = wsConnections.get(ws);
3791
- if (conn) {
3792
- conn.pty.kill();
3793
- wsConnections.delete(ws);
3794
- }
3795
- });
3977
+ ws.on('error', () => {
3978
+ const conn = wsConnections.get(ws);
3979
+ if (conn) {
3980
+ manager.off('session:output', conn.outputHandler);
3981
+ manager.off('session:exit', conn.exitHandler);
3982
+ wsConnections.delete(ws);
3983
+ }
3984
+ });
3985
+ } catch (err) {
3986
+ console.error('WebSocket connection error:', err);
3987
+ ws.send(JSON.stringify({ type: 'error', message: err.message }));
3988
+ ws.close();
3989
+ }
3796
3990
  });
3797
3991
 
3798
3992
  // ============================================================================
3799
3993
  // Startup
3800
3994
  // ============================================================================
3801
3995
 
3802
- function printBanner(url, backgroundMode = false) {
3996
+ function printBanner(url, port, backgroundMode = false) {
3803
3997
  const localIPs = getLocalIPs();
3804
3998
  // ANSI color codes
3805
3999
  const RESET = '\x1b[0m';
@@ -3820,7 +4014,7 @@ function startWebServer(cliArgs) {
3820
4014
  networkCount++;
3821
4015
  const spaces = networkCount !== 1 ? ' ' : '';
3822
4016
  const protocol = USE_HTTPS ? 'https' : 'http';
3823
- network.push(`${spaces}${BEIGE}${protocol}://${ip}:${HTTP_PORT}${RESET}\n`);
4017
+ network.push(`${spaces}${BEIGE}${protocol}://${ip}:${port}${RESET}\n`);
3824
4018
  }
3825
4019
  }
3826
4020
  console.log(`\n${network.join('')} `);
@@ -3843,6 +4037,8 @@ function startWebServer(cliArgs) {
3843
4037
 
3844
4038
  process.on('SIGINT', () => {
3845
4039
  console.log('\n\nShutting down...');
4040
+ // Clean up server config file for port discovery
4041
+ removeServerConfig();
3846
4042
  for (const [ws, conn] of wsConnections.entries()) {
3847
4043
  conn.pty.kill();
3848
4044
  ws.close();
@@ -3852,6 +4048,12 @@ function startWebServer(cliArgs) {
3852
4048
  });
3853
4049
 
3854
4050
  httpServer.listen(HTTP_PORT, BIND_ADDRESS, async () => {
4051
+ // Get actual port (important when HTTP_PORT is 0 for auto-discovery)
4052
+ const actualPort = httpServer.address().port;
4053
+
4054
+ // Write server config for port discovery by CLI commands
4055
+ writeServerConfig(actualPort);
4056
+
3855
4057
  // Display ASCII art banner
3856
4058
  try {
3857
4059
  const imagePath = path.join(__dirname, '..', 'bin', 'assets', 'ghosts.png');
@@ -3868,7 +4070,7 @@ function startWebServer(cliArgs) {
3868
4070
  }
3869
4071
 
3870
4072
  const protocol = USE_HTTPS ? 'https' : 'http';
3871
- printBanner(`${protocol}://localhost:${HTTP_PORT}`, backgroundMode);
4073
+ printBanner(`${protocol}://localhost:${actualPort}`, actualPort, backgroundMode);
3872
4074
 
3873
4075
  // Show warning when auth is disabled
3874
4076
  if (NO_AUTH) {
@@ -3881,8 +4083,8 @@ function startWebServer(cliArgs) {
3881
4083
  // Main entry point
3882
4084
  // ============================================================================
3883
4085
 
3884
- export function run(argv) {
3885
- const cliArgs = parseArgs(argv);
4086
+ export async function run(argv) {
4087
+ const cliArgs = await parseArgs(argv);
3886
4088
 
3887
4089
  if (cliArgs.handled) {
3888
4090
  return;