Skip to content

Commit f45399f

Browse files
chapterjasonclaude
andcommitted
Swap tmux for dtach, clean up copy/paste UX
Session persistence now runs under dtach instead of tmux: a tiny PTY holder with no mouse capture, no alt-screen tricks, and no scrollback trilemma. Per-session state lives in a state dir (WEB_SHELL_STATE_DIR, default ~/.cache/web-shell/sessions) as <id>.sock / .json / .log / .pid, with the log appended per PTY chunk so server crashes don't drop tail output. Added graceful shutdown so SIGINT/SIGTERM closes connections, drains PTYs, then detaches without killing shells. Client-side: auto-copy on Shift+drag selection, Ctrl/Cmd+C copies if there's a selection else sends SIGINT, Ctrl/Cmd+V swallows the keydown so xterm's paste event handles it without a duplicate. Wheel events are swallowed while in the alternate screen so they don't turn into bash history navigation. Bracketed-paste mode is stripped from the PTY stream (bash no longer highlights pastes). Replay sanitization also strips ED and CUP so rehydrate keeps prior output visible instead of clear-screening it away. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 89cb683 commit f45399f

11 files changed

Lines changed: 284 additions & 169 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 tmux \
11+
build-essential python3 dtach \
1212
&& rm -rf /var/lib/apt/lists/* \
1313
&& sed -i 's/^# *\(en_US.UTF-8\)/\1/' /etc/locale.gen \
1414
&& locale-gen \

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
# web-shell
22

3-
Persistent browser terminal. Spawns shells inside `tmux` sessions on a Node.js backend and streams them over WebSocket to [xterm.js](https://xtermjs.org/). Sessions live server-side with a scrollback buffer and survive page refreshes, device switches, _and_ server restarts — `tmux` keeps the shells running, and the server reattaches to them on boot.
3+
Persistent browser terminal. Spawns shells under `dtach` on a Node.js backend and streams them over WebSocket to [xterm.js](https://xtermjs.org/). Sessions live server-side with a scrollback buffer and survive page refreshes, device switches, _and_ server restarts — `dtach` keeps the shells running, and the server reattaches to them on boot.
44

55
## Stack
66

7-
- **Server**: Node.js + TypeScript, `node-pty`, `ws`, `tmux` (required on `$PATH`)
7+
- **Server**: Node.js + TypeScript, `node-pty`, `ws`, `dtach` (required on `$PATH`)
88
- **Client**: TypeScript + SCSS + Vite, `xterm.js`
99
- Strict TS everywhere (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, …)
1010

1111
## Requirements
1212

1313
- Node.js 20+
14-
- `tmux` installed and on `$PATH` — every session is a `tmux new-session -A -s webshell-<id>` under the hood.
14+
- `dtach` installed and on `$PATH` — every session runs under `dtach -A <socket>` so the shell survives Node restarts.
1515

1616
## Layout
1717

@@ -91,9 +91,9 @@ Server → client:
9191

9292
## Persistence model
9393

94-
Each `Session` is a `tmux` session named `webshell-<uuid>`, spawned under `node-pty` with a bundled `tmux.conf` (`server/tmux.conf`). `SessionManager` owns the live wrappers and keeps the last 256 KB of PTY output in a ring buffer per session. New WebSocket connections receive a sanitized `history` frame with the current buffer before live `output` streams, so the terminal repaints to the current state on refresh.
94+
Each `Session` spawns a shell under `dtach -A <sock>` via `node-pty`. `SessionManager` owns the live wrappers and keeps a ring buffer of recent PTY output in memory, also append-only mirrored to a log file next to the socket. New WebSocket connections receive a sanitized `history` frame with the current buffer before live `output` streams, so the terminal repaints to the current state on refresh.
9595

96-
Because the shells run inside `tmux`, they outlive the Node process. On startup, the server enumerates existing `webshell-*` tmux sessions, reattaches to each one, and seeds its scrollback from `tmux capture-pane`. Killing a session via the API runs `tmux kill-session`.
96+
Because the shells run under `dtach`, they outlive the Node process. Session state lives in `$WEB_SHELL_STATE_DIR` (default `~/.cache/web-shell/sessions/`): one `<id>.sock` (dtach), `<id>.json` (metadata), `<id>.log` (scrollback), `<id>.pid` (shell PID) per session. On startup, the server enumerates live sockets, reattaches to each, and seeds its scrollback from the log tail. Killing a session via the API `SIGHUP`s the shell and removes the session files. On graceful shutdown (SIGINT/SIGTERM) the server detaches cleanly so shells keep running.
9797

9898
The active session id is stored in `localStorage` so reloads reopen the same session automatically.
9999

@@ -106,6 +106,7 @@ The active session id is stored in `localStorage` so reloads reopen the same ses
106106
| `SHELL` | env / `bash` | Default shell for new sessions |
107107
| `ALLOWED_ORIGINS` | `http://localhost:5173,http://127.0.0.1:5173` | Comma-separated origin allow-list. Requests with a disallowed `Origin` are rejected (HTTP 403 / WS 403). Required for the frontend you actually deploy. |
108108
| `AUTH_TOKEN` | _unset_ | Optional shared bearer token. When set, REST requires `Authorization: Bearer <token>` and WS requires `?token=<token>`. When unset, auth is disabled — only safe behind an authenticated upstream (Coder agent, SSO proxy, Tailscale, etc.). |
109+
| `WEB_SHELL_STATE_DIR` | `~/.cache/web-shell/sessions` | Directory holding per-session dtach sockets, metadata, logs, and pid files. |
109110

110111
## Security model
111112

client/src/terminal/factory.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,44 @@ export function createTerminal(container: HTMLElement): TerminalBundle {
4646
term.open(container);
4747
fit.fit();
4848

49+
const viewport = term.element;
50+
if (viewport) {
51+
viewport.addEventListener(
52+
"wheel",
53+
(event) => {
54+
if (term.buffer.active.type === "alternate") {
55+
event.preventDefault();
56+
event.stopPropagation();
57+
}
58+
},
59+
{ capture: true },
60+
);
61+
viewport.addEventListener("paste", () => {
62+
setTimeout(() => term.clearSelection(), 0);
63+
});
64+
}
65+
66+
term.onSelectionChange(() => {
67+
const selection = term.getSelection();
68+
if (!selection) return;
69+
void navigator.clipboard?.writeText(selection);
70+
});
71+
72+
term.attachCustomKeyEventHandler((event) => {
73+
if (event.type !== "keydown") return true;
74+
const mod = event.ctrlKey || event.metaKey;
75+
if (mod && !event.shiftKey && !event.altKey && event.key.toLowerCase() === "c" && term.hasSelection()) {
76+
void navigator.clipboard?.writeText(term.getSelection());
77+
term.clearSelection();
78+
return false;
79+
}
80+
if (mod && !event.altKey && event.key.toLowerCase() === "v") {
81+
term.clearSelection();
82+
return false;
83+
}
84+
return true;
85+
});
86+
4987
requestAnimationFrame(() => {
5088
tryEnableGpuRenderer(term, fit);
5189
});

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
},
1010
"files": [
1111
"server/dist",
12-
"server/tmux.conf",
1312
"client/dist"
1413
],
1514
"scripts": {

server/src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,19 @@ server.listen(PORT, HOST, () => {
4040
const authState = isAuthDisabled() ? "disabled (trust upstream)" : "enabled";
4141
console.log(`[web-shell] http://${HOST}:${PORT} · auth ${authState} · ${mode}`);
4242
});
43+
44+
let shuttingDown = false;
45+
const shutdown = (signal: NodeJS.Signals) => {
46+
if (shuttingDown) return;
47+
shuttingDown = true;
48+
console.log(`[web-shell] ${signal} received, shutting down gracefully`);
49+
server.closeAllConnections?.();
50+
server.close();
51+
setTimeout(() => {
52+
manager.detachAll();
53+
process.exit(0);
54+
}, 500);
55+
};
56+
57+
process.on("SIGTERM", () => shutdown("SIGTERM"));
58+
process.on("SIGINT", () => shutdown("SIGINT"));

server/src/session/dtach.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2+
import { homedir } from "node:os";
3+
import { join } from "node:path";
4+
5+
const STATE_DIR =
6+
process.env.WEB_SHELL_STATE_DIR ?? join(homedir(), ".cache", "web-shell", "sessions");
7+
8+
export function ensureStateDir(): string {
9+
mkdirSync(STATE_DIR, { recursive: true });
10+
return STATE_DIR;
11+
}
12+
13+
export function socketPath(id: string): string {
14+
return join(ensureStateDir(), `${id}.sock`);
15+
}
16+
17+
export function metaPath(id: string): string {
18+
return join(ensureStateDir(), `${id}.json`);
19+
}
20+
21+
export function logPath(id: string): string {
22+
return join(ensureStateDir(), `${id}.log`);
23+
}
24+
25+
export function pidPath(id: string): string {
26+
return join(ensureStateDir(), `${id}.pid`);
27+
}
28+
29+
export interface SessionMeta {
30+
readonly id: string;
31+
readonly title: string;
32+
readonly shell: string;
33+
readonly createdAt: number;
34+
}
35+
36+
export function writeMeta(meta: SessionMeta): void {
37+
try {
38+
writeFileSync(metaPath(meta.id), JSON.stringify(meta));
39+
} catch {
40+
// ignore
41+
}
42+
}
43+
44+
export function readMeta(id: string): SessionMeta | null {
45+
try {
46+
return JSON.parse(readFileSync(metaPath(id), "utf8")) as SessionMeta;
47+
} catch {
48+
return null;
49+
}
50+
}
51+
52+
function isSocket(p: string): boolean {
53+
try {
54+
return statSync(p).isSocket();
55+
} catch {
56+
return false;
57+
}
58+
}
59+
60+
export function listSessionIds(): string[] {
61+
ensureStateDir();
62+
try {
63+
return readdirSync(STATE_DIR)
64+
.filter((f) => f.endsWith(".sock"))
65+
.map((f) => f.slice(0, -".sock".length))
66+
.filter((id) => isSocket(socketPath(id)));
67+
} catch {
68+
return [];
69+
}
70+
}
71+
72+
export function readLogTail(id: string, maxBytes: number): string {
73+
const p = logPath(id);
74+
try {
75+
const size = statSync(p).size;
76+
const start = Math.max(0, size - maxBytes);
77+
const len = size - start;
78+
const fd = openSync(p, "r");
79+
try {
80+
const buf = Buffer.alloc(len);
81+
readSync(fd, buf, 0, len, start);
82+
return buf.toString("utf8");
83+
} finally {
84+
closeSync(fd);
85+
}
86+
} catch {
87+
return "";
88+
}
89+
}
90+
91+
export function writeLog(id: string, snapshot: string): void {
92+
try {
93+
writeFileSync(logPath(id), snapshot);
94+
} catch {
95+
// ignore
96+
}
97+
}
98+
99+
export function appendLog(id: string, chunk: string, maxBytes: number): void {
100+
const p = logPath(id);
101+
try {
102+
const size = existsSync(p) ? statSync(p).size : 0;
103+
if (size + chunk.length > maxBytes * 2) {
104+
const tail = readLogTail(id, maxBytes);
105+
writeFileSync(p, tail + chunk);
106+
} else {
107+
appendFileSync(p, chunk);
108+
}
109+
} catch {
110+
// ignore
111+
}
112+
}
113+
114+
export function sessionExists(id: string): boolean {
115+
return existsSync(socketPath(id)) && isSocket(socketPath(id));
116+
}
117+
118+
export function readPid(id: string): number | null {
119+
try {
120+
const n = Number(readFileSync(pidPath(id), "utf8").trim());
121+
return Number.isFinite(n) && n > 0 ? n : null;
122+
} catch {
123+
return null;
124+
}
125+
}
126+
127+
export function cleanupFiles(id: string): void {
128+
for (const p of [socketPath(id), metaPath(id), logPath(id), pidPath(id)]) {
129+
try {
130+
unlinkSync(p);
131+
} catch {
132+
// ignore
133+
}
134+
}
135+
}
136+
137+
export function killShell(id: string): void {
138+
const pid = readPid(id);
139+
if (pid) {
140+
try {
141+
process.kill(pid, "SIGHUP");
142+
} catch {
143+
// already gone
144+
}
145+
}
146+
}

server/src/session/manager.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,26 @@ import { DEFAULT_COLS, DEFAULT_ROWS } from "../config.js";
22
import type { CreateSessionRequest, SessionInfo } from "../types/session.js";
33
import { log } from "../utils/log.js";
44
import { defaultCwd, defaultShell } from "../utils/shell.js";
5+
import * as dtach from "./dtach.js";
56
import { Session } from "./session.js";
6-
import * as tmux from "./tmux.js";
77

88
export class SessionManager {
99
private readonly sessions = new Map<string, Session>();
1010

1111
async rehydrate(): Promise<void> {
12-
const existing = await tmux.listSessions();
13-
for (const t of existing) {
14-
const initialHistory = await tmux.capturePane(t.name);
12+
const ids = dtach.listSessionIds();
13+
for (const id of ids) {
14+
const meta = dtach.readMeta(id);
15+
if (!meta) continue;
1516
const session = new Session({
16-
id: t.id,
17-
createdAt: t.createdAt,
18-
title: t.title,
19-
shell: defaultShell(),
17+
id: meta.id,
18+
createdAt: meta.createdAt,
19+
title: meta.title,
20+
shell: meta.shell,
2021
cwd: defaultCwd(),
2122
cols: DEFAULT_COLS,
2223
rows: DEFAULT_ROWS,
23-
initialHistory,
24+
reattach: true,
2425
});
2526
this.sessions.set(session.id, session);
2627
log("session", "rehydrate", session.id, session.title);
@@ -61,6 +62,13 @@ export class SessionManager {
6162
return infos;
6263
}
6364

65+
detachAll(): void {
66+
for (const session of this.sessions.values()) {
67+
session.detach();
68+
}
69+
this.sessions.clear();
70+
}
71+
6472
destroy(id: string): boolean {
6573
const session = this.sessions.get(id);
6674
if (!session) {

server/src/session/replay.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,18 @@
44
// answers leak onto the shell.
55
const CSI_QUERY = /\x1b\[[?>=]?[0-9;]*[cn]/g;
66
const OSC_COLOR_QUERY = /\x1b\][0-9]+;\?(?:\x07|\x1b\\)/g;
7+
// Erase-display (ED). Replaying these wipes prior content out of
8+
// xterm.js during rehydrate, hiding history the user expects to see.
9+
const ERASE_DISPLAY = /\x1b\[[0-3]?J/g;
10+
// Cursor Position / Horizontal-Vertical Position. Replaying these
11+
// jumps the cursor back over already-rendered content so live output
12+
// appends below instead of overwriting the replayed screen.
13+
const CURSOR_POSITION = /\x1b\[\d*(?:;\d*)?[Hf]/g;
714

815
export function sanitizeForReplay(data: string): string {
9-
return data.replace(CSI_QUERY, "").replace(OSC_COLOR_QUERY, "");
16+
return data
17+
.replace(CSI_QUERY, "")
18+
.replace(OSC_COLOR_QUERY, "")
19+
.replace(ERASE_DISPLAY, "")
20+
.replace(CURSOR_POSITION, "");
1021
}

0 commit comments

Comments
 (0)