@seflless/ghosttown 1.6.1 → 1.7.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.ts';
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);
@@ -69,6 +87,10 @@ const SERVER_SESSION_NAME = 'ghosttown-host';
69
87
  // Session expiration: 3 days in milliseconds
70
88
  const SESSION_EXPIRATION_MS = 3 * 24 * 60 * 60 * 1000;
71
89
 
90
+ // Session persistence file path
91
+ const SESSION_FILE_DIR = path.join(homedir(), '.config', 'ghosttown');
92
+ const SESSION_FILE_PATH = path.join(SESSION_FILE_DIR, 'sessions.json');
93
+
72
94
  // In-memory session store: Map<token, { username, createdAt, lastActivity }>
73
95
  const authSessions = new Map();
74
96
 
@@ -228,15 +250,74 @@ async function authenticateUser(username, password) {
228
250
  setInterval(
229
251
  () => {
230
252
  const now = Date.now();
253
+ let deleted = false;
231
254
  for (const [token, session] of authSessions.entries()) {
232
255
  if (now - session.createdAt > SESSION_EXPIRATION_MS) {
233
256
  authSessions.delete(token);
257
+ deleted = true;
234
258
  }
235
259
  }
260
+ if (deleted) {
261
+ saveSessions();
262
+ }
236
263
  },
237
264
  60 * 60 * 1000
238
265
  ); // Clean up every hour
239
266
 
267
+ // Load sessions from persistent storage
268
+ function loadSessions() {
269
+ try {
270
+ if (!fs.existsSync(SESSION_FILE_PATH)) {
271
+ return;
272
+ }
273
+ const data = fs.readFileSync(SESSION_FILE_PATH, 'utf8');
274
+ const sessions = JSON.parse(data);
275
+ const now = Date.now();
276
+
277
+ // Load valid sessions, skip expired ones
278
+ for (const [token, session] of Object.entries(sessions)) {
279
+ if (
280
+ session &&
281
+ typeof session.username === 'string' &&
282
+ typeof session.createdAt === 'number' &&
283
+ now - session.createdAt <= SESSION_EXPIRATION_MS
284
+ ) {
285
+ authSessions.set(token, {
286
+ username: session.username,
287
+ createdAt: session.createdAt,
288
+ lastActivity: session.lastActivity || session.createdAt,
289
+ });
290
+ }
291
+ }
292
+ } catch (err) {
293
+ // If file is corrupted or unreadable, start fresh
294
+ console.warn('Warning: Could not load sessions file, starting fresh:', err.message);
295
+ }
296
+ }
297
+
298
+ // Save sessions to persistent storage
299
+ function saveSessions() {
300
+ try {
301
+ // Ensure directory exists with secure permissions
302
+ if (!fs.existsSync(SESSION_FILE_DIR)) {
303
+ fs.mkdirSync(SESSION_FILE_DIR, { recursive: true, mode: 0o700 });
304
+ }
305
+
306
+ // Convert Map to object for JSON serialization
307
+ const sessions = {};
308
+ for (const [token, session] of authSessions.entries()) {
309
+ sessions[token] = session;
310
+ }
311
+
312
+ // Write file with secure permissions (owner read/write only)
313
+ fs.writeFileSync(SESSION_FILE_PATH, JSON.stringify(sessions, null, 2), {
314
+ mode: 0o600,
315
+ });
316
+ } catch (err) {
317
+ console.error('Error saving sessions:', err.message);
318
+ }
319
+ }
320
+
240
321
  // Helper to read POST body
241
322
  function readBody(req) {
242
323
  return new Promise((resolve, reject) => {
@@ -359,40 +440,43 @@ function isInsideGhosttownSession() {
359
440
  return sessionName && sessionName.startsWith('gt-');
360
441
  }
361
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
+
362
447
  /**
363
- * Validate a session name
364
- * 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}
365
454
  */
366
- function validateSessionName(name) {
367
- if (!name || name.length === 0) {
368
- return { valid: false, error: 'Session name cannot be empty' };
369
- }
370
- if (name.length > 50) {
371
- return { valid: false, error: 'Session name too long (max 50 chars)' };
372
- }
373
- if (/^[0-9]+$/.test(name)) {
374
- return {
375
- valid: false,
376
- error: 'Session name cannot be purely numeric (conflicts with auto-generated IDs)',
377
- };
378
- }
379
- // Reserved name for background server
380
- if (name === 'ghosttown-host') {
381
- return { valid: false, error: "'ghosttown-host' is a reserved name" };
455
+ function parseSessionName(fullName) {
456
+ if (!fullName || !fullName.startsWith('gt-')) {
457
+ return null;
382
458
  }
383
- // tmux session names cannot contain: colon, period
384
- // We also disallow spaces and special chars for URL safety
385
- if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
459
+ // Format: gt-<stable-id>-<display-name>
460
+ const match = fullName.match(/^gt-(\d+)-(.+)$/);
461
+ if (match) {
386
462
  return {
387
- valid: false,
388
- error: 'Session name can only contain letters, numbers, hyphens, and underscores',
463
+ stableId: match[1],
464
+ displayName: match[2],
465
+ fullName: fullName,
389
466
  };
390
467
  }
391
- 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
+ };
392
475
  }
393
476
 
394
477
  /**
395
478
  * Check if a tmux session with the given name exists
479
+ * DEPRECATED: Only used by legacy CLI commands that still use tmux
396
480
  */
397
481
  function sessionExists(sessionName) {
398
482
  try {
@@ -410,10 +494,11 @@ function sessionExists(sessionName) {
410
494
  }
411
495
 
412
496
  /**
413
- * Check if a display name is already used by a ghosttown session
414
- * 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
415
500
  */
416
- function displayNameExists(displayName) {
501
+ function getTmuxSessionsForValidation() {
417
502
  try {
418
503
  const output = execSync('tmux list-sessions -F "#{session_name}"', {
419
504
  encoding: 'utf8',
@@ -421,61 +506,54 @@ function displayNameExists(displayName) {
421
506
  });
422
507
  return output
423
508
  .split('\n')
424
- .filter((s) => s.trim())
425
- .some((name) => {
509
+ .filter((name) => name.startsWith('gt-'))
510
+ .map((name) => {
426
511
  const parsed = parseSessionName(name);
427
- return parsed && parsed.displayName === displayName;
512
+ return { displayName: parsed ? parsed.displayName : name.replace('gt-', '') };
428
513
  });
429
514
  } catch (err) {
430
- return false;
515
+ return [];
431
516
  }
432
517
  }
433
518
 
434
519
  /**
435
- * Parse a ghosttown session name into its components
436
- * Format: gt-<stable-id>-<display-name>
437
- * Returns { stableId, displayName, fullName } or null if not a valid gt session
438
- */
439
- function parseSessionName(fullName) {
440
- if (!fullName || !fullName.startsWith('gt-')) {
441
- return null;
442
- }
443
- // Format: gt-<stable-id>-<display-name>
444
- const match = fullName.match(/^gt-(\d+)-(.+)$/);
445
- if (match) {
446
- return {
447
- stableId: match[1],
448
- displayName: match[2],
449
- fullName: fullName,
450
- };
451
- }
452
- // Legacy format: gt-<name> (no stable ID)
453
- // Treat the whole thing after gt- as the display name, no stable ID
454
- const legacyName = fullName.slice(3);
455
- return {
456
- stableId: null,
457
- displayName: legacyName,
458
- fullName: fullName,
459
- };
460
- }
461
-
462
- /**
463
- * Find a ghosttown session by its stable ID
464
- * 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}
465
525
  */
466
- function findSessionByStableId(stableId) {
526
+ function findSessionByDisplayName(identifier) {
467
527
  try {
468
528
  const output = execSync('tmux list-sessions -F "#{session_name}"', {
469
529
  encoding: 'utf8',
470
530
  stdio: ['pipe', 'pipe', 'pipe'],
471
531
  });
472
- const sessions = output.split('\n').filter((s) => s.trim() && s.startsWith('gt-'));
473
- for (const name of sessions) {
474
- const parsed = parseSessionName(name);
475
- if (parsed && parsed.stableId === stableId) {
476
- return name;
477
- }
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;
478
549
  }
550
+
551
+ // Try stableId match (for backwards compatibility)
552
+ const byId = sessions.find((s) => s.stableId === identifier);
553
+ if (byId) {
554
+ return byId;
555
+ }
556
+
479
557
  return null;
480
558
  } catch (err) {
481
559
  return null;
@@ -511,6 +589,39 @@ function getNextDisplayNumber() {
511
589
  }
512
590
  }
513
591
 
592
+ /**
593
+ * Create a new bare tmux session for the web UI
594
+ * @returns {{ displayNumber: number, stableId: string }} The display number and stable ID of the created session
595
+ * @throws {Error} If session creation fails
596
+ */
597
+ function createWebSession() {
598
+ const displayNumber = getNextDisplayNumber();
599
+
600
+ // Create a detached session with a temporary name, get the stable ID
601
+ const tempName = `gt-temp-${Date.now()}`;
602
+ const output = execSync(`tmux new-session -d -s ${tempName} -x 200 -y 50 -P -F "#{session_id}"`, {
603
+ encoding: 'utf8',
604
+ stdio: ['pipe', 'pipe', 'pipe'],
605
+ });
606
+
607
+ // Extract the stable ID (e.g., "$10" -> "10")
608
+ const stableId = output.trim().replace('$', '');
609
+
610
+ // Rename to final name: gt-<stableId>-<displayNumber>
611
+ const sessionName = `gt-${stableId}-${displayNumber}`;
612
+ execSync(`tmux rename-session -t ${tempName} ${sessionName}`, { stdio: 'pipe' });
613
+
614
+ // Disable status bar in the new session (must be done before sending keys)
615
+ execSync(`tmux set-option -t ${sessionName} status off`, { stdio: 'pipe' });
616
+
617
+ // Export environment variables for proper Unicode/color support
618
+ const envExports =
619
+ 'export TERM=xterm-256color LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 COLORTERM=truecolor TERM_PROGRAM=ghostty';
620
+ execSync(`tmux send-keys -t ${sessionName} '${envExports}' Enter`, { stdio: 'pipe' });
621
+
622
+ return { displayNumber, stableId };
623
+ }
624
+
514
625
  /**
515
626
  * Check if the background server is running and get its port and protocol
516
627
  * @returns {{ running: boolean, port: number | null, useHttp: boolean }}
@@ -670,7 +781,7 @@ function createTmuxSession(command, customName = null) {
670
781
  process.exit(1);
671
782
  }
672
783
  // Check if display name already exists
673
- if (displayNameExists(customName)) {
784
+ if (displayNameExists(getTmuxSessionsForValidation(), customName)) {
674
785
  console.log('');
675
786
  console.log(` ${RED}Error:${RESET} Session '${customName}' already exists.`);
676
787
  console.log('');
@@ -683,6 +794,7 @@ function createTmuxSession(command, customName = null) {
683
794
 
684
795
  try {
685
796
  // Create a detached session with a temporary name to get its stable ID
797
+ // This also starts the tmux server if not running
686
798
  const tempName = `gt-temp-${Date.now()}`;
687
799
  const output = execSync(
688
800
  `tmux new-session -d -s ${tempName} -x 200 -y 50 -P -F "#{session_id}"`,
@@ -699,9 +811,12 @@ function createTmuxSession(command, customName = null) {
699
811
  // Set status off
700
812
  execSync(`tmux set-option -t ${sessionName} status off`, { stdio: 'pipe' });
701
813
 
702
- // Send the command to run, followed by shell
814
+ // Export environment variables for proper Unicode/color support, then run the command
815
+ // Include TERM to override tmux's default
816
+ const envExports =
817
+ 'export TERM=xterm-256color LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 COLORTERM=truecolor TERM_PROGRAM=ghostty';
703
818
  execSync(
704
- `tmux send-keys -t ${sessionName} '${command.replace(/'/g, "'\\''")}; exec $SHELL' Enter`,
819
+ `tmux send-keys -t ${sessionName} '${envExports} && ${command.replace(/'/g, "'\\''")}; exec $SHELL' Enter`,
705
820
  { stdio: 'pipe' }
706
821
  );
707
822
 
@@ -837,32 +952,13 @@ function attachToSession(sessionName) {
837
952
  const RED = '\x1b[31m';
838
953
  const BEIGE = '\x1b[38;2;255;220;150m';
839
954
 
840
- // Add gt- prefix if not present
841
- if (!sessionName.startsWith('gt-')) {
842
- sessionName = `gt-${sessionName}`;
843
- }
844
-
845
- // Check if session exists
846
- try {
847
- const output = execSync('tmux list-sessions -F "#{session_name}"', {
848
- encoding: 'utf8',
849
- stdio: ['pipe', 'pipe', 'pipe'],
850
- });
851
-
852
- const sessions = output.split('\n').filter((s) => s.trim());
853
- if (!sessions.includes(sessionName)) {
854
- console.log('');
855
- console.log(` ${RED}Error:${RESET} Session '${sessionName}' not found.`);
856
- console.log('');
857
- console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
858
- console.log('');
859
- process.exit(1);
860
- }
861
- } catch (err) {
955
+ // Look up session by display name
956
+ const found = findSessionByDisplayName(sessionName);
957
+ if (!found) {
862
958
  console.log('');
863
- console.log(` ${RED}Error:${RESET} No tmux sessions found.`);
959
+ console.log(` ${RED}Error:${RESET} Session '${sessionName}' not found.`);
864
960
  console.log('');
865
- console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
961
+ listSessionsInline();
866
962
  console.log('');
867
963
  process.exit(1);
868
964
  }
@@ -871,7 +967,7 @@ function attachToSession(sessionName) {
871
967
  // Use the same pattern as detachFromTmux which works
872
968
  const result = spawnSync(
873
969
  process.env.SHELL || '/bin/sh',
874
- ['-c', `tmux attach-session -t ${sessionName}`],
970
+ ['-c', `tmux attach-session -t ${found.fullName}`],
875
971
  {
876
972
  stdio: 'inherit',
877
973
  }
@@ -987,57 +1083,57 @@ function killSession(sessionName) {
987
1083
  }
988
1084
 
989
1085
  sessionName = getCurrentTmuxSessionName();
990
- }
991
-
992
- // Add gt- prefix if not present
993
- if (!sessionName.startsWith('gt-')) {
994
- sessionName = `gt-${sessionName}`;
995
- }
1086
+ // sessionName is now the full tmux name (e.g., gt-12-13)
1087
+ const parsed = parseSessionName(sessionName);
996
1088
 
997
- // Check if session exists
998
- try {
999
- const output = execSync('tmux list-sessions -F "#{session_name}"', {
1000
- encoding: 'utf8',
1001
- stdio: ['pipe', 'pipe', 'pipe'],
1002
- });
1089
+ // Kill the session
1090
+ try {
1091
+ execSync(`tmux kill-session -t ${sessionName}`, {
1092
+ stdio: 'pipe',
1093
+ });
1003
1094
 
1004
- const sessions = output.split('\n').filter((s) => s.trim());
1005
- if (!sessions.includes(sessionName)) {
1095
+ const displayName = parsed ? parsed.displayName : sessionName.replace('gt-', '');
1006
1096
  console.log('');
1007
- console.log(` ${RED}Error:${RESET} Session '${sessionName}' not found.`);
1097
+ console.log(` ${BOLD_YELLOW}Session ${displayName} has been killed.${RESET}`);
1008
1098
  console.log('');
1009
- 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}'.`);
1010
1106
  console.log('');
1011
1107
  process.exit(1);
1012
1108
  }
1013
- } catch (err) {
1109
+ }
1110
+
1111
+ // User provided a session name - look it up by display name
1112
+ const found = findSessionByDisplayName(sessionName);
1113
+ if (!found) {
1014
1114
  console.log('');
1015
- console.log(` ${RED}Error:${RESET} No tmux sessions found.`);
1115
+ console.log(` ${RED}Error:${RESET} Session '${sessionName}' not found.`);
1016
1116
  console.log('');
1017
- console.log(` ${BEIGE}No ghosttown sessions are currently running.${RESET}`);
1117
+ listSessionsInline();
1018
1118
  console.log('');
1019
1119
  process.exit(1);
1020
1120
  }
1021
1121
 
1022
1122
  // Kill the session
1023
1123
  try {
1024
- execSync(`tmux kill-session -t ${sessionName}`, {
1124
+ execSync(`tmux kill-session -t ${found.fullName}`, {
1025
1125
  stdio: 'pipe',
1026
1126
  });
1027
1127
 
1028
- // Extract display name from session name
1029
- const parsed = parseSessionName(sessionName);
1030
- const displayName = parsed ? parsed.displayName : sessionName.replace('gt-', '');
1031
-
1032
1128
  console.log('');
1033
- console.log(` ${BOLD_YELLOW}Session ${displayName} has been killed.${RESET}`);
1129
+ console.log(` ${BOLD_YELLOW}Session ${found.displayName} has been killed.${RESET}`);
1034
1130
  console.log('');
1035
1131
  console.log(` ${CYAN}To list remaining:${RESET} ${BEIGE}gt list${RESET}`);
1036
1132
  console.log('');
1037
1133
  process.exit(0);
1038
1134
  } catch (err) {
1039
1135
  console.log('');
1040
- console.log(` ${RED}Error:${RESET} Failed to kill session '${sessionName}'.`);
1136
+ console.log(` ${RED}Error:${RESET} Failed to kill session '${found.displayName}'.`);
1041
1137
  console.log('');
1042
1138
  process.exit(1);
1043
1139
  }
@@ -1153,6 +1249,7 @@ function startServerInBackground(port = 8080, useHttp = false) {
1153
1249
  const gtPath = process.argv[1];
1154
1250
 
1155
1251
  // Create a detached tmux session for the server
1252
+ // This also starts the tmux server if not running
1156
1253
  execSync(`tmux new-session -d -s ${SERVER_SESSION_NAME} -x 200 -y 50`, {
1157
1254
  stdio: 'pipe',
1158
1255
  });
@@ -1174,9 +1271,12 @@ function startServerInBackground(port = 8080, useHttp = false) {
1174
1271
  execSync(`tmux set-option -t ${SERVER_SESSION_NAME} status off`, { stdio: 'pipe' });
1175
1272
 
1176
1273
  // Run the server with background mode flag (add --http if needed)
1274
+ // Export environment variables for proper Unicode/color support first
1177
1275
  const httpFlag = useHttp ? ' --http' : '';
1276
+ const envExports =
1277
+ 'export TERM=xterm-256color LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 COLORTERM=truecolor TERM_PROGRAM=ghostty';
1178
1278
  execSync(
1179
- `tmux send-keys -t ${SERVER_SESSION_NAME} 'node "${gtPath}" --server-background -p ${port}${httpFlag}' Enter`,
1279
+ `tmux send-keys -t ${SERVER_SESSION_NAME} '${envExports} && node "${gtPath}" --server-background -p ${port}${httpFlag}' Enter`,
1180
1280
  { stdio: 'pipe' }
1181
1281
  );
1182
1282
 
@@ -1338,29 +1438,6 @@ function listSessionsInline() {
1338
1438
  }
1339
1439
  }
1340
1440
 
1341
- /**
1342
- * Find a ghosttown session by display name
1343
- * Returns the parsed session info or null if not found
1344
- */
1345
- function findSessionByDisplayName(displayName) {
1346
- try {
1347
- const output = execSync('tmux list-sessions -F "#{session_name}"', {
1348
- encoding: 'utf8',
1349
- stdio: ['pipe', 'pipe', 'pipe'],
1350
- });
1351
- const sessions = output.split('\n').filter((s) => s.trim() && s.startsWith('gt-'));
1352
- for (const fullName of sessions) {
1353
- const parsed = parseSessionName(fullName);
1354
- if (parsed && parsed.displayName === displayName) {
1355
- return parsed;
1356
- }
1357
- }
1358
- return null;
1359
- } catch (err) {
1360
- return null;
1361
- }
1362
- }
1363
-
1364
1441
  /**
1365
1442
  * Rename a ghosttown session
1366
1443
  * Usage:
@@ -1436,7 +1513,7 @@ function renameSession(renameArgs) {
1436
1513
  }
1437
1514
 
1438
1515
  // Check new display name doesn't conflict
1439
- if (displayNameExists(newDisplayName)) {
1516
+ if (displayNameExists(getTmuxSessionsForValidation(), newDisplayName)) {
1440
1517
  console.log('');
1441
1518
  console.log(` ${RED}Error:${RESET} Session '${newDisplayName}' already exists.`);
1442
1519
  console.log('');
@@ -1474,6 +1551,7 @@ function parseArgs(argv) {
1474
1551
  let handled = false;
1475
1552
  let sessionName = null;
1476
1553
  let useHttp = false;
1554
+ let noAuth = false;
1477
1555
  let serverBackgroundMode = false;
1478
1556
 
1479
1557
  for (let i = 0; i < args.length; i++) {
@@ -1487,6 +1565,7 @@ Options:
1487
1565
  -p, --port <port> Port to listen on (default: 8080, or PORT env var)
1488
1566
  -n, --name <name> Give the session a custom name (use with a command)
1489
1567
  --http Use HTTP instead of HTTPS (default is HTTPS)
1568
+ --no-auth Disable authentication (binds to localhost only)
1490
1569
  -k, --kill [session] Kill a session (current if inside one, or specify by name/ID)
1491
1570
  -ka, --kill-all Kill all ghosttown sessions
1492
1571
  -v, --version Show version number
@@ -1511,6 +1590,8 @@ Examples:
1511
1590
  ghosttown Start the web terminal server (foreground)
1512
1591
  ghosttown -p 3000 Start the server on port 3000 (foreground)
1513
1592
  ghosttown --http Use plain HTTP instead of HTTPS
1593
+ ghosttown --no-auth Disable auth (localhost only, for testing)
1594
+ ghosttown --http --no-auth HTTP without auth (for local testing)
1514
1595
  ghosttown list List all ghosttown sessions
1515
1596
  ghosttown attach 1 Attach to session gt-1
1516
1597
  ghosttown attach my-project Attach to session gt-my-project
@@ -1564,8 +1645,7 @@ Aliases:
1564
1645
  if (!sessionArg) {
1565
1646
  console.error('Error: attach requires a session name or ID');
1566
1647
  console.error('Usage: gt attach <name|id>');
1567
- handled = true;
1568
- break;
1648
+ process.exit(1);
1569
1649
  }
1570
1650
  handled = true;
1571
1651
  attachToSession(sessionArg);
@@ -1671,6 +1751,11 @@ Aliases:
1671
1751
  useHttp = true;
1672
1752
  }
1673
1753
 
1754
+ // Handle no-auth flag (disables authentication, binds to localhost only)
1755
+ else if (arg === '--no-auth') {
1756
+ noAuth = true;
1757
+ }
1758
+
1674
1759
  // First non-flag argument starts the command
1675
1760
  // Capture it and all remaining arguments as the command
1676
1761
  else if (!arg.startsWith('-')) {
@@ -1679,7 +1764,7 @@ Aliases:
1679
1764
  }
1680
1765
  }
1681
1766
 
1682
- return { port, command, handled, sessionName, useHttp, serverBackgroundMode };
1767
+ return { port, command, handled, sessionName, useHttp, noAuth, serverBackgroundMode };
1683
1768
  }
1684
1769
 
1685
1770
  // ============================================================================
@@ -1689,7 +1774,13 @@ Aliases:
1689
1774
  function startWebServer(cliArgs) {
1690
1775
  const HTTP_PORT = cliArgs.port || process.env.PORT || 8080;
1691
1776
  const USE_HTTPS = !cliArgs.useHttp;
1777
+ const NO_AUTH = cliArgs.noAuth || false;
1692
1778
  const backgroundMode = cliArgs.serverBackgroundMode || false;
1779
+ // When auth is disabled, only bind to localhost for security
1780
+ const BIND_ADDRESS = NO_AUTH ? '127.0.0.1' : '0.0.0.0';
1781
+
1782
+ // Load persisted sessions from disk
1783
+ loadSessions();
1693
1784
 
1694
1785
  // Generate self-signed certificate for HTTPS
1695
1786
  function generateSelfSignedCert() {
@@ -1765,7 +1856,11 @@ function startWebServer(cliArgs) {
1765
1856
  <head>
1766
1857
  <meta charset="UTF-8" />
1767
1858
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
1768
- <title>ghosttown</title>
1859
+ <title>Ghost Town</title>
1860
+ <!-- JetBrains Mono for consistent terminal rendering -->
1861
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
1862
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
1863
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet" />
1769
1864
  <style>
1770
1865
  :root {
1771
1866
  --vvh: 100vh;
@@ -1790,7 +1885,7 @@ function startWebServer(cliArgs) {
1790
1885
 
1791
1886
  body {
1792
1887
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1793
- background: #292c34;
1888
+ background: #1a1a1a;
1794
1889
  padding: 8px 8px 14px 8px;
1795
1890
  box-sizing: border-box;
1796
1891
  position: fixed;
@@ -1806,6 +1901,7 @@ function startWebServer(cliArgs) {
1806
1901
  justify-content: center;
1807
1902
  align-items: center;
1808
1903
  padding: 8px;
1904
+ caret-color: transparent;
1809
1905
  box-sizing: border-box;
1810
1906
  }
1811
1907
 
@@ -1814,69 +1910,129 @@ function startWebServer(cliArgs) {
1814
1910
  }
1815
1911
 
1816
1912
  .session-card {
1817
- background: #1e2127;
1913
+ background: #252525;
1818
1914
  border-radius: 12px;
1819
- padding: 24px;
1915
+ padding: 16px 20px;
1820
1916
  width: 100%;
1821
1917
  max-width: 600px;
1918
+ max-height: calc(100vh - 48px);
1822
1919
  box-sizing: border-box;
1823
1920
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
1921
+ display: flex;
1922
+ flex-direction: column;
1923
+ overflow: hidden;
1924
+ }
1925
+
1926
+ #session-list-content {
1927
+ flex: 1;
1928
+ overflow-y: auto;
1929
+ min-height: 0;
1930
+ }
1931
+
1932
+ #session-list-content::-webkit-scrollbar {
1933
+ width: 8px;
1934
+ }
1935
+
1936
+ #session-list-content::-webkit-scrollbar-track {
1937
+ background: transparent;
1938
+ }
1939
+
1940
+ #session-list-content::-webkit-scrollbar-thumb {
1941
+ background: #3a3f48;
1942
+ border-radius: 4px;
1943
+ }
1944
+
1945
+ #session-list-content::-webkit-scrollbar-thumb:hover {
1946
+ background: #4a4f58;
1824
1947
  }
1825
1948
 
1826
1949
  .session-card h1 {
1827
1950
  color: #e5e5e5;
1828
- font-size: 24px;
1951
+ font-size: 22px;
1829
1952
  font-weight: 600;
1830
- margin-bottom: 20px;
1953
+ margin-bottom: 4px;
1831
1954
  text-align: center;
1832
1955
  }
1833
1956
 
1834
- .session-table {
1835
- width: 100%;
1836
- border-collapse: collapse;
1837
- margin-bottom: 20px;
1957
+ .session-card .subtitle {
1958
+ color: #888;
1959
+ font-size: 13px;
1960
+ font-weight: 400;
1961
+ margin-bottom: 16px;
1962
+ text-align: center;
1838
1963
  }
1839
1964
 
1840
- .session-table th {
1965
+ .session-table-header {
1966
+ display: flex;
1841
1967
  color: #888;
1842
1968
  font-size: 11px;
1843
1969
  font-weight: 500;
1844
1970
  text-transform: uppercase;
1845
1971
  letter-spacing: 0.5px;
1846
- text-align: left;
1847
1972
  padding: 8px 12px;
1848
1973
  border-bottom: 1px solid #3a3f4b;
1974
+ flex-shrink: 0;
1975
+ }
1976
+
1977
+ .session-table-header .header-name {
1978
+ width: 90px;
1979
+ flex-shrink: 0;
1980
+ }
1981
+
1982
+ .session-table-header .header-activity {
1983
+ flex: 1;
1849
1984
  }
1850
1985
 
1851
- .session-table td {
1986
+ .session-table-header .header-status {
1987
+ width: 100px;
1988
+ flex-shrink: 0;
1989
+ }
1990
+
1991
+ .session-table-header .header-actions {
1992
+ width: 68px;
1993
+ flex-shrink: 0;
1994
+ }
1995
+
1996
+ .session-row {
1997
+ display: flex;
1998
+ align-items: center;
1852
1999
  color: #d4d4d4;
1853
2000
  font-size: 13px;
1854
- padding: 12px;
1855
2001
  border-bottom: 1px solid #2a2f38;
1856
- }
1857
-
1858
- .session-table tr.session-row {
1859
2002
  transition: background 0.15s;
1860
2003
  }
1861
2004
 
1862
- .session-table tr.session-row:hover {
2005
+ .session-row:hover {
1863
2006
  background: #2a2f38;
1864
2007
  }
1865
2008
 
1866
- .session-table tr.session-row td {
1867
- padding: 0;
1868
- }
1869
-
1870
- .session-table tr.session-row td a {
1871
- display: block;
2009
+ .session-row a {
2010
+ display: flex;
2011
+ flex: 1;
2012
+ align-items: center;
1872
2013
  padding: 12px;
1873
2014
  color: inherit;
1874
2015
  text-decoration: none;
1875
2016
  }
1876
2017
 
1877
- .session-table tr.session-row td.actions-cell {
2018
+ .session-row .row-name {
2019
+ width: 90px;
2020
+ flex-shrink: 0;
2021
+ }
2022
+
2023
+ .session-row .row-activity {
2024
+ flex: 1;
2025
+ }
2026
+
2027
+ .session-row .row-status {
2028
+ width: 100px;
2029
+ flex-shrink: 0;
2030
+ }
2031
+
2032
+ .session-row .actions-cell {
2033
+ width: 68px;
2034
+ flex-shrink: 0;
1878
2035
  padding: 12px 8px;
1879
- width: 40px;
1880
2036
  }
1881
2037
 
1882
2038
  .copy-btn {
@@ -1892,31 +2048,37 @@ function startWebServer(cliArgs) {
1892
2048
  }
1893
2049
 
1894
2050
  .copy-btn:hover {
1895
- background: #3a3f48;
2051
+ background: #2a2f38;
2052
+ border-color: #8a8f98;
1896
2053
  color: #fff;
1897
2054
  }
1898
2055
 
2056
+ .copy-btn:focus {
2057
+ outline: 2px solid #d97757;
2058
+ outline-offset: 2px;
2059
+ }
2060
+
1899
2061
  .copy-btn.copied {
1900
- color: #4ade80;
1901
- border-color: #4ade80;
2062
+ color: #3fb68b;
2063
+ border-color: #3fb68b;
1902
2064
  }
1903
2065
 
1904
2066
  .session-status {
1905
2067
  display: inline-block;
1906
- padding: 2px 8px;
2068
+ padding: 4px 10px;
1907
2069
  border-radius: 4px;
1908
- font-size: 11px;
1909
- font-weight: 500;
2070
+ font-size: 12px;
2071
+ font-weight: 400;
2072
+ background: #1e1e1e;
2073
+ border: 1px solid #333333;
1910
2074
  }
1911
2075
 
1912
2076
  .session-status.connected {
1913
- background: rgba(39, 201, 63, 0.2);
1914
- color: #27c93f;
2077
+ color: #3fb68b;
1915
2078
  }
1916
2079
 
1917
2080
  .session-status.disconnected {
1918
- background: rgba(255, 189, 46, 0.2);
1919
- color: #ffbd2e;
2081
+ color: #d9a557;
1920
2082
  }
1921
2083
 
1922
2084
  .empty-state {
@@ -1931,21 +2093,30 @@ function startWebServer(cliArgs) {
1931
2093
  }
1932
2094
 
1933
2095
  .create-btn {
1934
- background: #3a7afe;
1935
- color: white;
1936
- border: none;
2096
+ background: transparent;
2097
+ color: #d97757;
2098
+ border: 1px solid #3a3f48;
1937
2099
  padding: 10px 20px;
1938
2100
  border-radius: 6px;
1939
2101
  font-size: 14px;
1940
- font-weight: 500;
2102
+ font-weight: 400;
1941
2103
  cursor: pointer;
1942
- transition: background 0.15s;
2104
+ transition: all 0.15s;
1943
2105
  display: block;
1944
2106
  width: 100%;
2107
+ text-decoration: none;
2108
+ text-align: center;
2109
+ box-sizing: border-box;
1945
2110
  }
1946
2111
 
1947
2112
  .create-btn:hover {
1948
- background: #2563eb;
2113
+ background: #2a2f38;
2114
+ border-color: #d97757;
2115
+ }
2116
+
2117
+ .create-btn:focus {
2118
+ outline: 2px solid #d97757;
2119
+ outline-offset: 2px;
1949
2120
  }
1950
2121
 
1951
2122
  .button-row {
@@ -1964,16 +2135,22 @@ function startWebServer(cliArgs) {
1964
2135
  padding: 10px 16px;
1965
2136
  border-radius: 6px;
1966
2137
  font-size: 14px;
1967
- font-weight: 500;
2138
+ font-weight: 400;
1968
2139
  cursor: pointer;
1969
2140
  transition: all 0.15s;
1970
2141
  }
1971
2142
 
1972
2143
  .logout-btn:hover {
1973
- background: #3a3f48;
2144
+ background: #2a2f38;
2145
+ border-color: #888;
1974
2146
  color: #fff;
1975
2147
  }
1976
2148
 
2149
+ .logout-btn:focus {
2150
+ outline: 2px solid #d97757;
2151
+ outline-offset: 2px;
2152
+ }
2153
+
1977
2154
  .error-message {
1978
2155
  background: rgba(255, 95, 86, 0.1);
1979
2156
  border: 1px solid rgba(255, 95, 86, 0.3);
@@ -2019,7 +2196,7 @@ function startWebServer(cliArgs) {
2019
2196
  }
2020
2197
 
2021
2198
  .title-bar {
2022
- background: #292c34;
2199
+ background: #1a1a1a;
2023
2200
  padding: 8px 16px 6px 10px;
2024
2201
  display: flex;
2025
2202
  align-items: center;
@@ -2077,6 +2254,26 @@ function startWebServer(cliArgs) {
2077
2254
  color: #e5e5e5;
2078
2255
  }
2079
2256
 
2257
+ .new-terminal-btn {
2258
+ color: #888;
2259
+ font-size: 18px;
2260
+ font-weight: bold;
2261
+ text-decoration: none;
2262
+ width: 28px;
2263
+ height: 28px;
2264
+ display: grid;
2265
+ place-items: center;
2266
+ border-radius: 6px;
2267
+ margin-left: 12px;
2268
+ padding-bottom: 3px;
2269
+ box-sizing: border-box;
2270
+ transition: background 0.15s, color 0.15s;
2271
+ }
2272
+ .new-terminal-btn:hover {
2273
+ background: #3a4049;
2274
+ color: #e5e5e5;
2275
+ }
2276
+
2080
2277
  .connection-status {
2081
2278
  margin-left: auto;
2082
2279
  font-size: 11px;
@@ -2098,7 +2295,7 @@ function startWebServer(cliArgs) {
2098
2295
  #terminal-container {
2099
2296
  flex: 1;
2100
2297
  padding: 2px 0 2px 2px;
2101
- background: #292c34;
2298
+ background: #1a1a1a;
2102
2299
  position: relative;
2103
2300
  overflow: hidden;
2104
2301
  min-height: 0;
@@ -2152,7 +2349,7 @@ function startWebServer(cliArgs) {
2152
2349
  margin-top: 16px;
2153
2350
  padding: 8px 24px;
2154
2351
  background: #27c93f;
2155
- color: #1e2127;
2352
+ color: #ffffff;
2156
2353
  border: none;
2157
2354
  border-radius: 6px;
2158
2355
  font-size: 14px;
@@ -2165,12 +2362,19 @@ function startWebServer(cliArgs) {
2165
2362
  <div class="session-list-view" id="session-list-view">
2166
2363
  <div class="session-card">
2167
2364
  <h1>Ghost Town</h1>
2365
+ <p class="subtitle">Choose a terminal to connect to</p>
2168
2366
  <div class="error-message" id="error-message"></div>
2367
+ <div class="session-table-header" id="session-table-header" style="display: none;">
2368
+ <span class="header-name">Name</span>
2369
+ <span class="header-activity">Last Activity</span>
2370
+ <span class="header-status">Status</span>
2371
+ <span class="header-actions"></span>
2372
+ </div>
2169
2373
  <div id="session-list-content">
2170
2374
  <!-- Populated by JavaScript -->
2171
2375
  </div>
2172
2376
  <div class="button-row">
2173
- <button class="create-btn" id="create-session-btn">Create Session</button>
2377
+ <a href="/new" class="create-btn">Create Session</a>
2174
2378
  <button class="logout-btn" id="logout-btn">Logout</button>
2175
2379
  </div>
2176
2380
  </div>
@@ -2195,6 +2399,7 @@ function startWebServer(cliArgs) {
2195
2399
  <span class="connection-dot" id="connection-dot"></span>
2196
2400
  <span id="connection-text">Disconnected</span>
2197
2401
  </div>
2402
+ <a href="/new" class="new-terminal-btn" title="New Terminal">+</a>
2198
2403
  </div>
2199
2404
  <div id="terminal-container"></div>
2200
2405
  <div class="connection-error-overlay" id="connection-error">
@@ -2217,6 +2422,7 @@ function startWebServer(cliArgs) {
2217
2422
  let ws;
2218
2423
  let fitAddon;
2219
2424
  let currentSessionName = null;
2425
+ let requestedSessionName = null; // Original name from URL (for error messages)
2220
2426
 
2221
2427
  // HTML escape to prevent XSS when inserting user data into templates
2222
2428
  function escapeHtml(str) {
@@ -2229,13 +2435,15 @@ function startWebServer(cliArgs) {
2229
2435
  }
2230
2436
 
2231
2437
  // Get session info from URL query parameters
2232
- // 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
2233
2440
  function getSessionFromUrl() {
2234
2441
  const params = new URLSearchParams(window.location.search);
2442
+ const sessionId = params.get('sessionId');
2235
2443
  const name = params.get('session');
2236
2444
  const stableId = params.get('id');
2237
- if (name || stableId) {
2238
- return { name, stableId };
2445
+ if (sessionId || name || stableId) {
2446
+ return { sessionId, name, stableId };
2239
2447
  }
2240
2448
  return null;
2241
2449
  }
@@ -2244,11 +2452,17 @@ function startWebServer(cliArgs) {
2244
2452
  async function initApp() {
2245
2453
  const sessionInfo = getSessionFromUrl();
2246
2454
 
2247
- if (sessionInfo && sessionInfo.stableId) {
2248
- // Show terminal view using stable ID
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;
2249
2462
  showTerminalView(sessionInfo.stableId);
2250
2463
  } else if (sessionInfo && sessionInfo.name) {
2251
2464
  // Legacy URL with just name - show terminal view
2465
+ requestedSessionName = sessionInfo.name;
2252
2466
  showTerminalView(sessionInfo.name);
2253
2467
  } else {
2254
2468
  // Show session list view
@@ -2258,15 +2472,23 @@ function startWebServer(cliArgs) {
2258
2472
 
2259
2473
  // Show session list view
2260
2474
  async function showSessionListView() {
2475
+ // Disable contenteditable on terminal container to prevent caret from appearing
2476
+ const termContainer = document.getElementById('terminal-container');
2477
+ if (termContainer) {
2478
+ termContainer.setAttribute('contenteditable', 'false');
2479
+ }
2480
+
2481
+ // Blur any focused element
2482
+ if (document.activeElement) {
2483
+ document.activeElement.blur();
2484
+ }
2485
+
2261
2486
  document.getElementById('session-list-view').classList.add('active');
2262
2487
  document.getElementById('terminal-view').classList.remove('active');
2263
2488
  document.title = 'Ghost Town';
2264
2489
 
2265
2490
  await refreshSessionList();
2266
2491
 
2267
- // Set up create button
2268
- document.getElementById('create-session-btn').onclick = createSession;
2269
-
2270
2492
  // Set up logout button
2271
2493
  document.getElementById('logout-btn').onclick = logout;
2272
2494
  }
@@ -2288,14 +2510,17 @@ function startWebServer(cliArgs) {
2288
2510
  const data = await response.json();
2289
2511
 
2290
2512
  const content = document.getElementById('session-list-content');
2513
+ const header = document.getElementById('session-table-header');
2291
2514
 
2292
2515
  if (data.sessions.length === 0) {
2516
+ header.style.display = 'none';
2293
2517
  content.innerHTML = \`
2294
2518
  <div class="empty-state">
2295
2519
  <p>No sessions yet</p>
2296
2520
  </div>
2297
2521
  \`;
2298
2522
  } else {
2523
+ header.style.display = 'flex';
2299
2524
  const rows = data.sessions.map(session => {
2300
2525
  const lastActivity = new Date(session.lastActivity).toLocaleString();
2301
2526
  const statusClass = session.attached ? 'connected' : 'disconnected';
@@ -2303,30 +2528,18 @@ function startWebServer(cliArgs) {
2303
2528
  // URL format: /?session=<name>&id=<stableId>
2304
2529
  const sessionUrl = '/?session=' + encodeURIComponent(session.name) + (session.stableId ? '&id=' + encodeURIComponent(session.stableId) : '');
2305
2530
  return \`
2306
- <tr class="session-row">
2307
- <td><a href="\${sessionUrl}">\${escapeHtml(session.name)}</a></td>
2308
- <td><a href="\${sessionUrl}">\${lastActivity}</a></td>
2309
- <td><a href="\${sessionUrl}"><span class="session-status \${statusClass}">\${statusText}</span></a></td>
2310
- <td class="actions-cell"><button class="copy-btn" onclick="copySessionUrl(event, '\${escapeHtml(session.name)}', '\${escapeHtml(session.stableId || '')}')" title="Copy URL">Copy</button></td>
2311
- </tr>
2531
+ <div class="session-row">
2532
+ <a href="\${sessionUrl}">
2533
+ <span class="row-name">\${escapeHtml(session.name)}</span>
2534
+ <span class="row-activity">\${lastActivity}</span>
2535
+ <span class="row-status"><span class="session-status \${statusClass}">\${statusText}</span></span>
2536
+ </a>
2537
+ <div class="actions-cell"><button class="copy-btn" onclick="copySessionUrl(event, '\${escapeHtml(session.name)}', '\${escapeHtml(session.stableId || '')}')" title="Copy URL">Copy</button></div>
2538
+ </div>
2312
2539
  \`;
2313
2540
  }).join('');
2314
2541
 
2315
- content.innerHTML = \`
2316
- <table class="session-table">
2317
- <thead>
2318
- <tr>
2319
- <th>Name</th>
2320
- <th>Last Activity</th>
2321
- <th>Status</th>
2322
- <th></th>
2323
- </tr>
2324
- </thead>
2325
- <tbody>
2326
- \${rows}
2327
- </tbody>
2328
- </table>
2329
- \`;
2542
+ content.innerHTML = rows;
2330
2543
  }
2331
2544
  } catch (err) {
2332
2545
  console.error('Failed to fetch sessions:', err);
@@ -2354,19 +2567,6 @@ function startWebServer(cliArgs) {
2354
2567
  });
2355
2568
  };
2356
2569
 
2357
- // Create a new session
2358
- async function createSession() {
2359
- try {
2360
- const response = await fetch('/api/sessions/create', { method: 'POST' });
2361
- const data = await response.json();
2362
- // URL format: /?session=<name>&id=<stableId>
2363
- window.location.href = '/?session=' + encodeURIComponent(data.name) + '&id=' + encodeURIComponent(data.stableId);
2364
- } catch (err) {
2365
- console.error('Failed to create session:', err);
2366
- showError('Failed to create session');
2367
- }
2368
- }
2369
-
2370
2570
  // Show error message
2371
2571
  function showError(message) {
2372
2572
  const errorEl = document.getElementById('error-message');
@@ -2380,6 +2580,12 @@ function startWebServer(cliArgs) {
2380
2580
  currentSessionName = sessionId; // Will be updated when session_info arrives
2381
2581
  document.getElementById('session-list-view').classList.remove('active');
2382
2582
  document.getElementById('terminal-view').classList.add('active');
2583
+
2584
+ // Re-enable contenteditable on terminal container (it's disabled when showing session list)
2585
+ const termContainer = document.getElementById('terminal-container');
2586
+ if (termContainer) {
2587
+ termContainer.setAttribute('contenteditable', 'true');
2588
+ }
2383
2589
  // Show loading state until we get the real name from session_info
2384
2590
  document.getElementById('session-name').textContent = 'Loading...';
2385
2591
  document.title = 'ghosttown';
@@ -2421,10 +2627,28 @@ function startWebServer(cliArgs) {
2421
2627
  term = new Terminal({
2422
2628
  cursorBlink: true,
2423
2629
  fontSize: 12,
2424
- fontFamily: 'Monaco, Menlo, "Courier New", monospace',
2630
+ fontFamily: '"JetBrains Mono", Monaco, Menlo, "Courier New", monospace',
2425
2631
  theme: {
2426
- background: '#292c34',
2632
+ // Claude Code-inspired terminal colors
2633
+ background: '#1e1e1e',
2427
2634
  foreground: '#d4d4d4',
2635
+ cursor: '#d4d4d4',
2636
+ black: '#000000',
2637
+ red: '#c94a4a',
2638
+ green: '#3fb68b',
2639
+ yellow: '#d9a557',
2640
+ blue: '#5794d9',
2641
+ magenta: '#b56ec9',
2642
+ cyan: '#4ab5c9',
2643
+ white: '#d4d4d4',
2644
+ brightBlack: '#555555',
2645
+ brightRed: '#e57373',
2646
+ brightGreen: '#5ed4a5',
2647
+ brightYellow: '#e5c07b',
2648
+ brightBlue: '#7ab0e5',
2649
+ brightMagenta: '#d099e5',
2650
+ brightCyan: '#6fd4e5',
2651
+ brightWhite: '#ffffff',
2428
2652
  },
2429
2653
  smoothScrollDuration: 0,
2430
2654
  scrollback: 10000,
@@ -2589,7 +2813,10 @@ function startWebServer(cliArgs) {
2589
2813
 
2590
2814
  // SSE connection (for mobile with self-signed certs)
2591
2815
  function connectSSE() {
2592
- const sseUrl = '/sse?cols=' + term.cols + '&rows=' + term.rows + '&session=' + encodeURIComponent(currentSessionName);
2816
+ let sseUrl = '/sse?cols=' + term.cols + '&rows=' + term.rows + '&session=' + encodeURIComponent(currentSessionName);
2817
+ if (requestedSessionName && requestedSessionName !== currentSessionName) {
2818
+ sseUrl += '&name=' + encodeURIComponent(requestedSessionName);
2819
+ }
2593
2820
 
2594
2821
  sseSource = new EventSource(sseUrl);
2595
2822
 
@@ -2678,7 +2905,10 @@ function startWebServer(cliArgs) {
2678
2905
  // WebSocket-only connection (desktop or HTTP)
2679
2906
  function connectWebSocket() {
2680
2907
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
2681
- const wsUrl = protocol + '//' + window.location.host + '/ws?cols=' + term.cols + '&rows=' + term.rows + '&session=' + encodeURIComponent(currentSessionName);
2908
+ let wsUrl = protocol + '//' + window.location.host + '/ws?cols=' + term.cols + '&rows=' + term.rows + '&session=' + encodeURIComponent(currentSessionName);
2909
+ if (requestedSessionName && requestedSessionName !== currentSessionName) {
2910
+ wsUrl += '&name=' + encodeURIComponent(requestedSessionName);
2911
+ }
2682
2912
 
2683
2913
  ws = new WebSocket(wsUrl);
2684
2914
 
@@ -2748,7 +2978,7 @@ function startWebServer(cliArgs) {
2748
2978
  <head>
2749
2979
  <meta charset="UTF-8" />
2750
2980
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
2751
- <title>login - ghosttown</title>
2981
+ <title>Login - Ghost Town</title>
2752
2982
  <style>
2753
2983
  * {
2754
2984
  margin: 0;
@@ -2758,7 +2988,7 @@ function startWebServer(cliArgs) {
2758
2988
 
2759
2989
  body {
2760
2990
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
2761
- background: #292c34;
2991
+ background: #1a1a1a;
2762
2992
  min-height: 100vh;
2763
2993
  display: flex;
2764
2994
  align-items: center;
@@ -2769,14 +2999,14 @@ function startWebServer(cliArgs) {
2769
2999
  .login-window {
2770
3000
  width: 100%;
2771
3001
  max-width: 400px;
2772
- background: #1e2127;
3002
+ background: #252525;
2773
3003
  border-radius: 12px;
2774
3004
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
2775
3005
  overflow: hidden;
2776
3006
  }
2777
3007
 
2778
3008
  .title-bar {
2779
- background: #292c34;
3009
+ background: #1a1a1a;
2780
3010
  padding: 12px 16px;
2781
3011
  display: flex;
2782
3012
  align-items: center;
@@ -2843,27 +3073,27 @@ function startWebServer(cliArgs) {
2843
3073
  .form-group input {
2844
3074
  width: 100%;
2845
3075
  padding: 12px 14px;
2846
- background: #292c34;
2847
- border: 1px solid #3d3d3d;
3076
+ background: transparent;
3077
+ border: 1px solid #3a3f48;
2848
3078
  border-radius: 6px;
2849
- color: #e5e5e5;
3079
+ color: #d4d4d4;
2850
3080
  font-size: 14px;
2851
- transition: border-color 0.2s;
3081
+ transition: all 0.2s;
2852
3082
  }
2853
3083
 
2854
3084
  .form-group input:focus {
2855
- outline: none;
2856
- border-color: #27c93f;
3085
+ outline: 2px solid #d97757;
3086
+ outline-offset: 2px;
2857
3087
  }
2858
3088
 
2859
3089
  .form-group input::placeholder {
2860
- color: #666;
3090
+ color: #666666;
2861
3091
  }
2862
3092
 
2863
3093
  .error-message {
2864
- background: rgba(255, 95, 86, 0.1);
2865
- border: 1px solid rgba(255, 95, 86, 0.3);
2866
- color: #ff5f56;
3094
+ background: rgba(201, 74, 74, 0.15);
3095
+ border: 1px solid rgba(201, 74, 74, 0.3);
3096
+ color: #e57373;
2867
3097
  padding: 12px;
2868
3098
  border-radius: 6px;
2869
3099
  font-size: 13px;
@@ -2878,30 +3108,37 @@ function startWebServer(cliArgs) {
2878
3108
  .submit-btn {
2879
3109
  width: 100%;
2880
3110
  padding: 14px;
2881
- background: #27c93f;
2882
- border: none;
3111
+ background: transparent;
3112
+ border: 1px solid #3a3f48;
2883
3113
  border-radius: 6px;
2884
- color: #1e1e1e;
3114
+ color: #d97757;
2885
3115
  font-size: 14px;
2886
- font-weight: 600;
3116
+ font-weight: 400;
2887
3117
  cursor: pointer;
2888
- transition: background 0.2s;
3118
+ transition: all 0.2s;
2889
3119
  }
2890
3120
 
2891
3121
  .submit-btn:hover {
2892
- background: #2bd946;
3122
+ background: #2a2f38;
3123
+ border-color: #d97757;
3124
+ }
3125
+
3126
+ .submit-btn:focus {
3127
+ outline: 2px solid #d97757;
3128
+ outline-offset: 2px;
2893
3129
  }
2894
3130
 
2895
3131
  .submit-btn:disabled {
2896
- background: #3d3d3d;
2897
- color: #888;
3132
+ background: transparent;
3133
+ border-color: #252525;
3134
+ color: #555555;
2898
3135
  cursor: not-allowed;
2899
3136
  }
2900
3137
 
2901
3138
  .hint {
2902
3139
  text-align: center;
2903
3140
  margin-top: 20px;
2904
- color: #666;
3141
+ color: #666666;
2905
3142
  font-size: 12px;
2906
3143
  }
2907
3144
  </style>
@@ -3027,7 +3264,8 @@ function startWebServer(cliArgs) {
3027
3264
  const url = new URL(req.url, `http://${req.headers.host}`);
3028
3265
  const pathname = url.pathname;
3029
3266
  const cookies = parseCookies(req.headers.cookie);
3030
- const session = validateAuthSession(cookies.ghostty_session);
3267
+ // When NO_AUTH is enabled, treat all requests as authenticated
3268
+ const session = NO_AUTH ? { username: 'local' } : validateAuthSession(cookies.ghostty_session);
3031
3269
 
3032
3270
  // Handle login API endpoint
3033
3271
  if (pathname === '/api/login' && req.method === 'POST') {
@@ -3062,6 +3300,7 @@ function startWebServer(cliArgs) {
3062
3300
  createdAt: now,
3063
3301
  lastActivity: now,
3064
3302
  });
3303
+ saveSessions();
3065
3304
 
3066
3305
  setAuthCookieSecure(res, token);
3067
3306
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -3082,6 +3321,7 @@ function startWebServer(cliArgs) {
3082
3321
  if (pathname === '/api/logout' && req.method === 'POST') {
3083
3322
  if (cookies.ghostty_session) {
3084
3323
  authSessions.delete(cookies.ghostty_session);
3324
+ saveSessions();
3085
3325
  }
3086
3326
  clearAuthCookie(res);
3087
3327
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -3096,6 +3336,32 @@ function startWebServer(cliArgs) {
3096
3336
  return;
3097
3337
  }
3098
3338
 
3339
+ // Handle /new - create new session and redirect
3340
+ if (pathname === '/new') {
3341
+ if (!session) {
3342
+ res.writeHead(302, { Location: '/login' });
3343
+ res.end();
3344
+ return;
3345
+ }
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
+ })();
3362
+ return;
3363
+ }
3364
+
3099
3365
  // Serve index page (requires authentication)
3100
3366
  if (pathname === '/' || pathname === '/index.html') {
3101
3367
  if (!session) {
@@ -3131,7 +3397,7 @@ function startWebServer(cliArgs) {
3131
3397
  return;
3132
3398
  }
3133
3399
 
3134
- // API: List sessions (from tmux) - requires authentication
3400
+ // API: List sessions - requires authentication
3135
3401
  if (pathname === '/api/sessions' && req.method === 'GET') {
3136
3402
  if (!session) {
3137
3403
  res.writeHead(401, { 'Content-Type': 'application/json' });
@@ -3139,40 +3405,31 @@ function startWebServer(cliArgs) {
3139
3405
  return;
3140
3406
  }
3141
3407
  try {
3142
- const output = execSync(
3143
- 'tmux list-sessions -F "#{session_name}|#{session_activity}|#{session_attached}|#{session_windows}"',
3144
- { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
3145
- );
3146
-
3147
- const sessionList = output
3148
- .split('\n')
3149
- .filter((line) => line.startsWith('gt-'))
3150
- .map((line) => {
3151
- const [fullName, activity, attached, windows] = line.split('|');
3152
- const parsed = parseSessionName(fullName);
3153
- return {
3154
- name: parsed ? parsed.displayName : fullName.replace('gt-', ''),
3155
- stableId: parsed ? parsed.stableId : null,
3156
- fullName: fullName,
3157
- lastActivity: Number.parseInt(activity, 10) * 1000,
3158
- attached: attached === '1',
3159
- windows: Number.parseInt(windows, 10),
3160
- };
3161
- });
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
+ }));
3162
3421
 
3163
- // Sort by recent activity
3164
- sessionList.sort((a, b) => b.lastActivity - a.lastActivity);
3165
3422
  res.writeHead(200, { 'Content-Type': 'application/json' });
3166
- res.end(JSON.stringify({ sessions: sessionList }));
3423
+ res.end(JSON.stringify({ sessions: response }));
3167
3424
  } catch (err) {
3168
- // No tmux sessions or tmux not running
3425
+ console.error('Error listing sessions:', err);
3169
3426
  res.writeHead(200, { 'Content-Type': 'application/json' });
3170
3427
  res.end(JSON.stringify({ sessions: [] }));
3171
3428
  }
3172
3429
  return;
3173
3430
  }
3174
3431
 
3175
- // API: Create session (tmux session) - requires authentication
3432
+ // API: Create session - requires authentication
3176
3433
  if (pathname === '/api/sessions/create' && req.method === 'POST') {
3177
3434
  if (!session) {
3178
3435
  res.writeHead(401, { 'Content-Type': 'application/json' });
@@ -3180,28 +3437,18 @@ function startWebServer(cliArgs) {
3180
3437
  return;
3181
3438
  }
3182
3439
  try {
3183
- const displayNumber = getNextDisplayNumber();
3184
-
3185
- // Create a detached session with a temporary name to get its stable ID
3186
- const tempName = `gt-temp-${Date.now()}`;
3187
- const output = execSync(
3188
- `tmux new-session -d -s ${tempName} -x 200 -y 50 -P -F "#{session_id}"`,
3189
- { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
3190
- );
3191
-
3192
- // Extract the stable ID (e.g., "$10" -> "10")
3193
- const stableId = output.trim().replace('$', '');
3194
-
3195
- // Rename to final name: gt-<stableId>-<displayNumber>
3196
- const sessionName = `gt-${stableId}-${displayNumber}`;
3197
- execSync(`tmux rename-session -t ${tempName} ${sessionName}`, { stdio: 'pipe' });
3198
-
3199
- // Disable status bar in the new session
3200
- execSync(`tmux set-option -t ${sessionName} status off`, { stdio: 'pipe' });
3201
-
3440
+ const manager = await getSessionManager();
3441
+ const newSession = await manager.createSession();
3202
3442
  res.writeHead(200, { 'Content-Type': 'application/json' });
3203
- 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
+ );
3204
3450
  } catch (err) {
3451
+ console.error('Error creating session:', err);
3205
3452
  res.writeHead(500, { 'Content-Type': 'application/json' });
3206
3453
  res.end(JSON.stringify({ error: 'Failed to create session' }));
3207
3454
  }
@@ -3218,77 +3465,123 @@ function startWebServer(cliArgs) {
3218
3465
 
3219
3466
  const cols = Number.parseInt(url.searchParams.get('cols') || '80');
3220
3467
  const rows = Number.parseInt(url.searchParams.get('rows') || '24');
3221
- const requestedSession = url.searchParams.get('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;
3222
3473
 
3223
- if (!requestedSession) {
3474
+ if (!identifier) {
3224
3475
  res.writeHead(400, { 'Content-Type': 'application/json' });
3225
3476
  res.end(JSON.stringify({ error: 'Session parameter required' }));
3226
3477
  return;
3227
3478
  }
3228
3479
 
3229
- // Look up session by stable ID first, then by display name
3230
- let sessionInfo = findSessionByStableIdWeb(requestedSession);
3231
- if (!sessionInfo) {
3232
- sessionInfo = findSessionByDisplayName(requestedSession);
3233
- }
3234
- if (!sessionInfo) {
3235
- res.writeHead(404, { 'Content-Type': 'application/json' });
3236
- res.end(JSON.stringify({ error: `Session '${requestedSession}' not found` }));
3237
- return;
3238
- }
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;
3495
+ }
3239
3496
 
3240
- // Generate unique connection ID
3241
- 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)}`;
3242
3499
 
3243
- // Set up SSE headers
3244
- res.writeHead(200, {
3245
- 'Content-Type': 'text/event-stream',
3246
- 'Cache-Control': 'no-cache',
3247
- Connection: 'keep-alive',
3248
- 'X-SSE-Connection-Id': connectionId,
3249
- });
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
+ });
3250
3507
 
3251
- // Send session info as first event
3252
- res.write(
3253
- `data: ${JSON.stringify({ type: 'session_info', name: sessionInfo.displayName, stableId: sessionInfo.stableId, connectionId })}\n\n`
3254
- );
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
+ );
3255
3512
 
3256
- // Create PTY attached to tmux session
3257
- 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
+ };
3258
3529
 
3259
- // Store connection
3260
- sseConnections.set(connectionId, {
3261
- res,
3262
- pty: ptyProcess,
3263
- stableId: requestedSession,
3264
- sessionInfo,
3265
- });
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
+ };
3266
3550
 
3267
- // Stream terminal output as SSE events
3268
- ptyProcess.onData((data) => {
3269
- if (!res.writableEnded) {
3270
- // Base64 encode to handle binary data safely
3271
- const encoded = Buffer.from(data).toString('base64');
3272
- res.write(`data: ${JSON.stringify({ type: 'output', data: encoded })}\n\n`);
3273
- }
3274
- });
3551
+ manager.on('session:output', outputHandler);
3552
+ manager.on('session:exit', exitHandler);
3275
3553
 
3276
- ptyProcess.onExit(() => {
3277
- if (!res.writableEnded) {
3278
- res.write(`data: ${JSON.stringify({ type: 'exit' })}\n\n`);
3279
- res.end();
3280
- }
3281
- sseConnections.delete(connectionId);
3282
- });
3554
+ // Store connection
3555
+ sseConnections.set(connectionId, {
3556
+ res,
3557
+ sessionId: sessionObj.id,
3558
+ outputHandler,
3559
+ exitHandler,
3560
+ exitTimeout: () => exitTimeout, // Getter for cleanup
3561
+ });
3283
3562
 
3284
- // Handle client disconnect
3285
- req.on('close', () => {
3286
- const conn = sseConnections.get(connectionId);
3287
- if (conn) {
3288
- conn.pty.kill();
3289
- sseConnections.delete(connectionId);
3290
- }
3291
- });
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
+ }
3292
3585
 
3293
3586
  return;
3294
3587
  }
@@ -3305,7 +3598,7 @@ function startWebServer(cliArgs) {
3305
3598
  req.on('data', (chunk) => {
3306
3599
  body += chunk;
3307
3600
  });
3308
- req.on('end', () => {
3601
+ req.on('end', async () => {
3309
3602
  try {
3310
3603
  const { connectionId, data, type, cols, rows } = JSON.parse(body);
3311
3604
 
@@ -3316,12 +3609,14 @@ function startWebServer(cliArgs) {
3316
3609
  return;
3317
3610
  }
3318
3611
 
3612
+ const manager = await getSessionManager();
3613
+
3319
3614
  if (type === 'resize' && cols && rows) {
3320
- conn.pty.resize(cols, rows);
3615
+ await manager.resizeSession(conn.sessionId, cols, rows);
3321
3616
  } else if (data) {
3322
3617
  // Decode base64 input
3323
3618
  const decoded = Buffer.from(data, 'base64').toString('utf8');
3324
- conn.pty.write(decoded);
3619
+ manager.write(conn.sessionId, decoded);
3325
3620
  }
3326
3621
 
3327
3622
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -3358,54 +3653,35 @@ function startWebServer(cliArgs) {
3358
3653
  // WebSocket Server
3359
3654
  // ============================================================================
3360
3655
 
3361
- // Track active WebSocket connections to tmux sessions
3362
- // ws -> { pty, sessionName }
3656
+ // Track active WebSocket connections to sessions
3657
+ // ws -> { sessionId, outputHandler, exitHandler }
3363
3658
  const wsConnections = new Map();
3364
3659
 
3365
3660
  // Track active SSE connections (fallback for mobile)
3366
- // connectionId -> { res, pty, stableId, sessionInfo }
3661
+ // connectionId -> { res, sessionId, outputHandler, exitHandler }
3367
3662
  const sseConnections = new Map();
3368
3663
 
3369
3664
  /**
3370
- * Find a ghosttown session by stable ID (for web connections)
3371
- * 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
3372
3667
  */
3373
- function findSessionByStableIdWeb(stableId) {
3374
- try {
3375
- const output = execSync('tmux list-sessions -F "#{session_name}"', {
3376
- encoding: 'utf8',
3377
- stdio: ['pipe', 'pipe', 'pipe'],
3378
- });
3379
- const sessions = output.split('\n').filter((s) => s.trim() && s.startsWith('gt-'));
3380
- for (const fullName of sessions) {
3381
- const parsed = parseSessionName(fullName);
3382
- if (parsed && parsed.stableId === stableId) {
3383
- return parsed;
3384
- }
3385
- }
3386
- return null;
3387
- } catch (err) {
3388
- return null;
3389
- }
3390
- }
3668
+ async function findSessionForWeb(identifier) {
3669
+ const manager = await getSessionManager();
3670
+ const sessions = await manager.listSessions();
3391
3671
 
3392
- /**
3393
- * Create a PTY that attaches to a tmux session
3394
- */
3395
- function createTmuxAttachPty(fullSessionName, cols, rows) {
3396
- const ptyProcess = pty.spawn('tmux', ['attach-session', '-t', fullSessionName], {
3397
- name: 'xterm-256color',
3398
- cols: cols,
3399
- rows: rows,
3400
- cwd: homedir(),
3401
- env: {
3402
- ...process.env,
3403
- TERM: 'xterm-256color',
3404
- COLORTERM: 'truecolor',
3405
- },
3406
- });
3672
+ // Try exact UUID match first
3673
+ let session = sessions.find((s) => s.id === identifier);
3674
+ if (session) return manager.getSession(session.id);
3675
+
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);
3407
3683
 
3408
- return ptyProcess;
3684
+ return null;
3409
3685
  }
3410
3686
 
3411
3687
  const wss = new WebSocketServer({ noServer: true });
@@ -3414,9 +3690,11 @@ function startWebServer(cliArgs) {
3414
3690
  const url = new URL(req.url, `http://${req.headers.host}`);
3415
3691
 
3416
3692
  if (url.pathname === '/ws') {
3417
- // Validate authentication before allowing WebSocket connection
3693
+ // Validate authentication before allowing WebSocket connection (skip if NO_AUTH)
3418
3694
  const cookies = parseCookies(req.headers.cookie);
3419
- const session = validateAuthSession(cookies.ghostty_session);
3695
+ const session = NO_AUTH
3696
+ ? { username: 'local' }
3697
+ : validateAuthSession(cookies.ghostty_session);
3420
3698
 
3421
3699
  if (!session) {
3422
3700
  // Reject unauthenticated WebSocket connections
@@ -3434,93 +3712,130 @@ function startWebServer(cliArgs) {
3434
3712
  }
3435
3713
  });
3436
3714
 
3437
- wss.on('connection', (ws, req) => {
3715
+ wss.on('connection', async (ws, req) => {
3438
3716
  const url = new URL(req.url, `http://${req.headers.host}`);
3439
3717
  const cols = Number.parseInt(url.searchParams.get('cols') || '80');
3440
3718
  const rows = Number.parseInt(url.searchParams.get('rows') || '24');
3441
- const requestedSession = url.searchParams.get('session');
3442
3719
 
3443
- // Session parameter is required (should be stable ID)
3444
- if (!requestedSession) {
3720
+ // Support both new (sessionId) and legacy (session) parameters
3721
+ const sessionId = url.searchParams.get('sessionId');
3722
+ const legacySession = url.searchParams.get('session');
3723
+ const requestedName = url.searchParams.get('name'); // Original name from URL (if different from session)
3724
+ const identifier = sessionId || legacySession;
3725
+
3726
+ // Session parameter is required
3727
+ if (!identifier) {
3445
3728
  ws.send(JSON.stringify({ type: 'error', message: 'Session parameter required' }));
3446
3729
  ws.close();
3447
3730
  return;
3448
3731
  }
3449
3732
 
3450
- // Look up session by stable ID first, then by display name (for legacy URLs)
3451
- let sessionInfo = findSessionByStableIdWeb(requestedSession);
3452
- if (!sessionInfo) {
3453
- // Try looking up by display name
3454
- sessionInfo = findSessionByDisplayName(requestedSession);
3455
- }
3456
- if (!sessionInfo) {
3457
- ws.send(
3458
- JSON.stringify({ type: 'error', message: `Session '${requestedSession}' not found` })
3459
- );
3460
- ws.close();
3461
- return;
3462
- }
3733
+ try {
3734
+ const manager = await getSessionManager();
3463
3735
 
3464
- // Create a PTY that attaches to the tmux session
3465
- const ptyProcess = createTmuxAttachPty(sessionInfo.fullName, cols, rows);
3736
+ // Look up session by UUID, short ID, or display name
3737
+ const session = await findSessionForWeb(identifier);
3466
3738
 
3467
- wsConnections.set(ws, { pty: ptyProcess, stableId: requestedSession, sessionInfo });
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;
3750
+ }
3468
3751
 
3469
- // Send session info with current display name (may have been renamed)
3470
- ws.send(
3471
- JSON.stringify({
3472
- type: 'session_info',
3473
- name: sessionInfo.displayName,
3474
- stableId: sessionInfo.stableId,
3475
- })
3476
- );
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
+ };
3477
3758
 
3478
- ptyProcess.onData((data) => {
3479
- if (ws.readyState === ws.OPEN) {
3480
- ws.send(data);
3481
- }
3482
- });
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
+ };
3483
3765
 
3484
- ptyProcess.onExit(() => {
3485
- if (ws.readyState === ws.OPEN) {
3486
- ws.close();
3766
+ manager.on('session:output', outputHandler);
3767
+ manager.on('session:exit', exitHandler);
3768
+
3769
+ wsConnections.set(ws, { sessionId: session.id, outputHandler, exitHandler });
3770
+
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);
3487
3791
  }
3488
- wsConnections.delete(ws);
3489
- });
3490
3792
 
3491
- ws.on('message', (data) => {
3492
- const message = data.toString('utf8');
3793
+ ws.on('message', (data) => {
3794
+ const message = data.toString('utf8');
3493
3795
 
3494
- if (message.startsWith('{')) {
3495
- try {
3496
- const msg = JSON.parse(message);
3497
- if (msg.type === 'resize') {
3498
- ptyProcess.resize(msg.cols, msg.rows);
3499
- 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
3500
3805
  }
3501
- } catch (e) {
3502
- // Not JSON, treat as input
3503
3806
  }
3504
- }
3505
3807
 
3506
- ptyProcess.write(message);
3507
- });
3808
+ // Write to PTY
3809
+ try {
3810
+ manager.write(session.id, message);
3811
+ } catch {
3812
+ // PTY may have exited, that's ok
3813
+ }
3814
+ });
3508
3815
 
3509
- ws.on('close', () => {
3510
- const conn = wsConnections.get(ws);
3511
- if (conn) {
3512
- conn.pty.kill();
3513
- wsConnections.delete(ws);
3514
- }
3515
- });
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
+ });
3516
3825
 
3517
- ws.on('error', () => {
3518
- const conn = wsConnections.get(ws);
3519
- if (conn) {
3520
- conn.pty.kill();
3521
- wsConnections.delete(ws);
3522
- }
3523
- });
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
+ }
3524
3839
  });
3525
3840
 
3526
3841
  // ============================================================================
@@ -3579,7 +3894,7 @@ function startWebServer(cliArgs) {
3579
3894
  process.exit(0);
3580
3895
  });
3581
3896
 
3582
- httpServer.listen(HTTP_PORT, '0.0.0.0', async () => {
3897
+ httpServer.listen(HTTP_PORT, BIND_ADDRESS, async () => {
3583
3898
  // Display ASCII art banner
3584
3899
  try {
3585
3900
  const imagePath = path.join(__dirname, '..', 'bin', 'assets', 'ghosts.png');
@@ -3597,6 +3912,11 @@ function startWebServer(cliArgs) {
3597
3912
 
3598
3913
  const protocol = USE_HTTPS ? 'https' : 'http';
3599
3914
  printBanner(`${protocol}://localhost:${HTTP_PORT}`, backgroundMode);
3915
+
3916
+ // Show warning when auth is disabled
3917
+ if (NO_AUTH) {
3918
+ console.log(' \x1b[33m⚠ Auth disabled\x1b[0m - localhost only, for testing\n');
3919
+ }
3600
3920
  });
3601
3921
  }
3602
3922