A tiny terminal multiplexer built on top of dtach. No windows, no panes, no tabs, no config file. One session per project, auto-named, attached on demand. Close the laptop, drop the SSH, quit the terminal — your shell keeps running.
The name is short for ditch — ditch your terminal, keep the session.
dch wraps a small fork of dtach into one statically-built C binary. It
gives you the one thing screen/tmux/zellij are useful for — surviving
disconnect — and nothing else:
- No windows / panes / tabs. If you want those, use tmux.
- One session per project. Auto-named
<repo>-<branch>when run from a git worktree, otherwise the directory basename. - Attach is idempotent. Run
dchagain from the same dir — it reattaches the existing session instead of starting a duplicate. - Survives client death. The master daemon is split from the client, so
killing the terminal window (crash, GUI quit, SSH drop) leaves the shell
alive. Open a new window and
dchreattaches. - Agents can drive sessions headlessly. Each session keeps an in-memory terminal mirror (via libghostty-vt), so scripts and coding agents can send keys, read the rendered screen, and wait for output — without attaching, without a pty, without disturbing a human who is attached. See Agent API.
- No config file, no shell hooks. One binary on
$PATH.
The two forms you'll use 90% of the time are bare dch and dch <cmd> —
both auto-name the session from the current directory.
cd ~/code/coolapp # git repo, currently on branch "feat-auth"
dch claude # → session "coolapp-feat-auth" if absent;
# then runs claude inside the session
# new terminal, or after detach:
git checkout master
dch # → DIFFERENT session: "coolapp-master" (branch is part of the name)
# new terminal, or after detach:
cd /tmp/scratch # not a git repo
dch # → session "scratch", starts default shell
dch -n release zsh # → override auto-name; session "release"
dch -ls # → list every running session
dch -l # → pick one (TUI, arrow keys) and attach
dch -rl # → pick one, type a new display name, repeat
dch -k # → pick one and kill it
dch -kl # → kill ALL sessionsThe auto-name uses cwd basename when you're not in a git repo, and
<repo-toplevel>-<branch> when you are — slashes in branch names get
replaced with _ so they're safe.
dch -rl renames a session interactively: pick one, type a new display
name, and you're dropped back on the list to rename the next. The new name
is a display alias only — stored in a sidecar file next to the socket,
shown with priority in -l/-rl as alias (real-name). The underlying
session name is untouched, so attach/kill/detach by real name keep working.
An empty input clears the alias; killing a session removes its alias too.
An agent (or plain shell script) can drive a TUI in a session it never attaches to:
dch --spawn infra --size 120x40 k9s # headless session running k9s
dch --wait infra --match "Pods" --timeout 15000 # block until it's up
dch --keys infra : # k9s command mode
dch --run infra deployments # type the view name, press enter
dch --read infra # print the rendered screen, no attach
dch --read infra --ansi > snap.txt # same, with colors
dch --read infra --cursor # + "cursor <row> <col> ..." on stderrbrew install ventsislav-georgiev/tap/dch # full: agent API included
brew install ventsislav-georgiev/tap/dch-lite # ~100 KB, no terminal mirrorTwo formulas, conflicts_with each other — pick one. dch embeds
libghostty-vt (~2 MB binary) and supports every verb in the
Agent API. dch-lite is the classic ~100 KB
attach/detach tool: --send/--run/--keys still work (keys via a
legacy encoding), but --read/--wait need the mirror and exit 3.
The formulas are auto-published to the tap by dch's release workflow on
every v* tag.
git clone https://github.com/ventsislav-georgiev/dch.git
cd dch
./configure # or: ./configure --without-libghostty (lite build)
make
mkdir -p ~/.local/bin
install -m 0755 dch ~/.local/bin/dch # or anywhere on $PATHMake sure ~/.local/bin is in your $PATH, then dch -h to confirm.
Dependencies: a C compiler (clang or gcc) and autoconf.
- macOS:
xcode-select --install(clang + make) +brew install autoconf. - Debian/Ubuntu:
sudo apt install build-essential autoconf.
Point any coding agent (Claude Code, Cursor, Aider, …) at this link:
…and say "install dch by following this file". The agent will clone,
build, install to ~/.local/bin, and verify in one go.
Once installed, agents should read the Agent API section — it's the whole point of giving them dch.
dch # attach the auto-named session, or create it
dch <cmd...> # same, but if new, run <cmd> in it
dch -ls # list sessions
dch -l # pick a session (arrow keys) and attach
dch -rl # rename: pick a session, type a display alias, repeat
dch -k [name] # kill a session (interactive picker if no name)
dch -kl # kill ALL sessions
dch -d [name] # detach all clients of a session (sends SIGUSR1)
dch -n <name> # override the auto-name
dch -f # force-attach even if another client is connected
# agent / scripting verbs (never attach; see Agent API)
dch --spawn <name> [--size CxR] [--env K=V]... [cmd...] # headless session
dch --send <name> <text...> # type text into the session
dch --run <name> <text...> # type text, press enter
dch --keys <name> <key...> # send keys: ctrl+c up f2 ...
dch --read <name> [--ansi] [--recent [N]] # print the rendered screen
dch --read <name> --cursor # ... + caret row/col on stderr
dch --wait <name> --match <str> [--timeout ms] # block until output matches
dch --wait <name> --state <s[,s]> [--timeout ms] # block until state matches
dch --status <name> # print session state
dch --report <name> <state> # push state (harness hooks)
dch --ls-json # sessions as a JSON array
dch --restart <name>|--all [-f] # upgrade a live session in placeInside a session: Ctrl-\ to detach. Works in vim, fzf, less, Claude Code,
etc. — terminals that swallow most control keys still pass Ctrl-\ through.
Press Ctrl-\ twice quickly to switch sessions: dch detaches and shows the
same picker as dch -l, so you can hop straight into another session. Quitting
the picker (q / Esc) just leaves you detached. A single press still detaches
— it waits out the double-press window first. DCH_DOUBLE_TAP_MS sets that
window in milliseconds (default 300); DCH_DOUBLE_TAP_MS=0 turns switching
off and makes detach instant again.
Every dch session keeps an in-memory terminal mirror: the master feeds
each pty byte into an embedded libghostty-vt terminal (Ghostty's
VT engine as a C library, no rendering). The mirror is what --read prints
and --wait scans, and it tracks the modes (kitty keyboard protocol,
application cursor keys) that --keys uses to encode keys exactly the way
the running app expects.
Control verbs use a dedicated connection type: they never attach, never allocate a pty, and never echo to attached humans. Reading a session that someone is watching is invisible to them.
| Verb | Does | Exit codes |
|---|---|---|
--spawn <n> [--size CxR] [--env K=V]... [cmd...] |
Start headless session (default: $SHELL, 80x24 or $COLUMNS/$LINES). --env (max 32, first = splits) adds variables to the child; reserved keys (DCH_SESSION, TERM fixup) always win. Prints the name. |
0 ok, 1 exists/failed/bad --env |
--send <n> <text...> |
Type text (joined with spaces), no enter. | 0 ok |
--run <n> <text...> |
--send + enter. |
0 ok |
--keys <n> <key...> |
Encode+send keys mode-aware: ctrl+c, alt+x, shift+tab, up, f5, enter, esc, single chars. |
0 ok, 1 unknown key |
--read <n> [--ansi] [--cursor] [--recent [N]] |
Print rendered screen. --ansi keeps colors; --recent [N] = last N lines incl. scrollback (default 100); --cursor also prints cursor <row> <col> <visible> <wrap> on stderr (see below). Plain output drops all styling — to tell a TUI's own ghost text (placeholders, argument hints) from what the human typed, read with --ansi --cursor; see docs/ghost-text.md. |
0 ok, 1 error, 3 no mirror |
--wait <n> --match <str> [--timeout ms] |
Block until screen/scrollback contains <str> (literal substring, ≤512 bytes); prints the matching line. Default timeout 10 s. |
0 hit, 2 timeout, 3 no mirror |
--status <n> |
Print the session state: working, idle, blocked, or done — resolved from the reported state, the screen-content detection, and the output heuristic (see Agent state detection). |
0 ok, 1 no session |
--report <n> <state> |
Push a semantic state: working, idle, blocked, or done (closed set, unknown tokens rejected); clear reverts to auto (detection + heuristic). Last write wins; cleared automatically when the session ends. |
0 ok, 1 no session / bad state |
--wait <n> --state <s[,s...]> [--timeout ms] |
Block until the state matches any of the comma list (e.g. idle,blocked,done = "turn over"); prints the matched state. active is accepted as an alias for working. Polls every 100 ms. |
0 hit, 2 timeout, 1 no session |
--ls-json |
All sessions as JSON: [{"name","alias","activity_epoch","state","version"}]. state follows the same resolution rule as --status. version is the dch version of the master serving that session, or "" for a master old enough not to report one. |
0 |
--restart <n> | --restart --all [-f] |
Re-exec the session's master onto the current dch binary, in place — the program inside keeps running (see Upgrading a live session). --all restarts only sessions whose version differs from this binary's; -f restarts all of them. |
0 ok, 1 failed / master too old |
A typical agent loop:
dch --spawn build --size 120x40
dch --run build "make -j8 2>&1 | tee build.log"
if dch --wait build --match "error:" --timeout 300000; then
dch --read build --recent 40 # grab context around the failure
fiA tool that paints the screen dump somewhere else — its own mirror pane, a
web view — also has to place the caret. Inferring it from the byte stream
gets it wrong the moment the app moves the cursor with anything but plain
printing. --cursor asks the mirror instead:
One call, two streams — the screen on stdout, the caret on stderr:
cur=$(dch --read ui --cursor 2>&1 >screen.txt) # "cursor 7 12 1 0"Keep it to a single invocation: two reads would put the screen at one instant
and the caret at another, which is the misplacement this flag exists to
remove. (Capturing both in variables needs a third descriptor:
exec 3>&1; cur=$(dch --read ui --cursor 2>&1 1>&3); exec 3>&-.)
Fields are row col visible wrap: 1-based like CUP (\e[row;colH), row
counted from the top of the visible screen, visible = DEC mode 25, and
wrap = 1 when col is the last column and the next printed character
soft-wraps to the next row — the caret still sits on col, not past it.
It comes from the same snapshot as the screen on stdout — one round trip,
nothing can move in between — and stdout stays byte-identical, so existing
consumers are unaffected. Not valid with --recent (the caret belongs to the
visible screen, not a scrollback tail). Exit 1, with the caret withheld, if
the master predates the flag or the screen was big enough to truncate the
response (over 2 MB: the kept tail no longer starts at screen row 1, so the
row would be a lie).
row counts screen rows and --read trims trailing blank lines, so a
caret parked below the last line of content reports a row larger than the
number of lines printed — pad the pane out to it instead of clamping to the
line count. Leading and interior blank rows are preserved, so row N of the
report is line N of the dump whenever the dump is that long.
Still a snapshot, though: bytes the app emits after the read move the real caret, so a client that reads, paints, then types needs another read rather than trusting the old coordinate.
--status and --ls-json answer "what is this agent doing?" with zero
setup: dch reads the session's rendered screen (the same mirror --read
prints) and looks for the strings coding-agent TUIs paint when they are
busy or waiting on a human.
| State | Signal |
|---|---|
blocked |
Permission/confirmation prompts: "Do you want to proceed?", "Allow command?", "Press enter to confirm or esc to cancel", "permission required", "waiting for approval", … (Claude Code, Codex, Gemini, opencode, Cursor) |
working |
Busy footers ("esc to interrupt", "ctrl+c to interrupt") or an animated braille spinner at the start of a line — only honored if the session also produced output in the last 30 s, so a frozen frame can't read as busy |
idle / working |
Fallback output heuristic: pty output within the last 5 s = working, else idle |
Resolution order, per query:
- A reported
done(see below) always wins — no screen can prove completion. - A detected
blockedbeats any reported state exceptdone: a live permission prompt on screen is ground truth, and this also rescues sessions whose harness hook died mid-turn and left a staleworkingbehind. - Otherwise: reported state, else detected state, else the output heuristic.
Honest limits: detection reads the terminal body text, not window titles,
so a harness that shows its busy indicator only in the title falls back to
the output heuristic (still a correct busy/quiet answer, just without
screen confirmation). The rules are English-only and can lag a harness UI
redesign — updating dch updates the rules for every session, nothing else
to install. Transcript/pager views that replay old prompts are recognized
and suppressed, but a program that happens to print a matching string can
still false-positive. DCH_NO_DETECT=1 in the client's environment
disables detection entirely (reported state + heuristic only).
Detection needs the session master's VT mirror: against a lite or
DCH_NO_VT=1 master it falls back to the reported state + heuristic. A
lite client against a full master detects normally.
For multi-agent runs, name sessions <run>.<role> (e.g. crew1.planner,
crew1.coder) and filter --ls-json by prefix — dch keeps a flat
namespace on purpose.
Detection can't see everything — done in particular is a semantic fact
only the harness knows. A harness running inside the session can push its
state with --report (herdr-style): working, idle, blocked, done.
A reported state overrides the heuristic (and, except for a live on-screen
permission prompt, the detection) until the next report,
--report <name> clear, or session end (the sidecar is removed with the
socket).
Every process inside a dch session has DCH_SESSION set to the session
name, so hooks need zero configuration. Reports must never break the
harness: silence stderr and swallow the exit code, as below.
Claude Code (~/.claude/settings.json) — each entry is
{"hooks": [{"type": "command", "command": "<cmd>"}]} with:
| Hook event | Command |
|---|---|
UserPromptSubmit, PreToolUse |
dch --report "$DCH_SESSION" working 2>/dev/null; true |
Notification |
dch --report "$DCH_SESSION" blocked 2>/dev/null; true |
Stop |
dch --report "$DCH_SESSION" idle 2>/dev/null; true |
SessionEnd |
dch --report "$DCH_SESSION" clear 2>/dev/null; true |
PreToolUse flips blocked back to working once a permission prompt is
approved; SessionEnd clears the report so a closed harness can't leave a
stale working behind (a hard-killed one can, until the next report or
session end — same trade-off herdr makes, which uses process-exit rather
than a timeout to expire state).
OpenAI Codex (~/.codex/config.toml) — Codex only signals
turn-complete:
notify = ["sh", "-c", "dch --report \"$DCH_SESSION\" idle 2>/dev/null; true"]Anything else: call dch --report "$DCH_SESSION" <state> from the
harness's hook/notify mechanism. Outside a dch session DCH_SESSION is
empty and the report fails silently.
Then a watcher blocks on the state instead of polling screens:
dch --wait agent1 --state idle,blocked,done --timeout 600000 # turn over?- The mirror is a model, not a screenshot. libghostty-vt implements the same VT emulation Ghostty ships, so it's accurate for real-world TUIs (vim, k9s, htop, fzf) — but an app probing exotic terminal features could in principle render differently on the mirror than on your terminal.
--waitmatches rendered text, post-VT-processing: a spinner that redraws in place produces one line, not hundreds.- Timing is yours to handle.
--sendtypes instantly; TUIs that debounce input may need a--waitbetween verbs, exactly like a human pausing.
A session is served by a master process that was started by whichever dch
binary spawned it. Upgrade dch and the sessions you already have keep running
the old code — new features simply aren't there until the session is recreated,
which is exactly what you don't want to do to a long-running agent.
dch --restart <name> fixes that without killing anything:
brew upgrade dch
dch --restart --all # every session not already on this versionThe master serialises its state, then replaces its own image with
execv(2) — no fork. Its pid, its pty, the program running inside it, the
listening socket and every attached client's connection all survive, because
file descriptors and process identity are preserved across exec. The image it
execs is the dch that ran the command, not the path the session was started
from: on a versioned prefix (Homebrew's Cellar, the Nix store) those differ
after an upgrade, and re-execing the old path would report success and change
nothing. The --all form reads the version field of --ls-json and skips
sessions already current; -f restarts them anyway.
If the re-exec fails (binary missing mid-upgrade, say), execv simply
returns and the old master carries on serving — the rollback is free. dch --restart reports which of the two happened, and the acknowledgement it waits
for comes from the new image, so exit 0 means the new binary is serving.
Two things do not survive: scrollback held only in the master's mirror (the
visible screen does), and a master too old to know the verb at all — those
sessions have to be recreated once, and only once. --restart --all reports
those separately and does not count them as failures, since on the first
upgrade to a dch that has live restart, that is every session you have.
Attaching sends you the session's current screen from the master's mirror
before anything else. dtach's original contract was to clear your screen and
ask the program to repaint; diff-based renderers — Ink, and so Claude Code —
don't repaint, because they compare against their own model of a screen dch
just invalidated behind their back. The result was a blank attach that only
filled in when you resized the window. Set DCH_NO_REPLAY=1 in the master's
environment at spawn to get the old behavior back.
The mirror can be absent: the dch-lite build (./configure --without-libghostty) omits it, and DCH_NO_VT=1 in the master's
environment disables it at spawn.
| Verb | Without mirror |
|---|---|
--spawn, --send, --run, --status, --report, --wait --state, --ls-json |
Work unchanged (plain pty writes / sidecar reads). State detection needs the master's mirror, so against a lite/DCH_NO_VT master --status falls back to reported state + output heuristic. |
--read, --wait --match |
Exit 3 with a message pointing at the full build. |
--keys |
Falls back to a fixed legacy xterm table client-side (exit 0 + stderr note). Mode-aware apps may misread modified keys. |
The client side is protocol-only in both builds: a lite dch binary can
drive every verb against a session spawned by a full dch master.
This fork (the dch.c entry point + small attach.c / master.c patches)
adds:
- Auto-named sessions (
<repo>-<branch>or cwd basename), parsed natively from.git/HEAD— nogitfork/exec. - Static-TUI session picker for
-l/-k/-d(no external dep likegumorfzf). - Server/client split so the daemon survives the client closing.
- Per-client PID files for orphan reaping (PPID=1, no controlling TTY).
- Whole-buffer scan for the detach char and
VSUSP— fixes upstream bug where bursty/pasted input could swallow the keypress. - 16 KB replay buffer (upstream is 4 KB).
- Terminal restore on exit clears mouse-tracking / bracketed-paste / alt-screen state that inner apps (vim, fzf) leave on.
- An in-master terminal mirror (embedded libghostty-vt) plus an agent
control protocol:
--spawn,--send,--run,--keys,--read,--wait,--ls-json— drive and observe TUI sessions without attaching.
GPLv2 (inherited from dtach). See COPYING.