@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/dist/ghostty-web.js +590 -270
- package/dist/ghostty-web.umd.cjs +4 -4
- package/dist/index.d.ts +5 -0
- package/package.json +15 -2
- package/src/cli.js +801 -481
- package/src/cli.test.ts +279 -5
- package/src/session/history-replay.ts +253 -0
- package/src/session/index.ts +11 -0
- package/src/session/output-recorder.ts +322 -0
- package/src/session/session-manager.ts +587 -0
- package/src/session/types.ts +236 -0
- package/src/session-utils.js +91 -0
- package/src/session-utils.test.js +229 -0
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
|
-
*
|
|
364
|
-
*
|
|
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
|
|
367
|
-
if (!
|
|
368
|
-
return
|
|
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
|
-
//
|
|
384
|
-
|
|
385
|
-
if (
|
|
459
|
+
// Format: gt-<stable-id>-<display-name>
|
|
460
|
+
const match = fullName.match(/^gt-(\d+)-(.+)$/);
|
|
461
|
+
if (match) {
|
|
386
462
|
return {
|
|
387
|
-
|
|
388
|
-
|
|
463
|
+
stableId: match[1],
|
|
464
|
+
displayName: match[2],
|
|
465
|
+
fullName: fullName,
|
|
389
466
|
};
|
|
390
467
|
}
|
|
391
|
-
|
|
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
|
-
*
|
|
414
|
-
*
|
|
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
|
|
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((
|
|
425
|
-
.
|
|
509
|
+
.filter((name) => name.startsWith('gt-'))
|
|
510
|
+
.map((name) => {
|
|
426
511
|
const parsed = parseSessionName(name);
|
|
427
|
-
return parsed
|
|
512
|
+
return { displayName: parsed ? parsed.displayName : name.replace('gt-', '') };
|
|
428
513
|
});
|
|
429
514
|
} catch (err) {
|
|
430
|
-
return
|
|
515
|
+
return [];
|
|
431
516
|
}
|
|
432
517
|
}
|
|
433
518
|
|
|
434
519
|
/**
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
|
|
439
|
-
|
|
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
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
841
|
-
|
|
842
|
-
|
|
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}
|
|
959
|
+
console.log(` ${RED}Error:${RESET} Session '${sessionName}' not found.`);
|
|
864
960
|
console.log('');
|
|
865
|
-
|
|
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 ${
|
|
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
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
});
|
|
1089
|
+
// Kill the session
|
|
1090
|
+
try {
|
|
1091
|
+
execSync(`tmux kill-session -t ${sessionName}`, {
|
|
1092
|
+
stdio: 'pipe',
|
|
1093
|
+
});
|
|
1003
1094
|
|
|
1004
|
-
|
|
1005
|
-
if (!sessions.includes(sessionName)) {
|
|
1095
|
+
const displayName = parsed ? parsed.displayName : sessionName.replace('gt-', '');
|
|
1006
1096
|
console.log('');
|
|
1007
|
-
console.log(` ${
|
|
1097
|
+
console.log(` ${BOLD_YELLOW}Session ${displayName} has been killed.${RESET}`);
|
|
1008
1098
|
console.log('');
|
|
1009
|
-
console.log(` ${
|
|
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
|
-
}
|
|
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}
|
|
1115
|
+
console.log(` ${RED}Error:${RESET} Session '${sessionName}' not found.`);
|
|
1016
1116
|
console.log('');
|
|
1017
|
-
|
|
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 ${
|
|
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 '${
|
|
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
|
-
|
|
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>
|
|
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: #
|
|
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: #
|
|
1913
|
+
background: #252525;
|
|
1818
1914
|
border-radius: 12px;
|
|
1819
|
-
padding:
|
|
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:
|
|
1951
|
+
font-size: 22px;
|
|
1829
1952
|
font-weight: 600;
|
|
1830
|
-
margin-bottom:
|
|
1953
|
+
margin-bottom: 4px;
|
|
1831
1954
|
text-align: center;
|
|
1832
1955
|
}
|
|
1833
1956
|
|
|
1834
|
-
.session-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
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
|
|
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
|
|
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-
|
|
2005
|
+
.session-row:hover {
|
|
1863
2006
|
background: #2a2f38;
|
|
1864
2007
|
}
|
|
1865
2008
|
|
|
1866
|
-
.session-
|
|
1867
|
-
|
|
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-
|
|
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: #
|
|
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: #
|
|
1901
|
-
border-color: #
|
|
2062
|
+
color: #3fb68b;
|
|
2063
|
+
border-color: #3fb68b;
|
|
1902
2064
|
}
|
|
1903
2065
|
|
|
1904
2066
|
.session-status {
|
|
1905
2067
|
display: inline-block;
|
|
1906
|
-
padding:
|
|
2068
|
+
padding: 4px 10px;
|
|
1907
2069
|
border-radius: 4px;
|
|
1908
|
-
font-size:
|
|
1909
|
-
font-weight:
|
|
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
|
-
|
|
1914
|
-
color: #27c93f;
|
|
2077
|
+
color: #3fb68b;
|
|
1915
2078
|
}
|
|
1916
2079
|
|
|
1917
2080
|
.session-status.disconnected {
|
|
1918
|
-
|
|
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:
|
|
1935
|
-
color:
|
|
1936
|
-
border:
|
|
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:
|
|
2102
|
+
font-weight: 400;
|
|
1941
2103
|
cursor: pointer;
|
|
1942
|
-
transition:
|
|
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: #
|
|
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:
|
|
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: #
|
|
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: #
|
|
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: #
|
|
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: #
|
|
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
|
-
<
|
|
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.
|
|
2248
|
-
//
|
|
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
|
-
<
|
|
2307
|
-
<
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|
|
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: #
|
|
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: #
|
|
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: #
|
|
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:
|
|
2847
|
-
border: 1px solid #
|
|
3076
|
+
background: transparent;
|
|
3077
|
+
border: 1px solid #3a3f48;
|
|
2848
3078
|
border-radius: 6px;
|
|
2849
|
-
color: #
|
|
3079
|
+
color: #d4d4d4;
|
|
2850
3080
|
font-size: 14px;
|
|
2851
|
-
transition:
|
|
3081
|
+
transition: all 0.2s;
|
|
2852
3082
|
}
|
|
2853
3083
|
|
|
2854
3084
|
.form-group input:focus {
|
|
2855
|
-
outline:
|
|
2856
|
-
|
|
3085
|
+
outline: 2px solid #d97757;
|
|
3086
|
+
outline-offset: 2px;
|
|
2857
3087
|
}
|
|
2858
3088
|
|
|
2859
3089
|
.form-group input::placeholder {
|
|
2860
|
-
color: #
|
|
3090
|
+
color: #666666;
|
|
2861
3091
|
}
|
|
2862
3092
|
|
|
2863
3093
|
.error-message {
|
|
2864
|
-
background: rgba(
|
|
2865
|
-
border: 1px solid rgba(
|
|
2866
|
-
color: #
|
|
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:
|
|
2882
|
-
border:
|
|
3111
|
+
background: transparent;
|
|
3112
|
+
border: 1px solid #3a3f48;
|
|
2883
3113
|
border-radius: 6px;
|
|
2884
|
-
color: #
|
|
3114
|
+
color: #d97757;
|
|
2885
3115
|
font-size: 14px;
|
|
2886
|
-
font-weight:
|
|
3116
|
+
font-weight: 400;
|
|
2887
3117
|
cursor: pointer;
|
|
2888
|
-
transition:
|
|
3118
|
+
transition: all 0.2s;
|
|
2889
3119
|
}
|
|
2890
3120
|
|
|
2891
3121
|
.submit-btn:hover {
|
|
2892
|
-
background: #
|
|
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:
|
|
2897
|
-
color: #
|
|
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: #
|
|
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
|
-
|
|
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
|
|
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
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
.
|
|
3149
|
-
.
|
|
3150
|
-
.
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
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:
|
|
3423
|
+
res.end(JSON.stringify({ sessions: response }));
|
|
3167
3424
|
} catch (err) {
|
|
3168
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
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
|
-
|
|
3241
|
-
|
|
3497
|
+
// Generate unique connection ID
|
|
3498
|
+
const connectionId = `sse-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
3242
3499
|
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
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
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
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
|
-
|
|
3257
|
-
|
|
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
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
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
|
-
|
|
3268
|
-
|
|
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
|
-
|
|
3277
|
-
|
|
3278
|
-
res
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
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
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
3362
|
-
// ws -> {
|
|
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,
|
|
3661
|
+
// connectionId -> { res, sessionId, outputHandler, exitHandler }
|
|
3367
3662
|
const sseConnections = new Map();
|
|
3368
3663
|
|
|
3369
3664
|
/**
|
|
3370
|
-
* Find a
|
|
3371
|
-
* Returns
|
|
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
|
|
3374
|
-
|
|
3375
|
-
|
|
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
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
3444
|
-
|
|
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
|
-
|
|
3451
|
-
|
|
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
|
-
|
|
3465
|
-
|
|
3736
|
+
// Look up session by UUID, short ID, or display name
|
|
3737
|
+
const session = await findSessionForWeb(identifier);
|
|
3466
3738
|
|
|
3467
|
-
|
|
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
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
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
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
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
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
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
|
-
|
|
3492
|
-
|
|
3793
|
+
ws.on('message', (data) => {
|
|
3794
|
+
const message = data.toString('utf8');
|
|
3493
3795
|
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
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
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
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,
|
|
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
|
|