···11+# pty — Development Guide
22+33+A persistent terminal session manager. Run long-lived processes, detach, reconnect later — from any machine over SSH.
44+55+## Objectives
66+77+- Replace tmux/zellij for **session persistence** of long-running processes
88+- Simple CLI: `pty run <name> <command>`, `pty attach <name>`, `pty peek <name>`, `pty restart <name>`
99+- Reliable detach/reattach with full screen replay (colors, cursor position, scrollback)
1010+- Work seamlessly over SSH (Unix sockets, no port management)
1111+- Multi-client support (multiple people can observe or interact with a session)
1212+- Comprehensive test coverage — unit and integration tests for all features
1313+1414+## Non-goals
1515+1616+- **Not a window manager.** No splits, tabs, or layouts. Use kitty for that.
1717+- **Not a shell.** It wraps a single command per session.
1818+- **Not a security boundary.** Unix socket permissions are standard file permissions. Peek mode is a convenience, not access control.
1919+2020+## Quick Reference
2121+2222+```sh
2323+npm install # install dependencies
2424+npm run typecheck # typecheck with tsc (no emit)
2525+npm test # run all tests once
2626+npm run test:watch # run tests in watch mode
2727+2828+# Usage (during development)
2929+npx tsx src/cli.ts run <name> -- <command> [args...]
3030+npx tsx src/cli.ts run -d <name> -- <command> [args...]
3131+npx tsx src/cli.ts attach <name>
3232+npx tsx src/cli.ts peek <name>
3333+npx tsx src/cli.ts peek -f <name>
3434+npx tsx src/cli.ts list
3535+npx tsx src/cli.ts restart <name>
3636+npx tsx src/cli.ts kill <name>
3737+```
3838+3939+Detach from any attached/following session with **Ctrl+\\**. Press Ctrl+\\ twice quickly to send it to the process.
4040+4141+### No build step
4242+4343+This project ships TypeScript source directly — there is no compile step. All `.ts` files use `.ts` import extensions and are executed at runtime by [tsx](https://tsx.is). The `bin/pty` entry point locates tsx from `node_modules` and runs `src/cli.ts`. The `tsc` command is used only for typechecking (`noEmit: true`).
4444+4545+## Architecture
4646+4747+```
4848+┌─────────────────────────────────────────────┐
4949+│ Daemon (one per session) │
5050+│ │
5151+│ ┌──────────┐ ┌───────────────────────┐ │
5252+│ │ node-pty │───▶│ xterm-headless │ │
5353+│ │ (PTY) │ │ (screen buffer) │ │
5454+│ └──────────┘ │ + SerializeAddon │ │
5555+│ ▲ └───────────────────────┘ │
5656+│ │ │ │
5757+│ │ serialize() │
5858+│ │ ▼ │
5959+│ ┌──────────────────────────────────────┐ │
6060+│ │ Unix Socket Server │ │
6161+│ │ ~/.local/state/pty/<name>.sock │ │
6262+│ └──────────────────────────────────────┘ │
6363+│ ▲ ▲ ▲ │
6464+└───────┼──────────┼────────────┼─────────────┘
6565+ │ │ │
6666+ Client Client Peek
6767+ (attach) (attach) (read-only)
6868+```
6969+7070+Each session is a detached Node.js process running `src/server.ts` via tsx. It spawns the command in a PTY, feeds all output through xterm-headless to maintain a screen buffer, and listens on a Unix socket for client connections.
7171+7272+## Protocol
7373+7474+Binary packets over Unix sockets: `[type: uint8][length: uint32BE][payload]`
7575+7676+| Type | ID | Direction | Payload |
7777+|------|----|-----------|---------|
7878+| DATA | 0 | Both | Raw terminal bytes |
7979+| ATTACH | 1 | Client → Server | `[rows: uint16BE, cols: uint16BE]` (4 bytes) |
8080+| DETACH | 2 | Client → Server | Empty |
8181+| RESIZE | 3 | Client → Server | `[rows: uint16BE, cols: uint16BE]` (4 bytes) |
8282+| EXIT | 4 | Server → Client | `[exitCode: int32BE]` (4 bytes) |
8383+| SCREEN | 5 | Server → Client | ANSI escape sequences (string) |
8484+| PEEK | 6 | Client → Server | Empty |
8585+8686+`PacketReader` handles streaming reassembly of partial reads. Decoders gracefully handle truncated payloads (defaults for size, -1 for exit code). Unknown message types are silently ignored by the server.
8787+8888+## Key Design Decisions
8989+9090+### No build step — ship TypeScript directly
9191+9292+There is no `dist/` directory. Source files use `.ts` import extensions and run directly via tsx. The `tsconfig.json` has `noEmit: true` and `allowImportingTsExtensions: true` — tsc is only used for typechecking. This eliminates the compile-then-run dance entirely. tsx is a regular dependency (not devDependency) because it's needed at runtime.
9393+9494+### No TypeScript enums
9595+9696+We avoid TS enums because they emit runtime code that can't be type-stripped. Instead, we use `as const` objects with a derived union type. This is compatible with all TS runtimes: Node's native type stripping, tsx, esbuild, swc.
9797+9898+### Peek clients don't affect terminal size
9999+100100+The PTY can only be one size. If a peek client's terminal size were used, it could reflow the session — imagine vim at 120x40 suddenly becoming 40x20 because someone peeked from their phone. Readonly clients are excluded from size negotiation entirely. They see whatever fits; the active user's layout is never disrupted.
101101+102102+### Last attached client wins for size
103103+104104+When multiple interactive (non-peek) clients are connected, the most recently attached client's terminal size is used for the PTY. This is simple and predictable. An alternative would be minimum dimensions across all clients, but that punishes the primary user when a smaller client connects.
105105+106106+### xterm-headless as the screen buffer
107107+108108+The terminal emulation problem (parsing ANSI sequences, tracking cursor, colors, alternate screen, etc.) is genuinely hard. Instead of reimplementing it, we use xterm-headless — the same terminal engine as VS Code's terminal. The `SerializeAddon` produces ANSI escape sequences that reconstruct the screen state in any real terminal on reconnect.
109109+110110+### One daemon per session
111111+112112+Each session is an independent Node.js process. No central daemon, no shared state. This means a crash in one session doesn't affect others, and sessions are naturally isolated. The tradeoff is slightly higher memory per session.
113113+114114+### Unix sockets for local IPC
115115+116116+Fast, zero-config, and work transparently over SSH. No ports to manage, no firewall rules. Session sockets live at `~/.local/state/pty/<name>.sock` (override with `$PTY_SESSION_DIR`).
117117+118118+### PtyServer never calls process.exit()
119119+120120+The `PtyServer` class is a library — it cleans up resources via `close()` but never exits the process. Only the daemon entry point (bottom of `server.ts`) wires up signal handlers and `process.exit()`. This keeps the class testable in-process with vitest.
121121+122122+### Double Ctrl+\ passthrough
123123+124124+Ctrl+\ is the detach key. Since some programs (like vim or hx) may use Ctrl+\, pressing it twice quickly within 300ms sends Ctrl+\ to the process instead of detaching. This matches the UX pattern from `screen` and `tmux` where the prefix key is sent to the process by pressing it twice.
125125+126126+Programs that enable the Kitty keyboard protocol (claude, helix, neovim, etc.) cause the terminal to encode Ctrl+\ as `\x1b[92;5u` instead of the legacy byte `0x1c`. The client normalizes both encodings before detach processing so the detach key works regardless of which keyboard protocol is active.
127127+128128+### Terminal sanitize on disconnect
129129+130130+When a client detaches or a session exits, the client writes escape sequences to reset terminal modes that the PTY program may have enabled: mouse tracking (all three modes), SGR mouse, hidden cursor, and bracketed paste. This prevents "poisoned" terminal state (e.g., mouse movements producing escape characters) without clearing screen content.
131131+132132+### Spawn through shell
133133+134134+The server spawns commands via `/bin/sh -c 'exec "$@"'` rather than calling `posix_spawnp` directly. This handles shell scripts, symlinks, shebangs, and other executable formats that `posix_spawnp` may reject. `exec` replaces the shell with the actual process so there is no extra process. This matches the approach used by tmux and screen.
135135+136136+### Session name validation
137137+138138+Session names are restricted to `[a-zA-Z0-9._-]` to prevent path traversal and shell injection. This is validated before any filesystem operations.
139139+140140+### Race condition prevention
141141+142142+Concurrent `pty run` calls with the same name are protected by an exclusive lock file (`<name>.lock`). The lock includes the PID and is automatically stolen if the holding process dies.
143143+144144+### Daemon spawn failure detection
145145+146146+When `spawnDaemon` launches the daemon, it monitors the child for early exit. If the daemon crashes before the socket appears, the error is surfaced immediately instead of waiting for the 3-second timeout.
147147+148148+## Testing
149149+150150+Tests use **vitest** and live in `tests/`.
151151+152152+- `protocol.test.ts` — Unit tests for packet encoding, decoding, and streaming reassembly (partial reads, split packets, large payloads)
153153+- `integration.test.ts` — Full integration tests that spawn real PTY sessions, connect clients via sockets, and verify behavior
154154+- `screenshot.test.ts` — Screenshot-based tests that capture terminal state (ANSI and plain text) and assert on visual output from real programs (vim, nano, ls, bash). Covers control characters, terminal resize, alternate screen buffer, multiple clients, unicode, and daemon spawning.
155155+156156+All tests run in `/tmp/` directories to avoid polluting the project folder (e.g., vim swap files). Integration and screenshot tests use real processes and real Unix sockets. Each test creates a uniquely-named session and cleans up afterward. There is a `200ms` delay in some tests to allow xterm-headless to process async writes before checking screen state — this is a known characteristic of xterm's write pipeline, not a flaky test.
157157+158158+### Running tests
159159+160160+```sh
161161+npm test # run once
162162+npm run test:watch # watch mode
163163+npx vitest run -t "peek" # run tests matching "peek"
164164+```
165165+166166+### node-pty on macOS
167167+168168+npm sometimes extracts the `spawn-helper` binary without the execute bit. The `postinstall` script in `package.json` fixes this automatically. If you still see `posix_spawnp failed`, run manually:
169169+170170+```sh
171171+chmod +x node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper
172172+```
173173+174174+## File Structure
175175+176176+```
177177+src/
178178+ cli.ts CLI entry point and command routing
179179+ server.ts PtyServer class + daemon entry point
180180+ client.ts attach() and peek() functions
181181+ protocol.ts Packet types, encoding, decoding, PacketReader
182182+ sessions.ts Session discovery, socket/PID file management
183183+tests/
184184+ protocol.test.ts
185185+ integration.test.ts
186186+ screenshot.test.ts
187187+completions/
188188+ pty.bash Bash tab completion
189189+ pty.zsh Zsh tab completion
190190+bin/
191191+ pty Entry point (locates tsx, runs src/cli.ts)
192192+```
193193+194194+## Future
195195+196196+### WebSocket server
197197+198198+Add a WebSocket listener alongside the Unix socket so remote clients (phones, browsers) can connect without SSH. The `PtyServer` already manages clients generically — a WebSocket client would be another entry in the clients map, same protocol.
199199+200200+### Web UI
201201+202202+A lightweight browser-based terminal UI that connects via WebSocket. Could use xterm.js on the client side since the server already speaks its language.
203203+204204+### Native SwiftUI app
205205+206206+An iOS/macOS app for connecting to sessions over the network (via Tailscale). The binary protocol is simple enough to implement in Swift. The app would list available sessions, connect via WebSocket, and render the terminal natively.
207207+208208+### Kitty kitten
209209+210210+A Python kitten for kitty that lists sessions and opens them in new kitty windows. Would use kitty's remote control protocol to create windows and the Unix socket to attach.
211211+212212+### Session groups / profiles
213213+214214+Named configurations for starting multiple related sessions at once (e.g., "start my dev environment" → web server + database + file watcher).
+21
LICENSE
···11+MIT License
22+33+Copyright (c) 2026 Nathan Herald
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+42
README.md
···11+# pty
22+33+Persistent terminal sessions. Run a process, detach, reconnect later. From anywhere, locally and over SSH.
44+55+Uses [@xterm/headless](https://github.com/xtermjs/xterm.js/tree/master/headless) internally.
66+77+## Install
88+99+```sh
1010+git clone https://github.com/myobie/pty.git
1111+cd pty
1212+npm install
1313+npm link
1414+```
1515+1616+## Usage
1717+1818+```sh
1919+pty run myserver -- node server.js # start a session and attach
2020+pty run -d myserver -- node server.js # start in the background
2121+2222+pty list # show active sessions
2323+pty attach myserver # reconnect
2424+pty peek myserver # print current screen and exit
2525+pty peek -f myserver # follow output read-only
2626+2727+pty restart myserver # restart an exited session
2828+pty kill myserver # terminate a session
2929+```
3030+3131+Detach with `Ctrl+\`. (Press `Ctrl+\` twice to send it through to the process.)
3232+3333+## Tab Completion
3434+3535+```sh
3636+brew install bash-completion # required for bash; zsh works out of the box
3737+npm run install-completions
3838+```
3939+4040+## License
4141+4242+MIT
···11+#!/bin/sh
22+# Install shell completions for pty.
33+# Run automatically by postinstall or manually via: npm run install-completions
44+55+set -e
66+77+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
88+COMPLETIONS_DIR="$SCRIPT_DIR/../completions"
99+1010+# Bash completions via Homebrew
1111+for dir in /opt/homebrew/etc/bash_completion.d /usr/local/etc/bash_completion.d; do
1212+ if [ -d "$dir" ]; then
1313+ ln -sf "$COMPLETIONS_DIR/pty.bash" "$dir/pty"
1414+ echo "Bash completions installed: $dir/pty"
1515+ break
1616+ fi
1717+done
1818+1919+# Zsh completions via Homebrew
2020+for dir in /opt/homebrew/share/zsh/site-functions /usr/local/share/zsh/site-functions; do
2121+ if [ -d "$dir" ]; then
2222+ ln -sf "$COMPLETIONS_DIR/pty.zsh" "$dir/_pty"
2323+ echo "Zsh completions installed: $dir/_pty"
2424+ break
2525+ fi
2626+done
+554
src/cli.ts
···11+import { spawn, execFileSync } from "node:child_process";
22+import * as fs from "node:fs";
33+import * as os from "node:os";
44+import * as path from "node:path";
55+import * as readline from "node:readline/promises";
66+import * as tty from "node:tty";
77+import { fileURLToPath } from "node:url";
88+import { attach, peek } from "./client.ts";
99+import {
1010+ listSessions,
1111+ getSession,
1212+ getSocketPath,
1313+ cleanupAll,
1414+ cleanupSocket,
1515+ validateName,
1616+ acquireLock,
1717+ releaseLock,
1818+ type SessionInfo,
1919+} from "./sessions.ts";
2020+2121+const __dirname = path.dirname(fileURLToPath(import.meta.url));
2222+2323+function usage(): void {
2424+ console.log(`Usage:
2525+ pty run <name> <command> [args...] Create a session and attach
2626+ pty run -d <name> <command> [args...] Create a session in the background
2727+ pty run -a <name> <command> [args...] Create or attach if already running
2828+ pty attach <name> Attach to an existing session
2929+ pty attach -r <name> Attach, auto-restart if exited
3030+ pty peek <name> Print current screen and exit
3131+ pty peek -f <name> Follow output read-only (Ctrl+\\ to stop)
3232+ pty restart <name> Restart an exited session
3333+ pty list List active sessions
3434+ pty kill <name> Kill or remove a session
3535+3636+Detach from a session with Ctrl+\\ (press twice to send Ctrl+\\ to the process)`);
3737+}
3838+3939+async function main(): Promise<void> {
4040+ const args = process.argv.slice(2);
4141+4242+ if (args.length === 0) {
4343+ await cmdList();
4444+ return;
4545+ }
4646+4747+ const command = args[0];
4848+4949+ switch (command) {
5050+ case "run": {
5151+ // Parse flags before positional args
5252+ let detach = false;
5353+ let attachExisting = false;
5454+ let i = 1;
5555+ while (i < args.length && args[i].startsWith("-") && args[i] !== "--") {
5656+ if (args[i] === "-d" || args[i] === "--detach") detach = true;
5757+ else if (args[i] === "-a" || args[i] === "--attach") attachExisting = true;
5858+ else break;
5959+ i++;
6060+ }
6161+ const runArgs = args.slice(i);
6262+6363+ const dashDash = runArgs.indexOf("--");
6464+ let name: string;
6565+ let cmd: string;
6666+ let cmdArgs: string[];
6767+6868+ if (dashDash !== -1) {
6969+ if (dashDash !== 1) {
7070+ console.error("Usage: pty run [-d] [-a] <name> -- <command> [args...]");
7171+ process.exit(1);
7272+ }
7373+ name = runArgs[0];
7474+ cmd = runArgs[dashDash + 1];
7575+ cmdArgs = runArgs.slice(dashDash + 2);
7676+ } else {
7777+ name = runArgs[0];
7878+ cmd = runArgs[1];
7979+ cmdArgs = runArgs.slice(2);
8080+ }
8181+8282+ if (!name || !cmd) {
8383+ console.error("Usage: pty run [-d] [-a] <name> -- <command> [args...]");
8484+ process.exit(1);
8585+ }
8686+ try {
8787+ validateName(name);
8888+ } catch (e: any) {
8989+ console.error(e.message);
9090+ process.exit(1);
9191+ }
9292+ const displayCmd = cmd;
9393+ cmd = resolveCommand(cmd);
9494+ await cmdRun(name, cmd, cmdArgs, detach, attachExisting, displayCmd);
9595+ break;
9696+ }
9797+9898+ case "attach":
9999+ case "a": {
100100+ const autoRestart =
101101+ args[1] === "--auto-restart" || args[1] === "-r";
102102+ const attachName = autoRestart ? args[2] : args[1];
103103+ if (!attachName) {
104104+ console.error("Usage: pty attach [-r|--auto-restart] <name>");
105105+ process.exit(1);
106106+ }
107107+ try {
108108+ validateName(attachName);
109109+ } catch (e: any) {
110110+ console.error(e.message);
111111+ process.exit(1);
112112+ }
113113+ await cmdAttach(attachName, autoRestart);
114114+ break;
115115+ }
116116+117117+ case "peek": {
118118+ const follow = args[1] === "-f" || args[1] === "--follow";
119119+ const peekName = follow ? args[2] : args[1];
120120+ if (!peekName) {
121121+ console.error("Usage: pty peek [-f] <name>");
122122+ process.exit(1);
123123+ }
124124+ try {
125125+ validateName(peekName);
126126+ } catch (e: any) {
127127+ console.error(e.message);
128128+ process.exit(1);
129129+ }
130130+ cmdPeek(peekName, follow);
131131+ break;
132132+ }
133133+134134+ case "list":
135135+ case "ls": {
136136+ await cmdList();
137137+ break;
138138+ }
139139+140140+ case "restart": {
141141+ if (args.length < 2) {
142142+ console.error("Usage: pty restart <name>");
143143+ process.exit(1);
144144+ }
145145+ await cmdRestart(args[1]);
146146+ break;
147147+ }
148148+149149+ case "kill": {
150150+ if (args.length < 2) {
151151+ console.error("Usage: pty kill <name>");
152152+ process.exit(1);
153153+ }
154154+ try {
155155+ validateName(args[1]);
156156+ } catch (e: any) {
157157+ console.error(e.message);
158158+ process.exit(1);
159159+ }
160160+ await cmdKill(args[1]);
161161+ break;
162162+ }
163163+164164+ case "help":
165165+ case "--help":
166166+ case "-h": {
167167+ usage();
168168+ break;
169169+ }
170170+171171+ default: {
172172+ console.error(`Unknown command: ${command}`);
173173+ usage();
174174+ process.exit(1);
175175+ }
176176+ }
177177+}
178178+179179+async function cmdRun(
180180+ name: string,
181181+ command: string,
182182+ args: string[],
183183+ detach = false,
184184+ attachExisting = false,
185185+ displayCommand: string
186186+): Promise<void> {
187187+ const session = await getSession(name);
188188+ if (session?.status === "running") {
189189+ if (attachExisting) {
190190+ console.log(`Session "${name}" already running, attaching.`);
191191+ doAttach(name);
192192+ return;
193193+ }
194194+ console.error(
195195+ `Session "${name}" is already running. Use "pty attach ${name}" to connect.`
196196+ );
197197+ process.exit(1);
198198+ }
199199+200200+ if (!acquireLock(name)) {
201201+ console.error(
202202+ `Session "${name}" is being created by another process. Try again.`
203203+ );
204204+ process.exit(1);
205205+ }
206206+207207+ // Clean up any dead session with the same name
208208+ if (session?.status === "exited") {
209209+ cleanupAll(name);
210210+ }
211211+212212+ try {
213213+ await spawnDaemon(name, command, args, displayCommand);
214214+ } finally {
215215+ releaseLock(name);
216216+ }
217217+218218+ console.log(`Session "${name}" created.`);
219219+220220+ if (detach) {
221221+ return;
222222+ }
223223+224224+ doAttach(name);
225225+}
226226+227227+async function cmdAttach(
228228+ name: string,
229229+ autoRestart = false
230230+): Promise<void> {
231231+ const session = await getSession(name);
232232+233233+ if (!session) {
234234+ console.error(`Session "${name}" not found.`);
235235+ process.exit(1);
236236+ }
237237+238238+ if (session.status === "running") {
239239+ doAttach(name);
240240+ return;
241241+ }
242242+243243+ // Dead session — show last lines and offer to restart
244244+ await handleDeadSession(session, autoRestart);
245245+}
246246+247247+async function handleDeadSession(
248248+ session: SessionInfo,
249249+ autoRestart = false
250250+): Promise<void> {
251251+ const meta = session.metadata;
252252+ if (!meta) {
253253+ console.error(`Session "${session.name}" exited (no metadata available).`);
254254+ cleanupAll(session.name);
255255+ process.exit(1);
256256+ }
257257+258258+ // Show last lines
259259+ if (meta.lastLines && meta.lastLines.length > 0) {
260260+ console.log("");
261261+ for (const line of meta.lastLines) {
262262+ console.log(` ${line}`);
263263+ }
264264+ console.log("");
265265+ }
266266+267267+ console.log(
268268+ `Session "${session.name}" exited with code ${meta.exitCode ?? "unknown"}.`
269269+ );
270270+271271+ const cmd = [meta.displayCommand, ...meta.args].join(" ");
272272+ console.log(`Command was: ${cmd}`);
273273+ console.log("");
274274+275275+ if (!autoRestart) {
276276+ const answer = await ask("Restart? [Y/n] ");
277277+ if (answer.toLowerCase() === "n") {
278278+ process.exit(0);
279279+ }
280280+ }
281281+282282+ // Restart
283283+ cleanupAll(session.name);
284284+ await spawnDaemon(session.name, meta.command, meta.args, meta.displayCommand);
285285+ console.log(`Session "${session.name}" restarted.`);
286286+ doAttach(session.name);
287287+}
288288+289289+function doAttach(name: string): void {
290290+ attach({
291291+ name,
292292+ onDetach: () => process.exit(0),
293293+ onExit: (code) => process.exit(code),
294294+ });
295295+}
296296+297297+function cmdPeek(name: string, follow: boolean): void {
298298+ peek({
299299+ name,
300300+ follow,
301301+ onDetach: () => process.exit(0),
302302+ onExit: (code) => process.exit(code),
303303+ });
304304+}
305305+306306+async function cmdList(): Promise<void> {
307307+ const sessions = await listSessions();
308308+309309+ if (sessions.length === 0) {
310310+ console.log("No active sessions.");
311311+ return;
312312+ }
313313+314314+ const running = sessions.filter((s) => s.status === "running");
315315+ const exited = sessions.filter((s) => s.status === "exited");
316316+317317+ if (running.length > 0) {
318318+ console.log("Active sessions:");
319319+ for (const session of running) {
320320+ const cmd = session.metadata
321321+ ? [session.metadata.displayCommand, ...session.metadata.args].join(" ")
322322+ : "unknown";
323323+ const cwd = session.metadata?.cwd
324324+ ? shortPath(session.metadata.cwd)
325325+ : "";
326326+ console.log(` ${session.name} (pid: ${session.pid}) — ${cwd} — ${cmd}`);
327327+ }
328328+ }
329329+330330+ if (exited.length > 0) {
331331+ if (running.length > 0) console.log("");
332332+ console.log("Exited sessions:");
333333+ for (const session of exited) {
334334+ const meta = session.metadata;
335335+ const code = meta?.exitCode ?? "?";
336336+ const ago = meta?.exitedAt ? timeAgo(new Date(meta.exitedAt)) : "unknown";
337337+ const cwd = meta?.cwd ? shortPath(meta.cwd) : "";
338338+ console.log(` ${session.name} (exited with code ${code}, ${ago}) — ${cwd}`);
339339+ }
340340+ }
341341+}
342342+343343+async function cmdKill(name: string): Promise<void> {
344344+ const session = await getSession(name);
345345+346346+ if (!session) {
347347+ console.error(`Session "${name}" not found.`);
348348+ process.exit(1);
349349+ }
350350+351351+ if (session.status === "running" && session.pid) {
352352+ try {
353353+ process.kill(session.pid, "SIGTERM");
354354+ console.log(`Session "${name}" killed.`);
355355+ } catch {
356356+ console.error(`Failed to kill session "${name}".`);
357357+ }
358358+ cleanupSocket(name);
359359+ }
360360+361361+ cleanupAll(name);
362362+ if (session.status === "exited") {
363363+ console.log(`Session "${name}" removed.`);
364364+ }
365365+}
366366+367367+async function cmdRestart(name: string): Promise<void> {
368368+ try {
369369+ validateName(name);
370370+ } catch (e: any) {
371371+ console.error(e.message);
372372+ process.exit(1);
373373+ }
374374+375375+ const session = await getSession(name);
376376+377377+ if (!session) {
378378+ console.error(`Session "${name}" not found.`);
379379+ process.exit(1);
380380+ }
381381+382382+ if (session.status === "running") {
383383+ console.error(
384384+ `Session "${name}" is still running. Kill it first with "pty kill ${name}".`
385385+ );
386386+ process.exit(1);
387387+ }
388388+389389+ const meta = session.metadata;
390390+ if (!meta) {
391391+ console.error(`Session "${name}" has no metadata — cannot restart.`);
392392+ cleanupAll(name);
393393+ process.exit(1);
394394+ }
395395+396396+ cleanupAll(name);
397397+ await spawnDaemon(name, meta.command, meta.args, meta.displayCommand);
398398+ console.log(`Session "${name}" restarted.`);
399399+ doAttach(name);
400400+}
401401+402402+async function spawnDaemon(
403403+ name: string,
404404+ command: string,
405405+ args: string[],
406406+ displayCommand: string
407407+): Promise<void> {
408408+ const stdout = process.stdout as tty.WriteStream;
409409+ const rows = stdout.rows ?? 24;
410410+ const cols = stdout.columns ?? 80;
411411+412412+ const tsxBin = path.join(__dirname, "..", "node_modules", ".bin", "tsx");
413413+ const serverModule = path.join(__dirname, "server.ts");
414414+ const config = JSON.stringify({
415415+ name,
416416+ command,
417417+ args,
418418+ displayCommand,
419419+ cwd: process.cwd(),
420420+ rows,
421421+ cols,
422422+ });
423423+424424+ const child = spawn(tsxBin, [serverModule], {
425425+ detached: true,
426426+ stdio: ["ignore", "ignore", "pipe"],
427427+ env: { ...process.env, PTY_SERVER_CONFIG: config },
428428+ });
429429+430430+ // Capture stderr for better error reporting
431431+ let stderrOutput = "";
432432+ child.stderr?.on("data", (data: Buffer) => {
433433+ stderrOutput += data.toString();
434434+ });
435435+436436+ // Detect early daemon crash before the socket appears
437437+ let earlyExit = false;
438438+ let earlyExitCode: number | null = null;
439439+ child.on("exit", (code) => {
440440+ earlyExit = true;
441441+ earlyExitCode = code;
442442+ });
443443+444444+ (child.stderr as any)?.unref?.();
445445+ child.unref();
446446+447447+ await waitForSocket(name, 3000, () => {
448448+ if (earlyExit) {
449449+ const details = stderrOutput.trim();
450450+ const msg = `Daemon process exited immediately (code ${earlyExitCode ?? "unknown"}).`;
451451+ throw new Error(details ? `${msg}\n${details}` : `${msg} Is the command valid?`);
452452+ }
453453+ });
454454+}
455455+456456+function waitForSocket(
457457+ name: string,
458458+ timeoutMs: number,
459459+ earlyCheck?: () => void
460460+): Promise<void> {
461461+ const socketPath = getSocketPath(name);
462462+ const start = Date.now();
463463+464464+ return new Promise((resolve, reject) => {
465465+ function check(): void {
466466+ // Check for early daemon failure
467467+ try {
468468+ earlyCheck?.();
469469+ } catch (e) {
470470+ reject(e);
471471+ return;
472472+ }
473473+474474+ if (Date.now() - start > timeoutMs) {
475475+ reject(new Error(`Timeout waiting for session "${name}" to start`));
476476+ return;
477477+ }
478478+479479+ try {
480480+ const stat = fs.statSync(socketPath);
481481+ if (stat) {
482482+ setTimeout(resolve, 100);
483483+ return;
484484+ }
485485+ } catch {}
486486+487487+ setTimeout(check, 50);
488488+ }
489489+ check();
490490+ });
491491+}
492492+493493+function ask(prompt: string): Promise<string> {
494494+ const rl = readline.createInterface({
495495+ input: process.stdin,
496496+ output: process.stdout,
497497+ });
498498+ return rl.question(prompt).then((answer) => {
499499+ rl.close();
500500+ return answer;
501501+ });
502502+}
503503+504504+function resolveCommand(cmd: string): string {
505505+ // Already absolute — just verify it exists
506506+ if (path.isAbsolute(cmd)) {
507507+ if (!fs.existsSync(cmd)) {
508508+ console.error(`Command not found: ${cmd}`);
509509+ process.exit(1);
510510+ }
511511+ return cmd;
512512+ }
513513+514514+ // Relative path (contains /) — resolve against cwd
515515+ if (cmd.includes("/")) {
516516+ const resolved = path.resolve(cmd);
517517+ if (!fs.existsSync(resolved)) {
518518+ console.error(`Command not found: ${cmd}`);
519519+ process.exit(1);
520520+ }
521521+ return resolved;
522522+ }
523523+524524+ // Bare command name — look up in PATH
525525+ try {
526526+ return execFileSync("which", [cmd], { encoding: "utf8" }).trim();
527527+ } catch {
528528+ console.error(`Command not found: ${cmd}`);
529529+ process.exit(1);
530530+ }
531531+}
532532+533533+function shortPath(p: string): string {
534534+ const home = os.homedir();
535535+ if (p === home) return "~";
536536+ if (p.startsWith(home + "/")) return "~" + p.slice(home.length);
537537+ return p;
538538+}
539539+540540+function timeAgo(date: Date): string {
541541+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
542542+ if (seconds < 60) return `${seconds}s ago`;
543543+ const minutes = Math.floor(seconds / 60);
544544+ if (minutes < 60) return `${minutes}m ago`;
545545+ const hours = Math.floor(minutes / 60);
546546+ if (hours < 24) return `${hours}h ago`;
547547+ const days = Math.floor(hours / 24);
548548+ return `${days}d ago`;
549549+}
550550+551551+main().catch((err) => {
552552+ console.error(err.message);
553553+ process.exit(1);
554554+});
+269
src/client.ts
···11+import * as net from "node:net";
22+import * as tty from "node:tty";
33+import {
44+ MessageType,
55+ PacketReader,
66+ encodeAttach,
77+ encodeData,
88+ encodeDetach,
99+ encodePeek,
1010+ encodeResize,
1111+ decodeExit,
1212+} from "./protocol.ts";
1313+import { getSocketPath } from "./sessions.ts";
1414+1515+const DETACH_KEY = 0x1c; // Ctrl+\ (legacy encoding)
1616+const DETACH_KEY_KITTY = "\x1b[92;5u"; // Ctrl+\ (Kitty keyboard protocol)
1717+1818+/** Replace Kitty keyboard protocol encoding of Ctrl+\ with the legacy byte
1919+ * so the rest of the detach logic can work with a single representation. */
2020+function normalizeDetachKey(data: Buffer): Buffer {
2121+ const str = data.toString();
2222+ if (!str.includes(DETACH_KEY_KITTY)) return data;
2323+ return Buffer.from(
2424+ str.replaceAll(DETACH_KEY_KITTY, String.fromCharCode(DETACH_KEY))
2525+ );
2626+}
2727+2828+// Reset terminal modes that programs may have enabled. This prevents
2929+// "poisoned" terminals after detach/peek (e.g., mouse tracking, hidden
3030+// cursor, bracketed paste). Does NOT clear screen content.
3131+const TERMINAL_SANITIZE =
3232+ "\x1b[?1000l" + // disable mouse click tracking
3333+ "\x1b[?1002l" + // disable mouse button-event tracking
3434+ "\x1b[?1003l" + // disable mouse any-event tracking
3535+ "\x1b[?1006l" + // disable SGR mouse mode
3636+ "\x1b[?25h" + // show cursor
3737+ "\x1b[?2004l" + // disable bracketed paste
3838+ "\x1b[<u"; // pop Kitty keyboard protocol mode
3939+4040+export interface PeekOptions {
4141+ name: string;
4242+ follow?: boolean; // If true, stay connected and stream (like tail -f). If false, print screen and exit.
4343+ onExit?: (code: number) => void;
4444+ onDetach?: () => void;
4545+}
4646+4747+/** Read-only view of a session. Input is ignored by the server. */
4848+export function peek(options: PeekOptions): void {
4949+ const socketPath = getSocketPath(options.name);
5050+ const reader = new PacketReader();
5151+ const socket = net.createConnection(socketPath);
5252+ const stdout = process.stdout;
5353+ const follow = options.follow ?? false;
5454+5555+ socket.on("connect", () => {
5656+ socket.write(encodePeek());
5757+5858+ if (follow) {
5959+ // In follow mode, Ctrl+\ detaches
6060+ const stdin = process.stdin;
6161+ if (stdin.isTTY) stdin.setRawMode(true);
6262+6363+ stdin.on("data", (raw: Buffer) => {
6464+ const data = normalizeDetachKey(raw);
6565+ for (let i = 0; i < data.length; i++) {
6666+ if (data[i] === DETACH_KEY) {
6767+ if (stdin.isTTY) stdin.setRawMode(false);
6868+ socket.destroy();
6969+ stdout.write(TERMINAL_SANITIZE + "\r\n[detached]\r\n");
7070+ options.onDetach?.();
7171+ return;
7272+ }
7373+ }
7474+ // All other input is silently ignored (read-only)
7575+ });
7676+ }
7777+ });
7878+7979+ socket.on("data", (data: Buffer) => {
8080+ const packets = reader.feed(data);
8181+ for (const packet of packets) {
8282+ switch (packet.type) {
8383+ case MessageType.SCREEN:
8484+ stdout.write(packet.payload);
8585+ if (!follow) {
8686+ stdout.write(TERMINAL_SANITIZE + "\n");
8787+ socket.destroy();
8888+ return;
8989+ }
9090+ break;
9191+9292+ case MessageType.DATA:
9393+ if (follow) {
9494+ stdout.write(packet.payload);
9595+ }
9696+ break;
9797+9898+ case MessageType.EXIT: {
9999+ const code = decodeExit(packet.payload);
100100+ socket.destroy();
101101+ stdout.write(TERMINAL_SANITIZE);
102102+ if (follow) {
103103+ stdout.write(`\r\n[session exited with code ${code}]\r\n`);
104104+ }
105105+ options.onExit?.(code);
106106+ return;
107107+ }
108108+ }
109109+ }
110110+ });
111111+112112+ socket.on("error", (err: NodeJS.ErrnoException) => {
113113+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") {
114114+ console.error(`Session "${options.name}" not found or not running.`);
115115+ } else {
116116+ console.error(`Connection error: ${err.message}`);
117117+ }
118118+ process.exit(1);
119119+ });
120120+121121+ socket.on("close", () => {
122122+ if (process.stdin.isTTY && process.stdin.isRaw) {
123123+ process.stdin.setRawMode(false);
124124+ }
125125+ });
126126+}
127127+128128+export interface AttachOptions {
129129+ name: string;
130130+ onExit?: (code: number) => void;
131131+ onDetach?: () => void;
132132+}
133133+134134+export function attach(options: AttachOptions): void {
135135+ const socketPath = getSocketPath(options.name);
136136+ const reader = new PacketReader();
137137+ const socket = net.createConnection(socketPath);
138138+139139+ const stdin = process.stdin;
140140+ const stdout = process.stdout;
141141+142142+ let detaching = false;
143143+ let rawWasSet = false;
144144+ let exitCode = 0;
145145+146146+ function enterRawMode(): void {
147147+ if (stdin.isTTY && !stdin.isRaw) {
148148+ stdin.setRawMode(true);
149149+ rawWasSet = true;
150150+ }
151151+ }
152152+153153+ function exitRawMode(): void {
154154+ if (rawWasSet && stdin.isTTY) {
155155+ stdin.setRawMode(false);
156156+ }
157157+ }
158158+159159+ function cleanExit(): void {
160160+ exitRawMode();
161161+ socket.destroy();
162162+ }
163163+164164+ socket.on("connect", () => {
165165+ enterRawMode();
166166+167167+ // Tell the server our terminal size
168168+ const rows = (stdout as tty.WriteStream).rows ?? 24;
169169+ const cols = (stdout as tty.WriteStream).columns ?? 80;
170170+ socket.write(encodeAttach(rows, cols));
171171+172172+ // Forward stdin to server
173173+ // Double Ctrl+\ passthrough: press once = detach, press twice quickly = send Ctrl+\ to process
174174+ let lastDetachKeyTime = 0;
175175+ const DOUBLE_TAP_MS = 300;
176176+177177+ stdin.on("data", (raw: Buffer) => {
178178+ const data = normalizeDetachKey(raw);
179179+180180+ // Fast path: no detach key in this chunk
181181+ if (data.indexOf(DETACH_KEY) === -1) {
182182+ socket.write(encodeData(data.toString()));
183183+ return;
184184+ }
185185+186186+ // Slow path: detach key found — process byte by byte
187187+ const forward: number[] = [];
188188+189189+ for (let i = 0; i < data.length; i++) {
190190+ if (data[i] === DETACH_KEY) {
191191+ const now = Date.now();
192192+ if (now - lastDetachKeyTime < DOUBLE_TAP_MS) {
193193+ // Double-tap: send Ctrl+\ to the process, reset timer
194194+ lastDetachKeyTime = 0;
195195+ forward.push(DETACH_KEY);
196196+ } else {
197197+ // First tap: schedule detach (will fire if no second tap)
198198+ lastDetachKeyTime = now;
199199+ setTimeout(() => {
200200+ if (lastDetachKeyTime === now) {
201201+ detaching = true;
202202+ socket.write(encodeDetach());
203203+ cleanExit();
204204+ stdout.write(TERMINAL_SANITIZE + "\r\n[detached]\r\n");
205205+ options.onDetach?.();
206206+ }
207207+ }, DOUBLE_TAP_MS);
208208+ }
209209+ } else {
210210+ forward.push(data[i]);
211211+ }
212212+ }
213213+214214+ if (forward.length > 0) {
215215+ socket.write(encodeData(Buffer.from(forward).toString()));
216216+ }
217217+ });
218218+219219+ // Handle terminal resize
220220+ if (stdout instanceof tty.WriteStream) {
221221+ stdout.on("resize", () => {
222222+ const rows = stdout.rows;
223223+ const cols = stdout.columns;
224224+ socket.write(encodeResize(rows, cols));
225225+ });
226226+ }
227227+ });
228228+229229+ socket.on("data", (data: Buffer) => {
230230+ const packets = reader.feed(data);
231231+ for (const packet of packets) {
232232+ switch (packet.type) {
233233+ case MessageType.DATA:
234234+ stdout.write(packet.payload);
235235+ break;
236236+237237+ case MessageType.SCREEN:
238238+ // Clear screen and write the replayed buffer
239239+ stdout.write("\x1b[2J\x1b[H");
240240+ stdout.write(packet.payload);
241241+ break;
242242+243243+ case MessageType.EXIT:
244244+ exitCode = decodeExit(packet.payload);
245245+ cleanExit();
246246+ stdout.write(TERMINAL_SANITIZE + `\r\n[session exited with code ${exitCode}]\r\n`);
247247+ options.onExit?.(exitCode);
248248+ return;
249249+ }
250250+ }
251251+ });
252252+253253+ socket.on("error", (err: NodeJS.ErrnoException) => {
254254+ cleanExit();
255255+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") {
256256+ console.error(`Session "${options.name}" not found or not running.`);
257257+ } else {
258258+ console.error(`Connection error: ${err.message}`);
259259+ }
260260+ process.exit(1);
261261+ });
262262+263263+ socket.on("close", () => {
264264+ if (!detaching) {
265265+ cleanExit();
266266+ process.exit(exitCode);
267267+ }
268268+ });
269269+}