@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.
@@ -0,0 +1,587 @@
1
+ /**
2
+ * Session Manager
3
+ *
4
+ * Core session management without tmux. Handles:
5
+ * - Direct PTY spawning via node-pty
6
+ * - Session persistence to disk
7
+ * - Session restoration on server restart
8
+ * - Multi-client connections
9
+ */
10
+
11
+ import { randomUUID } from 'crypto';
12
+ import { EventEmitter } from 'events';
13
+ import { existsSync, mkdirSync } from 'fs';
14
+ import { homedir } from 'os';
15
+ import path from 'path';
16
+ import type { IPty } from '@lydell/node-pty';
17
+ import fs from 'fs/promises';
18
+
19
+ import { HistoryReplay } from './history-replay.js';
20
+ import { type OutputChunk, OutputRecorder } from './output-recorder.js';
21
+ import type {
22
+ CreateSessionOptions,
23
+ Session,
24
+ SessionId,
25
+ SessionInfo,
26
+ SessionManagerConfig,
27
+ SessionPaths,
28
+ } from './types.js';
29
+
30
+ // Dynamic import for node-pty (CommonJS module)
31
+ let ptyModule: typeof import('@lydell/node-pty') | null = null;
32
+
33
+ async function getPty(): Promise<typeof import('@lydell/node-pty')> {
34
+ if (!ptyModule) {
35
+ ptyModule = await import('@lydell/node-pty');
36
+ }
37
+ return ptyModule;
38
+ }
39
+
40
+ /**
41
+ * Default configuration values.
42
+ */
43
+ const DEFAULTS = {
44
+ storageDir: path.join(homedir(), '.config', 'ghosttown', 'sessions'),
45
+ scrollbackLimit: 10_000,
46
+ defaultShell: process.env.SHELL || '/bin/sh',
47
+ defaultCols: 80,
48
+ defaultRows: 24,
49
+ };
50
+
51
+ /**
52
+ * Get the default shell for the current platform.
53
+ */
54
+ function getDefaultShell(): string {
55
+ if (process.platform === 'win32') {
56
+ return process.env.COMSPEC || 'cmd.exe';
57
+ }
58
+ return process.env.SHELL || '/bin/sh';
59
+ }
60
+
61
+ /**
62
+ * Get default environment variables for terminal sessions.
63
+ */
64
+ function getDefaultEnv(): Record<string, string> {
65
+ return {
66
+ TERM: 'xterm-256color',
67
+ COLORTERM: 'truecolor',
68
+ LANG: process.env.LANG || 'en_US.UTF-8',
69
+ LC_ALL: process.env.LC_ALL || 'en_US.UTF-8',
70
+ TERM_PROGRAM: 'ghosttown',
71
+ };
72
+ }
73
+
74
+ /**
75
+ * SessionManager handles all session lifecycle operations.
76
+ *
77
+ * @example
78
+ * ```typescript
79
+ * const manager = new SessionManager();
80
+ * await manager.init();
81
+ *
82
+ * // Create a new session
83
+ * const session = await manager.createSession({ name: 'dev' });
84
+ *
85
+ * // Get the PTY for I/O
86
+ * const pty = manager.getPty(session.id);
87
+ * pty.onData((data) => console.log(data));
88
+ * pty.write('echo hello\n');
89
+ *
90
+ * // List all sessions
91
+ * const sessions = await manager.listSessions();
92
+ *
93
+ * // Delete a session
94
+ * await manager.deleteSession(session.id);
95
+ * ```
96
+ */
97
+ export class SessionManager extends EventEmitter {
98
+ private config: Required<SessionManagerConfig>;
99
+ private paths: SessionPaths;
100
+ private sessions: Map<SessionId, Session> = new Map();
101
+ private ptyProcesses: Map<SessionId, IPty> = new Map();
102
+ private outputRecorders: Map<SessionId, OutputRecorder> = new Map(); // Disk-backed scrollback
103
+ private initialized = false;
104
+
105
+ constructor(config: SessionManagerConfig = {}) {
106
+ super();
107
+
108
+ this.config = {
109
+ storageDir: config.storageDir ?? DEFAULTS.storageDir,
110
+ scrollbackLimit: config.scrollbackLimit ?? DEFAULTS.scrollbackLimit,
111
+ defaultShell: config.defaultShell ?? getDefaultShell(),
112
+ };
113
+
114
+ this.paths = {
115
+ baseDir: this.config.storageDir,
116
+ sessionDir: (id: SessionId) => path.join(this.config.storageDir, id),
117
+ metadataFile: (id: SessionId) => path.join(this.config.storageDir, id, 'metadata.json'),
118
+ scrollbackFile: (id: SessionId) => path.join(this.config.storageDir, id, 'scrollback.jsonl'),
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Initialize the session manager.
124
+ * Loads persisted sessions from disk.
125
+ */
126
+ async init(): Promise<void> {
127
+ if (this.initialized) return;
128
+
129
+ // Ensure storage directory exists
130
+ if (!existsSync(this.paths.baseDir)) {
131
+ mkdirSync(this.paths.baseDir, { recursive: true });
132
+ }
133
+
134
+ // Load persisted sessions
135
+ await this.loadPersistedSessions();
136
+
137
+ this.initialized = true;
138
+ }
139
+
140
+ /**
141
+ * Create a new session.
142
+ */
143
+ async createSession(options: CreateSessionOptions = {}): Promise<Session> {
144
+ if (!this.initialized) {
145
+ throw new Error('SessionManager not initialized. Call init() first.');
146
+ }
147
+
148
+ const id = randomUUID();
149
+ const displayName = options.name ?? this.generateDisplayName();
150
+ const now = Date.now();
151
+
152
+ const session: Session = {
153
+ id,
154
+ displayName,
155
+ createdAt: now,
156
+ lastActivity: now,
157
+ process: {
158
+ pid: null,
159
+ shell: options.shell ?? this.config.defaultShell,
160
+ args: options.args ?? [],
161
+ cwd: options.cwd ?? homedir(),
162
+ env: {
163
+ ...getDefaultEnv(),
164
+ ...options.env,
165
+ },
166
+ },
167
+ terminal: {
168
+ cols: options.cols ?? DEFAULTS.defaultCols,
169
+ rows: options.rows ?? DEFAULTS.defaultRows,
170
+ cursorX: 0,
171
+ cursorY: 0,
172
+ cursorStyle: 'block',
173
+ cursorVisible: true,
174
+ },
175
+ scrollbackFile: 'scrollback.jsonl',
176
+ scrollbackLength: 0,
177
+ sharing: null,
178
+ };
179
+
180
+ // Create session directory
181
+ const sessionDir = this.paths.sessionDir(id);
182
+ mkdirSync(sessionDir, { recursive: true });
183
+
184
+ // Spawn PTY process if requested (default: true)
185
+ // Web sessions use startProcess: false to wait for client connection
186
+ if (options.startProcess !== false) {
187
+ await this.spawnProcess(session);
188
+ }
189
+
190
+ // Save to memory and disk
191
+ this.sessions.set(id, session);
192
+ await this.persistSession(session);
193
+
194
+ this.emit('session:created', session);
195
+
196
+ return session;
197
+ }
198
+
199
+ /**
200
+ * Get a session by ID.
201
+ */
202
+ getSession(sessionId: SessionId): Session | undefined {
203
+ return this.sessions.get(sessionId);
204
+ }
205
+
206
+ /**
207
+ * Get the PTY process for a session.
208
+ * Returns undefined if the process is not running.
209
+ */
210
+ getPty(sessionId: SessionId): IPty | undefined {
211
+ return this.ptyProcesses.get(sessionId);
212
+ }
213
+
214
+ /**
215
+ * List all sessions with summary info.
216
+ */
217
+ async listSessions(): Promise<SessionInfo[]> {
218
+ const sessions: SessionInfo[] = [];
219
+
220
+ for (const session of this.sessions.values()) {
221
+ sessions.push({
222
+ id: session.id,
223
+ displayName: session.displayName,
224
+ lastActivity: session.lastActivity,
225
+ isRunning: session.process.pid !== null,
226
+ connectionCount: 0, // TODO: Track connections
227
+ isShared: session.sharing?.enabled ?? false,
228
+ });
229
+ }
230
+
231
+ // Sort by last activity (most recent first)
232
+ sessions.sort((a, b) => b.lastActivity - a.lastActivity);
233
+
234
+ return sessions;
235
+ }
236
+
237
+ /**
238
+ * Delete a session and all associated data.
239
+ */
240
+ async deleteSession(sessionId: SessionId): Promise<void> {
241
+ const session = this.sessions.get(sessionId);
242
+ if (!session) {
243
+ throw new Error(`Session not found: ${sessionId}`);
244
+ }
245
+
246
+ // Kill the PTY process if running
247
+ const pty = this.ptyProcesses.get(sessionId);
248
+ if (pty) {
249
+ pty.kill();
250
+ this.ptyProcesses.delete(sessionId);
251
+ }
252
+
253
+ // Close the output recorder
254
+ const recorder = this.outputRecorders.get(sessionId);
255
+ if (recorder) {
256
+ await recorder.close();
257
+ this.outputRecorders.delete(sessionId);
258
+ }
259
+
260
+ // Remove from memory
261
+ this.sessions.delete(sessionId);
262
+
263
+ // Remove from disk
264
+ const sessionDir = this.paths.sessionDir(sessionId);
265
+ if (existsSync(sessionDir)) {
266
+ await fs.rm(sessionDir, { recursive: true, force: true });
267
+ }
268
+
269
+ this.emit('session:deleted', sessionId);
270
+ }
271
+
272
+ /**
273
+ * Rename a session.
274
+ */
275
+ async renameSession(sessionId: SessionId, newName: string): Promise<void> {
276
+ const session = this.sessions.get(sessionId);
277
+ if (!session) {
278
+ throw new Error(`Session not found: ${sessionId}`);
279
+ }
280
+
281
+ session.displayName = newName;
282
+ session.lastActivity = Date.now();
283
+ await this.persistSession(session);
284
+ }
285
+
286
+ /**
287
+ * Resize a session's terminal.
288
+ */
289
+ async resizeSession(sessionId: SessionId, cols: number, rows: number): Promise<void> {
290
+ const session = this.sessions.get(sessionId);
291
+ if (!session) {
292
+ throw new Error(`Session not found: ${sessionId}`);
293
+ }
294
+
295
+ // Update session state first
296
+ session.terminal.cols = cols;
297
+ session.terminal.rows = rows;
298
+ session.lastActivity = Date.now();
299
+
300
+ // Persist before attempting PTY resize (so state is saved even if PTY fails)
301
+ await this.persistSession(session);
302
+
303
+ // Try to resize the PTY (may fail if process exited)
304
+ const pty = this.ptyProcesses.get(sessionId);
305
+ if (pty) {
306
+ try {
307
+ pty.resize(cols, rows);
308
+ } catch {
309
+ // PTY may have exited, that's ok - state is already updated
310
+ }
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Connect to a session, respawning the process if needed.
316
+ * Returns the PTY for I/O.
317
+ */
318
+ async connectToSession(sessionId: SessionId): Promise<IPty> {
319
+ const session = this.sessions.get(sessionId);
320
+ if (!session) {
321
+ throw new Error(`Session not found: ${sessionId}`);
322
+ }
323
+
324
+ // Respawn process if it's not running
325
+ const hasPty = this.ptyProcesses.has(sessionId);
326
+
327
+ if (!hasPty) {
328
+ await this.spawnProcess(session);
329
+ }
330
+
331
+ const pty = this.ptyProcesses.get(sessionId);
332
+ if (!pty) {
333
+ throw new Error(`Failed to get PTY for session: ${sessionId}`);
334
+ }
335
+
336
+ session.lastActivity = Date.now();
337
+ await this.persistSession(session);
338
+
339
+ return pty;
340
+ }
341
+
342
+ /**
343
+ * Write data to a session's PTY.
344
+ */
345
+ write(sessionId: SessionId, data: string): void {
346
+ const pty = this.ptyProcesses.get(sessionId);
347
+ if (!pty) {
348
+ throw new Error(`No PTY for session: ${sessionId}`);
349
+ }
350
+
351
+ pty.write(data);
352
+
353
+ const session = this.sessions.get(sessionId);
354
+ if (session) {
355
+ session.lastActivity = Date.now();
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Get the storage paths for session data.
361
+ */
362
+ getPaths(): SessionPaths {
363
+ return this.paths;
364
+ }
365
+
366
+ /**
367
+ * Get the buffered output for a session (for replay on reconnect).
368
+ * Returns the concatenated output as a single string.
369
+ * Note: This loads all scrollback into memory. For large histories,
370
+ * use getScrollbackChunks() instead.
371
+ */
372
+ async getScrollback(sessionId: SessionId): Promise<string> {
373
+ const recorder = this.outputRecorders.get(sessionId);
374
+ if (!recorder) {
375
+ return '';
376
+ }
377
+
378
+ const chunks = await recorder.readAll();
379
+ return chunks.map((c) => c.d).join('');
380
+ }
381
+
382
+ /**
383
+ * Get scrollback chunks for a session with pagination.
384
+ * @param sessionId Session ID
385
+ * @param offset Number of chunks to skip from the start
386
+ * @param limit Maximum number of chunks to return
387
+ */
388
+ async getScrollbackChunks(
389
+ sessionId: SessionId,
390
+ offset = 0,
391
+ limit = 1000
392
+ ): Promise<OutputChunk[]> {
393
+ const recorder = this.outputRecorders.get(sessionId);
394
+ if (!recorder) {
395
+ return [];
396
+ }
397
+
398
+ return recorder.read(offset, limit);
399
+ }
400
+
401
+ /**
402
+ * Get the total number of scrollback chunks for a session.
403
+ */
404
+ getScrollbackLength(sessionId: SessionId): number {
405
+ const recorder = this.outputRecorders.get(sessionId);
406
+ return recorder ? recorder.getChunkCount() : 0;
407
+ }
408
+
409
+ /**
410
+ * Create a history replay for streaming scrollback to a client.
411
+ * The replay emits 'data', 'progress', and 'complete' events.
412
+ */
413
+ async createHistoryReplay(sessionId: SessionId): Promise<HistoryReplay | null> {
414
+ const recorder = this.outputRecorders.get(sessionId);
415
+ if (!recorder) {
416
+ return null;
417
+ }
418
+
419
+ const chunks = await recorder.readAll();
420
+ const replay = new HistoryReplay({
421
+ chunkSize: 100,
422
+ batchDelay: 10,
423
+ });
424
+
425
+ // Start replay in next tick to allow event handler setup
426
+ setImmediate(() => {
427
+ replay.start(chunks).catch((err) => replay.emit('error', err));
428
+ });
429
+
430
+ return replay;
431
+ }
432
+
433
+ /**
434
+ * Spawn a PTY process for a session.
435
+ */
436
+ private async spawnProcess(session: Session): Promise<void> {
437
+ const pty = await getPty();
438
+
439
+ const ptyProcess = pty.spawn(session.process.shell, session.process.args, {
440
+ name: 'xterm-256color',
441
+ cols: session.terminal.cols,
442
+ rows: session.terminal.rows,
443
+ cwd: session.process.cwd,
444
+ env: {
445
+ ...process.env,
446
+ ...session.process.env,
447
+ },
448
+ });
449
+
450
+ session.process.pid = ptyProcess.pid;
451
+ this.ptyProcesses.set(session.id, ptyProcess);
452
+
453
+ // Initialize output recorder for scrollback (disk-backed)
454
+ if (!this.outputRecorders.has(session.id)) {
455
+ const recorder = new OutputRecorder({
456
+ filePath: this.paths.scrollbackFile(session.id),
457
+ maxChunks: this.config.scrollbackLimit,
458
+ });
459
+ await recorder.init();
460
+ this.outputRecorders.set(session.id, recorder);
461
+ }
462
+
463
+ // Handle output
464
+ ptyProcess.onData((data) => {
465
+ // Record output to disk-backed scrollback
466
+ const recorder = this.outputRecorders.get(session.id);
467
+ if (recorder) {
468
+ recorder.record(data);
469
+ }
470
+
471
+ this.emit('session:output', session.id, data);
472
+ session.lastActivity = Date.now();
473
+ });
474
+
475
+ // Handle exit
476
+ ptyProcess.onExit(({ exitCode }) => {
477
+ session.process.pid = null;
478
+ this.ptyProcesses.delete(session.id);
479
+
480
+ // Flush scrollback before persisting session
481
+ const recorder = this.outputRecorders.get(session.id);
482
+ if (recorder) {
483
+ recorder.flush().catch(console.error);
484
+ }
485
+
486
+ this.persistSession(session).catch(console.error);
487
+ this.emit('session:exit', session.id, exitCode);
488
+ });
489
+ }
490
+
491
+ /**
492
+ * Persist a session's metadata to disk.
493
+ */
494
+ private async persistSession(session: Session): Promise<void> {
495
+ const metadataPath = this.paths.metadataFile(session.id);
496
+ const data = JSON.stringify(session, null, 2);
497
+ await fs.writeFile(metadataPath, data, 'utf-8');
498
+ }
499
+
500
+ /**
501
+ * Load persisted sessions from disk.
502
+ */
503
+ private async loadPersistedSessions(): Promise<void> {
504
+ const baseDir = this.paths.baseDir;
505
+
506
+ if (!existsSync(baseDir)) {
507
+ return;
508
+ }
509
+
510
+ const entries = await fs.readdir(baseDir, { withFileTypes: true });
511
+
512
+ for (const entry of entries) {
513
+ if (!entry.isDirectory()) continue;
514
+
515
+ const sessionId = entry.name;
516
+ const metadataPath = this.paths.metadataFile(sessionId);
517
+
518
+ if (!existsSync(metadataPath)) continue;
519
+
520
+ try {
521
+ const data = await fs.readFile(metadataPath, 'utf-8');
522
+ const session: Session = JSON.parse(data);
523
+
524
+ // Mark process as not running (it died when server stopped)
525
+ session.process.pid = null;
526
+
527
+ this.sessions.set(sessionId, session);
528
+ } catch (error) {
529
+ console.error(`Failed to load session ${sessionId}:`, error);
530
+ }
531
+ }
532
+ }
533
+
534
+ /**
535
+ * Generate a display name for a new session.
536
+ * Returns the next available number (1, 2, 3, ...).
537
+ */
538
+ private generateDisplayName(): string {
539
+ const existingNames = new Set(Array.from(this.sessions.values()).map((s) => s.displayName));
540
+
541
+ let num = 1;
542
+ while (existingNames.has(String(num))) {
543
+ num++;
544
+ }
545
+
546
+ return String(num);
547
+ }
548
+
549
+ /**
550
+ * Clean up all resources.
551
+ */
552
+ async destroy(): Promise<void> {
553
+ // Kill all PTY processes
554
+ for (const pty of this.ptyProcesses.values()) {
555
+ pty.kill();
556
+ }
557
+ this.ptyProcesses.clear();
558
+
559
+ // Close all output recorders (flushes pending data)
560
+ for (const recorder of this.outputRecorders.values()) {
561
+ await recorder.close();
562
+ }
563
+ this.outputRecorders.clear();
564
+
565
+ // Persist all sessions
566
+ for (const session of this.sessions.values()) {
567
+ await this.persistSession(session);
568
+ }
569
+
570
+ this.removeAllListeners();
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Singleton instance for convenience.
576
+ * For most use cases, you can use this directly.
577
+ */
578
+ let defaultManager: SessionManager | null = null;
579
+
580
+ export function getSessionManager(config?: SessionManagerConfig): SessionManager {
581
+ if (!defaultManager) {
582
+ defaultManager = new SessionManager(config);
583
+ }
584
+ return defaultManager;
585
+ }
586
+
587
+ export { getDefaultShell, getDefaultEnv };