diff --git a/src/config.ts b/src/config.ts index 3708fef..1700a5b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,15 @@ import os from 'node:os' import type { Config, EnvLike } from './types.js' +import { tmuxAvailable } from './session/tmux.js' + +/** USE_TMUX: '1'/'true'/'on' → on, '0'/'false'/'off' → off, else auto-detect tmux. */ +function resolveUseTmux(raw: string | undefined): boolean { + const v = raw?.trim().toLowerCase() + if (v === '1' || v === 'true' || v === 'on') return true + if (v === '0' || v === 'false' || v === 'off') return false + return tmuxAvailable() // unset / 'auto' +} // ── constants ───────────────────────────────────────────────────────────────── @@ -141,6 +150,8 @@ export function loadConfig(env: EnvLike): Config { const wsPath = env['WS_PATH'] ?? DEFAULT_WS_PATH + const useTmux = resolveUseTmux(env['USE_TMUX']) + const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS']) return Object.freeze({ @@ -152,6 +163,7 @@ export function loadConfig(env: EnvLike): Config { scrollbackBytes, maxPayloadBytes, wsPath, + useTmux, allowedOrigins, } satisfies Config) } diff --git a/src/session/manager.ts b/src/session/manager.ts index 6e84504..82d341f 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -33,6 +33,7 @@ import type { import { WS_OPEN } from '../types.js'; import { serialize } from '../protocol.js'; import { createSession, attachWs, kill } from './session.js'; +import { hasSession, tmuxName } from './tmux.js'; /** * Send a serialised ServerMessage to `ws` only when it is OPEN (M5). @@ -113,6 +114,17 @@ export function createSessionManager(cfg: Config): SessionManager { return existing; } + // ── Case 3.5 (H1): not in our table, but a tmux session survived a restart ─ + // Re-attach with the SAME id so `tmux new-session -A -s web_` reconnects + // to the still-running shell instead of spawning a fresh one. + if (cfg.useTmux && hasSession(tmuxName(sessionId))) { + const revived = createSession(cfg, dims, now, onSessionExit, sessionId); + const kickedRevived = attachWs(revived, ws); + if (kickedRevived !== null) kickedRevived.close(); + sessions = new Map(sessions).set(revived.meta.id, revived); + return revived; + } + // ── Case 4: session not found → create a new one ───────────────────────── // M4: createSession may throw. Do NOT catch here. const session = createSession(cfg, dims, now, onSessionExit); @@ -180,10 +192,18 @@ export function createSessionManager(cfg: Config): SessionManager { return count; } - /** Kill every live session and clear the table (called on SIGINT/SIGTERM). */ + /** + * Called on SIGINT/SIGTERM/close. For non-tmux sessions, kill the PTY. For + * tmux sessions (H1), kill only the client pty — the tmux server keeps the + * shell alive so it survives the restart and can be re-attached. + */ function shutdown(): void { for (const session of sessions.values()) { - kill(session); + if (session.tmuxName !== null) { + session.pty.kill(); // detach client; tmux keeps the shell + } else { + kill(session); + } } sessions = new Map(); } diff --git a/src/session/session.ts b/src/session/session.ts index c98feda..6bfcbb4 100644 --- a/src/session/session.ts +++ b/src/session/session.ts @@ -37,6 +37,7 @@ import type { import { WS_OPEN } from '../types.js'; import { serialize } from '../protocol.js'; import { createRingBuffer } from './ring-buffer.js'; +import { tmuxName, killSession } from './tmux.js'; /** Send a server message to `ws` only if it is OPEN (M5). Never throws on a * closed socket; forwarding to a dead ws is simply a no-op. */ @@ -57,14 +58,21 @@ export function createSession( dims: Dims, now: number, onExit: (session: Session) => void, + // H1: pass an existing id to RE-ATTACH to a surviving tmux session (e.g. after + // a server restart). Default = a fresh id for a brand-new session. + id: string = randomUUID(), ): Session { - // Generate the id first so it can be injected into the shell env: Claude Code - // hooks running inside this shell read $WEBTERM_SESSION to tell us which tab - // an event belongs to, and POST to $WEBTERM_HOOK_URL (H2). - const id = randomUUID(); + // The id is injected into the shell env so Claude Code hooks know which tab an + // event belongs to ($WEBTERM_SESSION) and where to POST ($WEBTERM_HOOK_URL, H2). + const tName = cfg.useTmux ? tmuxName(id) : null; - // M4: let a spawn failure (e.g. missing shell) propagate synchronously. - const pty: IPty = spawn(cfg.shellPath, [], { + // H1: under tmux, the node-pty process is a tmux CLIENT; `new-session -A` + // attaches to web_ if it exists (restart survival) or creates it. + const file = tName !== null ? 'tmux' : cfg.shellPath; + const args = tName !== null ? ['new-session', '-A', '-s', tName, cfg.shellPath] : []; + + // M4: let a spawn failure (e.g. missing shell / tmux) propagate synchronously. + const pty: IPty = spawn(file, args, { name: 'xterm-256color', cols: dims.cols, rows: dims.rows, @@ -91,6 +99,7 @@ export function createSession( exitedAt: null, exitCode: null, claudeStatus: 'unknown', + tmuxName: tName, pty, }; @@ -149,7 +158,12 @@ export function resize(session: Session, cols: number, rows: number): void { session.pty.resize(cols, rows); } -/** Kill the underlying PTY (process exit / idle reclaim). */ +/** + * Kill the session (idle reclaim). Under tmux this ends the actual shell via + * `tmux kill-session` (H1); killing only the client pty would leave the tmux + * session running. Always also kills the client pty. + */ export function kill(session: Session): void { + if (session.tmuxName !== null) killSession(session.tmuxName); session.pty.kill(); } diff --git a/src/session/tmux.ts b/src/session/tmux.ts new file mode 100644 index 0000000..451120f --- /dev/null +++ b/src/session/tmux.ts @@ -0,0 +1,46 @@ +/** + * src/session/tmux.ts (H1) — thin synchronous wrappers around the tmux CLI. + * + * Used only when cfg.useTmux is on. Running the shell inside a tmux session + * lets it survive a server/host restart: the node-pty process is just a tmux + * *client*; killing it detaches, and the tmux server keeps the shell alive. + * + * Every call is best-effort and never throws (tmux missing / session gone are + * treated as "false"/no-op). + */ + +import { execFileSync } from 'node:child_process' + +/** Is the tmux binary available on PATH? */ +export function tmuxAvailable(): boolean { + try { + execFileSync('tmux', ['-V'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +/** tmux session name for a web-terminal session id (UUIDs are tmux-safe). */ +export function tmuxName(sessionId: string): string { + return `web_${sessionId}` +} + +/** Does a tmux session with this name already exist (e.g. after a restart)? */ +export function hasSession(name: string): boolean { + try { + execFileSync('tmux', ['has-session', '-t', name], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +/** Kill a tmux session (ends the shell). No-op if it's already gone. */ +export function killSession(name: string): void { + try { + execFileSync('tmux', ['kill-session', '-t', name], { stdio: 'ignore' }) + } catch { + // already gone — fine + } +} diff --git a/src/types.ts b/src/types.ts index 4969c20..5cb01ba 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,6 +27,7 @@ export interface Config { readonly scrollbackBytes: number; // ring buffer capacity, default 2MB readonly maxPayloadBytes: number; // max WS frame bytes, default 1MB (L5) readonly wsPath: string; // WS upgrade path, default '/term' (L3; invariant 8) + readonly useTmux: boolean; // H1: spawn the shell inside tmux so it survives a server restart readonly allowedOrigins: readonly string[]; // derived from NIC IPs, NOT bindHost (M1) } @@ -149,6 +150,8 @@ export interface Session { exitCode: number | null; /** Claude Code activity from hooks (H2); updated by manager.handleHookEvent. */ claudeStatus: ClaudeStatus; + /** tmux session name (H1) when running under tmux, else null. */ + readonly tmuxName: string | null; readonly pty: IPty; } diff --git a/test/integration/server.test.ts b/test/integration/server.test.ts index 81553b7..f8eb4bc 100644 --- a/test/integration/server.test.ts +++ b/test/integration/server.test.ts @@ -25,6 +25,7 @@ */ import net from 'node:net' +import { execFileSync } from 'node:child_process' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import WebSocket from 'ws' @@ -49,6 +50,19 @@ const PTY_AVAILABLE = (() => { })() const itPty = PTY_AVAILABLE ? it : it.skip +/** H1 tmux tests need real PTY + the tmux binary. */ +const TMUX_OK = + PTY_AVAILABLE && + (() => { + try { + execFileSync('tmux', ['-V'], { stdio: 'ignore' }) + return true + } catch { + return false + } + })() +const itTmux = TMUX_OK ? it : it.skip + // ── Helpers ────────────────────────────────────────────────────────────────── /** Pick a free port on 127.0.0.1 by briefly binding to port 0. */ @@ -88,6 +102,7 @@ function makeTestConfig(port: number, shellPath: string, maxPayloadBytes = 512 * // Keep idle TTL tiny so stray sessions don't linger. IDLE_TTL: '86400', MAX_PAYLOAD_BYTES: String(maxPayloadBytes), + USE_TMUX: '0', // default off so most cases don't spawn/leak tmux sessions }) } @@ -638,4 +653,81 @@ describe('startServer — integration', () => { await waitForClose(ws, 3_000).catch(() => undefined) }, ) + + // ── H1: tmux keepalive — a shell survives a full server restart ──────────── + itTmux( + 'H1 [needs tmux + real PTY] shell state survives a server restart', + async () => { + const delay = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) + const isAttached = (m: unknown): boolean => + typeof m === 'object' && m !== null && (m as Record)['type'] === 'attached' + + const tport = await getFreePort() + const tcfg = loadConfig({ + PORT: String(tport), + BIND_HOST: '127.0.0.1', + SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', + ALLOWED_ORIGINS: `http://127.0.0.1:${tport}`, + IDLE_TTL: '86400', + USE_TMUX: '1', + }) + + let sid = '' + let srv = startServer(tcfg) + await delay(150) + try { + // Attach and set a shell variable inside the tmux shell. + const ws1 = new WebSocket(`ws://127.0.0.1:${tport}/term`, { + headers: { Origin: `http://127.0.0.1:${tport}` }, + }) + await waitForOpen(ws1, 3_000) + ws1.send(JSON.stringify({ type: 'attach', sessionId: null })) + const att = (await waitForMessage(ws1, isAttached, 5_000)) as Record + sid = att['sessionId'] as string + await delay(500) + ws1.send(JSON.stringify({ type: 'input', data: 'WEBTERM_MARK=tmuxlives\r' })) + await delay(500) + ws1.close() + await waitForClose(ws1, 3_000).catch(() => undefined) + + // Restart the server — shutdown must keep the tmux session alive (H1). + await srv.close() + srv = startServer(tcfg) + await delay(150) + + // Reconnect with the SAME sessionId → re-attach to the surviving shell. + const ws2 = new WebSocket(`ws://127.0.0.1:${tport}/term`, { + headers: { Origin: `http://127.0.0.1:${tport}` }, + }) + await waitForOpen(ws2, 3_000) + const out: string[] = [] + ws2.on('message', (raw) => { + try { + const m = JSON.parse(raw.toString('utf8')) as Record + if (m['type'] === 'output' && typeof m['data'] === 'string') out.push(m['data']) + } catch { + /* ignore */ + } + }) + ws2.send(JSON.stringify({ type: 'attach', sessionId: sid })) + await delay(400) + ws2.send(JSON.stringify({ type: 'input', data: 'echo MARK-$WEBTERM_MARK\r' })) + await delay(900) + // The variable set before the restart is still set → same shell survived. + expect(out.join('')).toContain('MARK-tmuxlives') + ws2.close() + await waitForClose(ws2, 3_000).catch(() => undefined) + } finally { + await srv.close() + if (sid !== '') { + try { + execFileSync('tmux', ['kill-session', '-t', `web_${sid}`], { stdio: 'ignore' }) + } catch { + /* already gone */ + } + } + } + }, + 20_000, + ) }) diff --git a/test/manager.test.ts b/test/manager.test.ts index f0c1383..d5f5a89 100644 --- a/test/manager.test.ts +++ b/test/manager.test.ts @@ -46,6 +46,7 @@ const CFG: Config = { scrollbackBytes: 2 * 1024 * 1024, maxPayloadBytes: 1024 * 1024, wsPath: '/term', + useTmux: false, allowedOrigins: [], }; diff --git a/test/session.test.ts b/test/session.test.ts index e93ed46..70f990b 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -48,6 +48,7 @@ const CFG: Config = { scrollbackBytes: 2 * 1024 * 1024, maxPayloadBytes: 1024 * 1024, wsPath: '/term', + useTmux: false, allowedOrigins: [], };