ADR 0048: A terminal for the model, experimental and unguarded
- Status: experimental; implemented 2026-10-14. Temporary: it is kept only until what it is worth has been measured (Decision 7), and is then removed or rebuilt under a new ADR. Nothing here is a promise to support it as it is.
- Date: 2026-10-14
Context
shell-start, shell-read and shell-send run a program that keeps
running, but over pipes. A program that checks for a terminal (tmux, neovim,
less, htop, most REPLs' line editing) does not work over pipes, and what
a full-screen program writes arrives as escape codes, not as the screen a
person sees. A model cannot edit with neovim, work inside tmux, scroll back
through output, or answer a program's menu.
The person asked whether the model could be given a terminal: something that runs tmux, where it can use neovim, run commands, look at the screen and scroll. They asked for it without guards, to see how much it helps; if it does, making it safe is the next piece of work. This ADR records what was built for that, what was left out, and what would have to be true to keep it.
Decision
- Six primitives, offered as tools (
internal/prim/term.go): -term-open cmd args [cols rows]runs a program on a pseudo-terminal (100 by 30 unless told otherwise) in the repository root, with the scrubbed environment ofshell-start,TERM=xterm-256color, a UTF-8 locale when there is none and its ownTMUX_TMPDIR; it returns the screen once the program has been quiet for 300 ms. -term-type id texttypes text, a newline pressing Enter;term-keys id keyspresses keys named as tmux names them (Enter,Escape,Up,PageDown,F5,C-w,M-x,BTab), or a character. A name that is not one is an error and presses nothing. Both return the screen once the program has been quiet for 150 ms, or after 2 seconds. -term-screen id [offset]returns the screen now, or, withoffset, the view scrolled that many lines back through the history of a program that does not use the alternate screen (5000 lines are kept). It needs no approval. -term-wait id [seconds] [until]waits until a line of the screen matches a regular expression, or, without one, until the program has been quiet for half a second, or until it exits, or for at most 60 seconds, and says which. It needs no approval. -term-close idhangs up (SIGHUP to the process group, SIGKILL after 2 seconds) and returns the last screen. It needs no approval.
The model reads the screen as text: a header (state, size, cursor row and
column, whether the program is full-screen), then each row with its number
and |, three or more blank rows in a row as one line. The program's
output goes through a terminal emulator (charmbracelet/x/vt), which
also answers the questions programs ask of a terminal (neovim and tmux ask
where the cursor is and wait), and encodes keys for the modes the program
has set. A pseudo-terminal comes from creack/pty. Both are new
dependencies.
The emulator is a Charm library, and ADR 10's boundary lint forbids the
core, and so internal/prim, any package of that family. The primitives
therefore use an interface (internal/termemu, which imports nothing),
the adapter over x/vt is internal/frontend/termvt, where Charm
libraries are allowed, and cmd/nodwyn links it in with a blank import
as a SQL driver is. A binary that does not link it opens no terminal: the
primitives are there and term-open says so. The lint was not changed.
2. It is experimental and temporary, and says so where it is used. It is
off unless [experimental] terminal = true is in config.toml; Settings
does not offer it, deliberately, since a switch there would suggest a
feature. A session with it on records a note saying it is experimental,
temporary and unchecked; every primitive's description, which the model
reads, begins with the same; the prompt for term-open says it is
experimental; the manual says it; and this ADR's status says it. When it
is removed the config key goes with it, and a config.toml that still
names it fails to load with the unknown key, which is how the person finds
out.
3. Nothing the model types is checked. term-open is irreversible and
asks, once, like shell-start: its cmd and args are an argv role, so
the argv trigger, the taint rule and the credential check read the command
line that starts the program. After that term-type and term-keys run
without a confirm, and nothing reads what they send. So a terminal is a
shell with the person's privileges, and what that means is: the path
denylist and the repository pin do not reach what a program run in it
opens (the model can open any file with neovim, or cat it in a shell);
writes by a program in it are not versioned or shown with a diff, git is
the only undo; its network is unrestricted; a standing approval for
shell-exec neither covers nor limits it; and the judge answers only the
term-open prompt. The primitive's description tells the model that a
refusal from another tool is the answer, not something to get around
through a terminal; that is a request, not a control. approval.dont_ask
and --yolo answer the term-open prompt as they answer any other.
4. What is done so that it is usable and does not leave things behind.
A tmux server detaches from the terminal that starts it, so hanging up
does not end it, and the default tmux socket is the person's own. Every
terminal of a session therefore gets one private TMUX_TMPDIR (a short
directory under /tmp, since socket paths are short), the server survives
term-close so a later term-open tmux attach finds it, and closing the
session runs tmux kill-server there and removes the directory. At most 3
terminals are open at once; term-type sends at most 16 KiB and
term-keys presses at most 200 keys; a write the program does not read
fails after 5 seconds instead of hanging the session; a panic in the
emulator ends that terminal and not the daemon.
5. The progress guard counts terminal calls on their own. It ends a turn
on the same call four times in a row, which is a loop for most tools and
is how one moves down a file in a terminal (Down four times). Successful
terminal calls are not repeats; 150 of them with no other tool between
end the turn (screenLimit, a guess: no log has driven a terminal yet).
A call that fails still counts as it does for any tool.
6. Not built. Each of these is a reason this is not the final design:
- Any check of what is typed: a per-keystroke guard, a command-line
parser over a shell's input, a pin for paths a program opens.
- Running the terminal in the container runtime of ADR 23 (read-only
root, no network but the egress proxy), which would make unchecked
keystrokes defensible. It needs an image with the tools in it, and the
host's toolchain is not there.
- Text attributes. The screen is plain text: a menu entry shown only by
colour or reverse video, a visual selection, a syntax error underlined
in red, are not visible to the model.
- Resizing after the program starts, the mouse, bracketed paste (text is
typed as typed, so an editor with auto-indent mangles pasted code; use
fs-edit for that), and a live view of the terminal for the person in
any client (docs/clients.md).
- Seeing it in the loop's accounting: a test run in a terminal is not a
shell-exec, so ran-ok?, the turn summary and the check loop do not
know about it.
- Windows: pseudo-terminals here are Unix ones, as shell-start's process
groups already are.
7. How it is judged, and what happens after. Use it for real work with
[experimental] terminal = true and look at the sessions: which
term-* calls the model made, whether tasks that failed without it
finished with it, how many calls and tokens a screen-driven edit takes
against fs-edit, and what the model did that the guard would have
stopped. If it earns its keep, a new ADR designs the guarded terminal
(Decision 6) and replaces this; if not, the removal is: internal/prim/term.go
and term_test.go, Options.Terms, the session's terms and its Close,
config.Experimental, the progress guard's terminal outcome, the six rows
of docs/scheme.md and the book and expert guide tables, this ADR's
status, and the two dependencies (go mod tidy).
Validation
internal/prim/term_test.go (linking the real emulator, as the binary does)
runs programs on the terminal: that they have
one (test -t 0, stty size), typed text echoing, the bytes each named key
sends (read back by a program in raw mode), keys refused without any being
pressed, a program's cursor-position question being answered, scrollback and
the alternate screen, term-wait returning for each reason, exit and close,
the limit, the hang-up of every terminal when the set closes, and tmux
(skipped without it): its socket in the session's directory, its server
outliving term-close and gone when the session ends. internal/core
checks that a session offers the primitives only with the setting on, asks
once when the model opens a terminal and not for what it types, and hangs up
on a terminal left open. internal/termemu checks that without an adapter
there is no terminal. internal/loop checks the progress guard. neovim and
tmux were also driven by hand through the primitives on 2026-10-14: neovim
opened a file, took an insertion and wrote it; tmux split a window, ran a
command in each pane, was left, and was attached again.
Amendment 2026-10-22: closing the set names the tmux socket
The private TMUX_TMPDIR did not keep the person's tmux safe on its own: given
a TMUX_TMPDIR that does not exist, tmux (3.7c) falls back to /tmp/tmux-UID,
the person's own server. Closing the set removed the directory, and closing it
a second time (a session closes, then its cleanup does, as the tests do) ran
tmux kill-server against the removed one. Run inside tmux, the tests ended
the person's server and the session working in it; a kernel trace showed the
server sending itself SIGTERM as the term tests closed.
Closing the set now names the socket (tmux -S <dir>/tmux-UID/default
kill-server), and only when that socket exists, so a second close does
nothing. TestTermCloseNamesItsOwnTmuxSocketAndOnlyOnce records what Close
asks of a fake tmux on PATH, so it fails on the old code without a real
server in reach. To run the term tests where a real tmux could be hit, hide
its socket directory first: unshare -Urm sh -c 'mount -t tmpfs tmpfs
/tmp/tmux-$(id -u) && go test ./internal/prim -run TestTerm'.