Durable PTY

Terminals that don't die with your app.

Durable PTY keeps shells and AI coding agents running through app crashes and daemon restarts, and brings them back after a reboot.

Install with

$brew install jasonkneen/tap/dpty
$curl -fsSL https://raw.githubusercontent.com/jasonkneen/durable-pty/main/install.sh | sh
$cargo install --path .

Then run dpty install to start it at login. macOS (Apple silicon and Intel) and Linux (x86_64, aarch64). MIT licensed. Full install guide →

zsh — work
$ dpty new -n work -- claude "fix the flaky test" attached to work (pid 48213) — claude is thinking… daemon killed (kill -9) $ dpty ls work claude running pid 48213 machine rebooted $ dpty ls work claude lost [durable-pty] resuming: claude --resume 0186495c-9e2a-7c3e-8b1f-2a6e5c9d7f41 claude: picking up where I left off…

Durability

What happens when things crash

Sessions live in the daemon, not in your app. Here is exactly what survives, honestly, including the one thing it does not yet do.

EventWhat happens
Your app / browser crashes Nothing happens to the session. Reattach by id, name or label and get the current screen, then live output.
Daemon crashes or restarts Each session's shim keeps the process running on its own. The daemon reattaches on start and catches up on missed output — nothing lost. If a shim is also gone, that session is marked lost and relaunched per its restart policy (default for agents: on-failure), same id, history kept.
Machine reboots or you log out The processes themselves die — nothing can stop that. Sessions and their history survive in SQLite and are restored the next time the daemon starts.
A session keeps crash-looping Capped at 5 automatic restarts per 60 seconds, then dpty gives up and records the session as restart-loop instead of retrying forever.

Each session has its own small helper process — a shim — that owns the PTY and the running process, so the process itself keeps running through a daemon restart; see architecture. A machine reboot or logout still ends it; nothing can prevent that.

Command line

The CLI, as it actually runs

Real transcripts, not screenshots. The first command starts the daemon if it is not already running.

zsh
$ dpty new -n work -C ~/proj -- claude "fix the bug"attached to work (session 4f0c…) — Ctrl-\ to detach$ dpty lsNAME STATUS CMD CWDwork running claude "fix the bug" ~/proj
zsh
$ dpty send work "npm test"sent$ dpty wait work "passed" --timeout 60matched "passed" at 00:00:12$ dpty screen work… last screen of the work session, as currently drawn …
zsh
$ dpty history work… typed commands and events for this session …$ dpty restart workwork restarted (same id, scrollback kept)$ dpty installinstalled launchd service — restarts dpty if it exits$ dpty mcpserving MCP over stdio …

Agent sessions

claude and codex resume where they left off

dpty treats agent CLIs as a special case: it keeps the session id and resumes the agent with that id instead of just replaying a shell.

Finding the session id

claude is launched with --session-id <uuid>, so dpty knows the id from the moment the session starts. codex does not take an id up front, so dpty finds it by scanning ~/.codex/sessions after launch.

Other agents can be supported by passing --kind and --resume-cmd "grok --resume {id}", or by having the agent report its own id with a hook: dpty tag-agent "$DPTY_SESSION" <id>.

Resuming after a crash

On recovery dpty relaunches with the same cwd and environment and runs claude --resume <id> or codex resume <id>. The original prompt is dropped — the agent already has it in its own session — and every other flag you launched with is kept.

Integration points

Three ways in: socket, HTTP, MCP

The CLI, the HTTP API and the MCP server all call the same underlying RPC methods, so nothing behaves differently depending on how you connect.

Unix socket

~/.durable-pty/dpty.sock (mode 0600): JSON-line RPC, with an attach call that upgrades the connection to binary terminal frames.

HTTP and WebSocket

Default 127.0.0.1:7878, bearer token from dpty token (disable with --no-http): POST /rpc, GET /ws for live attach, GET /health.

MCP

Stdio, streamable HTTP at POST /mcp, and legacy SSE at GET /sse + POST /messages. Tools exposed:

  • pty_list list every session and its state
  • pty_new start a new session
  • pty_send type into a session
  • pty_screen read the current screen
  • pty_logs read raw output
  • pty_wait block until a pattern appears
  • pty_history read typed commands and events
  • pty_restart restart a session
  • pty_kill stop a session

Point an MCP client at it with:

{
  "mcpServers": {
    "dpty": {
      "command": "dpty",
      "args": ["mcp"]
    }
  }
}

Design

Architecture

Clients never hold the terminal — the daemon does, and it is the only thing that writes to SQLite. A per-session shim keeps a running process alive even while the daemon is restarted.

dpty architecture Your app, the CLI and MCP clients all talk to the dpty daemon, which stores everything in SQLite and talks to a per-session shim process that in turn owns the PTY and the running process. Your app dpty CLI MCP clients dpty daemon socket · HTTP · MCP SQLite Per-session shim outlives the daemon PTY + running process

The shim keeps the real process running while the daemon is replaced or restarted, and the daemon re-attaches to it afterward and catches up on whatever output it missed. dpty stop leaves every session's shim running; dpty stop --sessions stops them too. More detail: how it works →.

Scope

What dpty is not

Said plainly, so you do not find out the hard way.

Not for Windows

macOS and Linux only. There is no Windows support today.

A reboot ends running processes

There is no way around this — the OS kills them. What dpty guarantees is that the session, its scrollback and its history survive and are restored on the next daemon start.

History and resume remain

Scrollback, typed commands and events are in SQLite before anything crashes, so you never lose the record even if the process itself is gone.

Agent resume is best-effort

claude and codex are resumed by session id with the original prompt dropped. Other agents need --resume-cmd or a reporting hook to work the same way.