215 lines
9.4 KiB
Markdown
215 lines
9.4 KiB
Markdown
# 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`.
|