# termd A headless terminal emulator daemon built for LLMs to drive. An agent talks to it over a plain HTTP API: spawn shell sessions in real PTYs, send keystrokes and terminal mouse events, and read the rendered screen back as clean, style-free text. A human can manage sessions from the CLI and attach to any session live, tmux-style. ``` LLM / curl ──HTTP──▶ termd daemon ──PTY──▶ bash, vim, htop, ... │ └─ vt emulator models the screen; GET /screen returns it as text human ──termd CLI──▶ same API human ──termd attach──▶ live websocket mirror ``` ## Build & run ```sh go build -o termd ./cmd/termd ./termd serve # listens on 127.0.0.1:7070 ./termd serve -addr 127.0.0.1:9000 ./termd serve -unix /run/user/1000/termd.sock # note: socket paths max ~108 chars ./termd serve -token s3cret # require "Authorization: Bearer s3cret" ``` ## Quickstart (LLM side, plain curl) ```sh # create a session curl -s -XPOST localhost:7070/sessions -d'{"command":["bash"],"cols":100,"rows":30}' # → {"id":"a1b2c3","pid":1234,...} # type a command and press Enter curl -s -XPOST localhost:7070/sessions/a1b2c3/input \ -d'{"input":[{"text":"vim notes.txt"},{"key":"Enter"}]}' # read the screen curl -s localhost:7070/sessions/a1b2c3/screen # → {"lines":["...","..."],"cursor":{"x":0,"y":0,"visible":true}, # "modes":{"mouse":"off",...},"alt_screen":true,...} # click at column 10, row 3 (0-based cells) curl -s -XPOST localhost:7070/sessions/a1b2c3/mouse \ -d'{"type":"click","button":"left","x":10,"y":3}' ``` ## Quickstart (human side) ```sh export TERMD_ADDR=127.0.0.1:7070 # or pass -addr; -token/$TERMD_TOKEN likewise termd new bash # prints the session id termd new -cols 120 -rows 40 -cwd ~/src -env FOO=bar htop termd ls termd send a1b2c3 'echo hi' Enter # args matching key names are keys, rest is text termd send -l a1b2c3 Enter # -l: everything literal termd screen a1b2c3 # plain text; -json full state; -raw styled ANSI termd resize a1b2c3 120x40 termd mouse a1b2c3 click 10,3 # click|press|release|move|drag|scroll termd attach a1b2c3 # live read-only view; detach with C-] termd attach -write a1b2c3 # also forward your keystrokes to the session termd attach -resize a1b2c3 # push your terminal size too (and SIGWINCH) termd kill a1b2c3 ``` ## HTTP API Every error is `{"error":"..."}` with a real status code. All coordinates are 0-based screen cells. | Method & path | Body / params | Returns | |---|---|---| | `POST /sessions` | `{"command":["bash"],"cwd":"...","env":{"K":"V"},"cols":80,"rows":24,"term":"xterm-256color"}` — all optional; command defaults to `$SHELL` | `201` session info | | `GET /sessions` | — | `{"sessions":[...]}` | | `GET /sessions/{id}` | — | `{"id","pid","command","cols","rows","title","exited","exit_code"}` | | `DELETE /sessions/{id}` | `?signal=HUP` default (HUP, TERM, KILL, INT, QUIT, USR1, USR2); SIGKILL escalation after 3s. HUP because interactive shells ignore TERM | `204` | | `POST /sessions/{id}/resize` | `{"cols":120,"rows":40}` | `200` | | `POST /sessions/{id}/input` | see below | `{"written":N}` (items delivered) | | `POST /sessions/{id}/mouse` | see below | `200`, or `409` if the app can't receive it | | `GET /sessions/{id}/screen` | `?format=text` (default) or `raw` (adds styled ANSI in `"raw"`) | screen state, below | | `GET /sessions/{id}/attach` | WebSocket upgrade | binary = terminal bytes, text = JSON control | ### Input An ordered array; each element is exactly one of: ```json {"input":[ {"text":"ls -la"}, // literal text, no interpretation {"key":"Enter"}, // named key, encoded mode-aware (see key names) {"raw":"[200~x[201~"} // escape hatch: bytes straight to the pty ]} ``` The batch is validated up front and delivered all-or-nothing; an unknown key name is a `400` naming the bad element. Input to an exited session is a `409`. ### Screen ```json { "lines": ["$ ls", "notes.txt", ""], // one string per row, right-trimmed "cols": 80, "rows": 24, "cursor": {"x": 2, "y": 1, "visible": true}, "alt_screen": false, "title": "bash", "modes": { "mouse": "off | x10 | normal | button_event | any_event", "mouse_encoding": "default | sgr", "app_cursor_keys": false, "bracketed_paste": true }, "exited": false, "exit_code": null // screen stays readable after exit, until DELETE } ``` `modes` is the application's actual DECSET state, tracked live — check `modes.mouse` before clicking. ### Mouse ```json {"type":"click","button":"left","x":10,"y":5,"modifiers":["ctrl"]} ``` - `type`: `press`, `release`, `click` (= press+release), `move`, `drag`, `scroll` - `button`: `left`, `middle`, `right`, `wheel-up`, `wheel-down` (scroll only) - Encoded as SGR (`CSI < b;x+1;y+1 M/m`) when the app enabled `?1006`, legacy X10 bytes otherwise. Events the application cannot receive are **rejected with `409`**, never silently dropped — the body explains why and includes the current `modes`: no tracking mode at all; `drag` when only `?1000` is on (needs `?1002`/`?1003`); `move` without `?1003`; anything but `press` under X10 (`?9`). ## Key names tmux-style: a named key or single character, with stackable `C-` (Ctrl), `M-` (Alt), `S-` (Shift) prefixes in any order. | Names | Notes | |---|---| | `Enter` `Escape`/`Esc` `Tab` `BTab` `Space` `Backspace`/`BSpace` | `BTab` = Shift-Tab (`CSI Z`) | | `Up` `Down` `Left` `Right` | `CSI A..D`, or `SS3 A..D` when the app set DECCKM (vim, htop...) — handled automatically | | `Home` `End` `Insert`/`IC` `Delete`/`DC` `PgUp`/`PPage` `PgDn`/`NPage` | | | `F1`–`F12` | | | single characters: `a` `Z` `%` `-` ... | sent literally | | `C-a`…`C-z`, `C-Space`, `C-[` `C-\` `C-]` `C-^` `C-_` | control characters `0x00`–`0x1f` | | `M-` | ESC-prefix (e.g. `M-x` = `ESC x`) | | `S-a` → `A` | shift is only meaningful on letters | | modified specials: `C-Up` `S-F5` `C-M-Delete` ... | xterm `CSI 1;m` / `CSI n;m~`, `m = 1+Shift(1)+Alt(2)+Ctrl(4)` | Combinations with no real escape sequence (`C-Enter`, `C-1`) are a `400`, not a guess. ## Attach `termd attach ` mirrors the session into your terminal: instant repaint on join (the emulator re-renders its full state — no scrollback replay needed), then the live byte stream. Attach is **read-only by default** — watch without the risk of typing into an agent's session. With `-write`, your keystrokes go straight to the PTY and interleave with API input. Detach with `C-]` either way (configurable byte in the client). The session size is API-authoritative; `-resize` makes your terminal the authority instead (sent on connect and every SIGWINCH, last writer wins). If your terminal is **larger** than the session, attach automatically switches to *render mode*: instead of the raw stream (whose wrap/scroll behavior encodes the session's geometry and breaks on a bigger screen), the server streams debounced full frames rendered from the emulator — the session drawn as a top-left box, every line cleared to your screen edge with default styling, the area below blanked, cursor placed to match. `-stream` forces raw passthrough if you want it anyway. Wire protocol (if you want your own client): websocket at `GET /sessions/{id}/attach`; binary frames are raw terminal bytes both ways; text frames are JSON control — client sends `{"resize":{"cols":N,"rows":N}}`, server sends `{"hello":{...session info...}}` first and `{"exited":{"code":N}}` when the process dies. With `?readonly=1` the server drops incoming binary frames — read-only is enforced server-side, not just client courtesy. With `?view=render` binary frames are emulator-rendered repaints (25ms debounce) instead of the raw byte stream. ## Testing ```sh go test -race ./... ``` Integration-first: the suite boots the real HTTP server with real PTYs and the real emulator, and drives it through the CLI command functions, so one test crosses CLI parsing → REST client → routing → session locking → key encoding → PTY → emulator → screen snapshot. The application inside the PTY is the "probe": this same test binary re-executed (`TERMD_TEST_PROBE=1`), which sets its tty raw, enables whatever DEC modes the test asks for, and echoes every byte it receives back as hex on its screen — so tests assert the exact bytes an application would see. Unit tests exist only where integration can't pin behavior: the keymap table, and `vtpin_test.go`, which pins the input-encoding and mode-callback behavior of the unversioned `charmbracelet/x/vt` dependency so an upstream change fails loudly. ## Architecture notes - One goroutine reads the PTY and feeds the `charmbracelet/x/vt` emulator under the session mutex; the same raw bytes broadcast to attach subscribers. Mode callbacks (DECSET/DECRST, alt screen, title) fire synchronously inside `em.Write` and record state used for `modes` and mouse gating. - Input sent through the emulator's encoders (`SendKey`/`SendText`/`SendMouse`) is mode-aware for free (DECCKM arrows, SGR vs X10 mouse). Its synchronous input pipe is decoupled from the PTY by an unbounded queue so a child that stops reading stdin can never deadlock a session. - Attach clients' bytes bypass the encoders — their terminal already encoded them — and go straight to the PTY, like tmux. - A slow attach client is disconnected rather than allowed to stall the session; a dead process keeps its screen readable until `DELETE`.