termd/README.md
2026-07-02 22:13:49 -03:00

215 lines
9.4 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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-<anything>` | 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<ch>` / `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 <id>` 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`.