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