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

9.4 KiB
Raw Permalink Blame History

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

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)

# 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)

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:

{"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

{
  "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

{"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
F1F12
single characters: a Z % - ... sent literally
C-aC-z, C-Space, C-[ C-\ C-] C-^ C-_ control characters 0x000x1f
M-<anything> ESC-prefix (e.g. M-x = ESC x)
S-aA 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

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.