Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,29 @@ xterm.js and its addons are loaded at runtime from CDN — no frontend build ste
## License

MIT

## Session persistence (tmux / dtach)

Set `WEB_TERMINAL_SESSION_BACKEND` on the CloudCLI host to keep shells alive across browser refreshes:

| `WEB_TERMINAL_SESSION_BACKEND` | Behavior |
|---|---|
| `none` (default) | One shell per WebSocket. Browser refresh ends the shell with SIGHUP. |
| `tmux` | Shell runs inside `tmux new-session -A -s <name>`. Refresh reattaches the existing session. Requires `tmux` on `PATH`. |
| `dtach` | Shell runs inside `dtach -A <socket> -z`. Lighter than tmux; same survival semantics. Requires `dtach` on `PATH`. |

Companion env vars:

- `WEB_TERMINAL_SESSION_NAME` — tmux session name or dtach socket basename. Default `main`.
- `WEB_TERMINAL_DTACH_SOCKET` — full path for dtach socket. Default `/tmp/web-terminal-<name>.sock`.

Example:

```bash
WEB_TERMINAL_SESSION_BACKEND=tmux \
WEB_TERMINAL_SESSION_NAME=main \
node /path/to/cloudcli/server.js
```

The single `main` session is shared across browser windows, so reopening the page reattaches the same shell with full scrollback intact. For per-tab persistence, run multiple CloudCLI hosts or open an issue describing the use case.

18 changes: 15 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,11 @@ class TerminalSession {
this.terminal.write('\r\n\x1b[2m--- reconnected ---\x1b[0m\r\n');
}
this._hasConnectedBefore = true;
setTimeout(() => { this._fit(); this.terminal.focus(); }, 60);
setTimeout(() => {
this._fit();
this.terminal.scrollToBottom();
this.terminal.focus();
}, 60);
return;
}
if (m.type === 'exit') {
Expand Down Expand Up @@ -540,7 +544,11 @@ class TerminalSession {
this.el.classList.remove('hidden');
if (this.status === 'connected' && this.ws && this.ws.readyState === WebSocket.OPEN) {
this.overlayEl.style.display = 'none';
setTimeout(() => { this._fit(); this.terminal.focus(); }, 30);
setTimeout(() => {
this._fit();
this.terminal.scrollToBottom();
this.terminal.focus();
}, 30);
} else if (this.status === 'connecting' && this.ws) {
setTimeout(() => { this._fit(); }, 30);
} else {
Expand Down Expand Up @@ -568,7 +576,11 @@ class TerminalSession {
attachTo(container: HTMLElement): void {
container.appendChild(this.el);
setTimeout(() => {
try { this.terminal.refresh(0, this.terminal.rows - 1); this._fit(); } catch { /* ignore */ }
try {
this.terminal.refresh(0, this.terminal.rows - 1);
this._fit();
this.terminal.scrollToBottom();
} catch { /* ignore */ }
}, 50);
}

Expand Down
45 changes: 40 additions & 5 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ interface PtyProcess {
kill(): void;
pause(): void;
resume(): void;
onData(callback: (data: string) => void): void;
onData(callback: (data: string | Buffer) => void): void;
onExit(callback: (event: { exitCode: number; signal?: number }) => void): void;
spawn(shell: string, args: string[], opts: any): PtyProcess;
}
Expand Down Expand Up @@ -89,6 +89,29 @@ function getShell(): string {
return process.env.SHELL || '/bin/bash';
}

// Session persistence wrapper. When WEB_TERMINAL_SESSION_BACKEND=tmux|dtach,
// the shell is launched inside a detachable multiplexer so WebSocket close
// (browser refresh, network blip) does not SIGHUP the running program.
function buildShellCommand(): { command: string; args: string[] } {
const shell = getShell();
if (process.platform === 'win32') return { command: shell, args: [] };

const backend = (process.env.WEB_TERMINAL_SESSION_BACKEND || 'none').toLowerCase();
const sessionName = process.env.WEB_TERMINAL_SESSION_NAME || 'main';

if (backend === 'tmux') {
return {
command: 'tmux',
args: ['-L', 'web', '-u', 'new-session', '-A', '-s', sessionName, shell, '-l'],
};
}
if (backend === 'dtach') {
const socket = process.env.WEB_TERMINAL_DTACH_SOCKET || `/tmp/web-terminal-${sessionName}.sock`;
return { command: 'dtach', args: ['-A', socket, '-z', shell, '-l'] };
}
return { command: shell, args: [] };
}

function safeSend(ws: any, obj: unknown): void {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(typeof obj === 'string' ? obj : JSON.stringify(obj));
Expand Down Expand Up @@ -118,16 +141,18 @@ const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws: any) => {
const sessionId = `s${++sessionCounter}`;
const cwd = process.env.HOME || os.homedir();
const shell = getShell();
const { command, args } = buildShellCommand();
const shell = command;

let ptyProc: PtyProcess;
try {
ptyProc = pty.spawn(shell, [], {
ptyProc = pty.spawn(command, args, {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd,
env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'web-terminal' },
encoding: null,
});
} catch (err) {
safeSend(ws, { type: 'error', message: `Failed to spawn shell: ${(err as Error).message}` });
Expand All @@ -138,10 +163,20 @@ wss.on('connection', (ws: any) => {
sessions.set(sessionId, { pty: ptyProc, ws });
safeSend(ws, { type: 'ready', sessionId, shell, cwd });

ptyProc.onData((chunk: string) => {
// Streaming UTF-8 decode: node-pty emits raw Buffer chunks, but a multi-byte
// codepoint can land split across two chunks. TextDecoder with {stream:true}
// buffers trailing incomplete bytes until the next call, so the string we
// forward over the WebSocket always contains only complete codepoints. This
// eliminates the "smeared border / wrong-width character" glitch that
// appears when emoji or box-drawing chars cross a chunk boundary.
const decoder = new TextDecoder('utf-8', { fatal: false });
ptyProc.onData((chunk: Buffer | string) => {
ptyProc.pause();
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk;
const text = decoder.decode(bytes, { stream: true });
if (!text) { ptyProc.resume(); return; }
if (ws.readyState === WebSocket.OPEN) {
ws.send(chunk, () => ptyProc.resume());
ws.send(text, () => ptyProc.resume());
} else {
ptyProc.resume();
}
Expand Down