Skip to content

Commit 7b7ee03

Browse files
chapterjasonclaude
andcommitted
Persist sessions across server restarts via tmux
Each session is now backed by a tmux session named webshell-<uuid>: - new Session() spawns `tmux new-session -A -s webshell-<id> <shell>`, so restarting the dev server reattaches to the same long-lived tmux session instead of killing the shell. - SessionManager.rehydrate() runs on boot: lists tmux sessions with our prefix, captures each pane's full scrollback via `capture-pane -p -e -J -S -`, and seeds it as the initial history frame. First client connect after a restart replays everything that happened before the crash. - Session title persists as tmux option `@title`; rename pushes it into tmux so it survives restarts too. - Session.kill() now also calls `tmux kill-session` so destroy actually destroys the backing shell. - configureTmux() turns off the status bar per session. - Dockerfile adds the tmux package. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 17f3c97 commit 7b7ee03

5 files changed

Lines changed: 156 additions & 11 deletions

File tree

.devcontainer/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ RUN apt-get update \
88
&& apt-get install -y --no-install-recommends \
99
sudo ca-certificates git openssh-client \
1010
curl jq less vim bash-completion locales unzip \
11-
build-essential python3 \
11+
build-essential python3 tmux \
1212
&& rm -rf /var/lib/apt/lists/* \
1313
&& sed -i 's/^# *\(en_US.UTF-8\)/\1/' /etc/locale.gen \
1414
&& locale-gen \

server/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { mountWsRouter } from "./ws/router.js";
1212
process.env["PORT"] = String(PORT);
1313

1414
const manager = new SessionManager();
15+
await manager.rehydrate();
1516
const server = http.createServer();
1617
mountWsRouter(server, manager);
1718

server/src/session/manager.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,34 @@ import type { CreateSessionRequest, SessionInfo } from "../types/session.js";
33
import { log } from "../utils/log.js";
44
import { defaultCwd, defaultShell } from "../utils/shell.js";
55
import { Session } from "./session.js";
6+
import * as tmux from "./tmux.js";
67

78
export class SessionManager {
89
private readonly sessions = new Map<string, Session>();
910

11+
async rehydrate(): Promise<void> {
12+
const existing = await tmux.listSessions();
13+
for (const t of existing) {
14+
const initialHistory = await tmux.capturePane(t.name);
15+
const session = new Session({
16+
id: t.id,
17+
createdAt: t.createdAt,
18+
title: t.title,
19+
shell: defaultShell(),
20+
cwd: defaultCwd(),
21+
cols: DEFAULT_COLS,
22+
rows: DEFAULT_ROWS,
23+
initialHistory,
24+
});
25+
this.sessions.set(session.id, session);
26+
log("session", "rehydrate", session.id, session.title);
27+
session.onExit((code, signal) => {
28+
log("session", "removed after exit", session.id, "code=", code, "signal=", signal);
29+
this.sessions.delete(session.id);
30+
});
31+
}
32+
}
33+
1034
create(req: CreateSessionRequest): Session {
1135
const session = new Session({
1236
title: req.title ?? `shell-${this.sessions.size + 1}`,

server/src/session/session.ts

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,25 @@ import { randomUUID } from "node:crypto";
33
import { SCROLLBACK_BYTES } from "../config.js";
44
import type { SessionInfo } from "../types/session.js";
55
import { Scrollback } from "./scrollback.js";
6+
import * as tmux from "./tmux.js";
67

78
export type OutputListener = (chunk: string) => void;
89
export type ExitListener = (code: number, signal?: number) => void;
910

1011
export interface SessionOptions {
12+
readonly id?: string;
13+
readonly createdAt?: number;
1114
readonly title: string;
1215
readonly shell: string;
1316
readonly cwd: string;
1417
readonly cols: number;
1518
readonly rows: number;
19+
readonly initialHistory?: string;
1620
}
1721

1822
export class Session {
19-
readonly id: string = randomUUID();
20-
readonly createdAt: number = Date.now();
23+
readonly id: string;
24+
readonly createdAt: number;
2125
readonly shell: string;
2226

2327
private _title: string;
@@ -27,20 +31,40 @@ export class Session {
2731
private readonly scrollback = new Scrollback(SCROLLBACK_BYTES);
2832
private readonly outputListeners = new Set<OutputListener>();
2933
private readonly exitListeners = new Set<ExitListener>();
34+
private readonly tmuxName: string;
3035

3136
constructor(opts: SessionOptions) {
37+
this.id = opts.id ?? randomUUID();
38+
this.createdAt = opts.createdAt ?? Date.now();
3239
this._title = opts.title;
3340
this.shell = opts.shell;
3441
this._cols = opts.cols;
3542
this._rows = opts.rows;
36-
37-
this.pty = spawn(opts.shell, [], {
38-
name: "xterm-256color",
39-
cols: opts.cols,
40-
rows: opts.rows,
41-
cwd: opts.cwd,
42-
env: process.env as Record<string, string>,
43-
});
43+
this.tmuxName = tmux.sessionName(this.id);
44+
45+
if (opts.initialHistory) this.scrollback.append(opts.initialHistory);
46+
47+
this.pty = spawn(
48+
"tmux",
49+
[
50+
"new-session",
51+
"-A",
52+
"-s",
53+
this.tmuxName,
54+
"-x",
55+
String(opts.cols),
56+
"-y",
57+
String(opts.rows),
58+
opts.shell,
59+
],
60+
{
61+
name: "xterm-256color",
62+
cols: opts.cols,
63+
rows: opts.rows,
64+
cwd: opts.cwd,
65+
env: process.env as Record<string, string>,
66+
},
67+
);
4468

4569
this.pty.onData((data) => {
4670
this.scrollback.append(data);
@@ -50,6 +74,14 @@ export class Session {
5074
this.pty.onExit(({ exitCode, signal }) => {
5175
for (const listener of this.exitListeners) listener(exitCode, signal);
5276
});
77+
78+
void this.configureTmux();
79+
}
80+
81+
private async configureTmux(): Promise<void> {
82+
await tmux.setOption(this.tmuxName, "status", "off");
83+
await tmux.setOption(this.tmuxName, "history-limit", "10000");
84+
await tmux.setTitle(this.tmuxName, this._title);
5385
}
5486

5587
get title(): string {
@@ -58,6 +90,7 @@ export class Session {
5890

5991
setTitle(title: string): void {
6092
this._title = title;
93+
void tmux.setTitle(this.tmuxName, title);
6194
}
6295

6396
get cols(): number {
@@ -106,6 +139,7 @@ export class Session {
106139
} catch {
107140
// already dead
108141
}
142+
void tmux.kill(this.tmuxName);
109143
}
110144

111145
info(): SessionInfo {

server/src/session/tmux.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { spawn } from "node:child_process";
2+
3+
const SESSION_PREFIX = "webshell-";
4+
5+
export function sessionName(id: string): string {
6+
return `${SESSION_PREFIX}${id}`;
7+
}
8+
9+
export function idFromName(name: string): string {
10+
return name.slice(SESSION_PREFIX.length);
11+
}
12+
13+
export function isOurs(name: string): boolean {
14+
return name.startsWith(SESSION_PREFIX);
15+
}
16+
17+
export interface TmuxSession {
18+
readonly name: string;
19+
readonly id: string;
20+
readonly title: string;
21+
readonly createdAt: number;
22+
}
23+
24+
function run(args: string[]): Promise<string> {
25+
return new Promise((resolve, reject) => {
26+
const p = spawn("tmux", args, { stdio: ["ignore", "pipe", "pipe"] });
27+
let stdout = "";
28+
let stderr = "";
29+
p.stdout.on("data", (d) => (stdout += d));
30+
p.stderr.on("data", (d) => (stderr += d));
31+
p.on("close", (code) => {
32+
if (code === 0) resolve(stdout);
33+
else reject(new Error(`tmux ${args.join(" ")} exited ${code}: ${stderr.trim()}`));
34+
});
35+
p.on("error", reject);
36+
});
37+
}
38+
39+
export async function listSessions(): Promise<TmuxSession[]> {
40+
try {
41+
const out = await run([
42+
"list-sessions",
43+
"-F",
44+
"#{session_name}\t#{session_created}\t#{@title}",
45+
]);
46+
return out
47+
.split("\n")
48+
.filter(Boolean)
49+
.map((line) => {
50+
const parts = line.split("\t");
51+
const name = parts[0] ?? "";
52+
const created = parts[1] ?? "0";
53+
const rawTitle = parts[2] ?? "";
54+
const title = rawTitle.length > 0 ? rawTitle : name;
55+
return {
56+
name,
57+
id: idFromName(name),
58+
title,
59+
createdAt: Number(created) * 1000,
60+
};
61+
})
62+
.filter((s) => isOurs(s.name));
63+
} catch {
64+
return [];
65+
}
66+
}
67+
68+
export async function capturePane(name: string): Promise<string> {
69+
try {
70+
return await run(["capture-pane", "-p", "-e", "-J", "-S", "-", "-t", name]);
71+
} catch {
72+
return "";
73+
}
74+
}
75+
76+
export async function setTitle(name: string, title: string): Promise<void> {
77+
await run(["set-option", "-t", name, "@title", title]).catch(() => {});
78+
}
79+
80+
export async function setOption(name: string, option: string, value: string): Promise<void> {
81+
await run(["set-option", "-t", name, option, value]).catch(() => {});
82+
}
83+
84+
export async function kill(name: string): Promise<void> {
85+
await run(["kill-session", "-t", name]).catch(() => {});
86+
}

0 commit comments

Comments
 (0)