feat(terminals): send/read/close follow-ups to running terminal agents
What changed
Public API to message an already-running terminal agent (claude/codex) instead of re-spawning a session per message via agents.create — which spiked terminal-session counts and started a fresh CLI each time rather than continuing the conversation.
The terminals resource gains four verbs alongside create, mirrored across MCP v2 / SDK / CLI:
| Verb | Host procedure | Notes |
|---|---|---|
list | terminal.listSessions (existing) | daemon+DB truth (#5823), so background agents show up |
send | terminal.send (new) | bracketed-paste framing + Enter, server-side |
read | terminal.snapshot (new) | non-destructive screen + recent scrollback |
close | terminal.killSession (existing) |
Recommended flow: agents.create once to start → terminals.send for every follow-up (0 new sessions) → terminals.read to observe the reply.
How it works
- send: writes into the live PTY via the existing
session.pty.writepath. When the running program has bracketed-paste mode on (tracked by the per-session headless xterm), multi-line text is framed\x1b[200~…\x1b[201~so embedded newlines land as literal newlines in the TUI instead of premature submits. - read:
ModeTracker.snapshot()walks the existing per-session@xterm/headlessbuffer (already fed every output chunk) andtranslateToString()s each row. For TUI agents that's the alt-screen — the agent's rendered output, no escape gibberish. Tracker scrollback bumped 1 → 1000 (~1.5 MB/session worst case, lines allocate lazily) so plain shell output keeps recent history. - restart resilience: the in-memory session map empties on every host-service restart while the detached daemon keeps PTYs alive (#5823).
send/readrecover such sessions through the sameadoptOnlypath pane auto-adoption uses, then give the async ring-buffer replay a short grace period to rebuild the tracker before acting.
Relation to #5784 / #5837
#5784 shipped a much larger version of this capability (daemon protocol v3 snapshots, DaemonClient rewrite, prompt-file launcher) and was reverted in #5837 after a deterministic terminals-stop-accepting-input regression, with the note that it should be re-landed piecewise. This PR is the surgical subset: no pty-daemon changes, no protocol changes, no launcher changes — only two small host-service additions on top of already-production code paths (writeInput plumbing, mode-tracker emulator), plus thin public wrappers.
Surfaces
-
MCP v2:
terminals_list/terminals_send/terminals_read/terminals_close, explicithostIdtargeting (#5885) and tool annotations (#5917) -
SDK:
client.terminals.list/send/read/close, params take explicithostIdliketerminals.create -
CLI:
superset terminals list/send/read/close,--hostdefaults to this machine, samefindWorkspaceOnHostpattern asterminals create -
bun run lint— clean -
bun run typecheck— 35/35 tasks -
bun test(bun 1.3.14, matches.bun-version): mode-tracker 10/10, terminal router daemon tests 6/6
https://claude.ai/code/session_01C4cXgxgH3A7k3qHSLEyFNJ
Validation
End-to-end against a real pty-daemon Server, real SQLite host DB, and real shells (terminal.send-snapshot.node-test.ts, same harness as terminal.adoption.node-test.ts):
- send delivers text + Enter into a live shell;
submit: falsestages without executing - multi-line text is bracketed-paste framed only while the foreground program has paste mode on — verified byte-for-byte by capturing the PTY's delivered stdin with
cat > f(\x1b[200~line1\nline2\x1b[201~), and confirmed unframed again after the program turns the mode off - snapshot serves the normal buffer, the alt-screen buffer while a TUI is active, and respects
maxLines - after a simulated host-service restart (
__resetSessionsForTesting+ daemon-client disposal), both send and snapshot adopt the daemon-owned session: snapshot sees pre-restart output via ring-buffer replay, send reaches the same shell pid - error paths: unknown terminal (no accidental spawn), exited session (send fails, snapshot still serves the final screen), cross-workspace targeting rejected
Suite run 10 consecutive times: 10/10 green. Existing adoption suite still 20/20. Post-adoption readiness is now deterministic: instead of a fixed sleep, the code waits for the replayed ring-buffer bytes to quiesce (they land in session.buffer — no renderer is attached right after adoption), capped at 500ms.
Live dogfood against a real Claude Code session (v2.1.219, driven end-to-end through writeFramedInputToSession / snapshotSession on a real daemon PTY): snapshot renders the TUI faithfully (welcome screen, trust dialog, transcript, footer); the trust prompt was accepted via send; an echo-test prompt came back verbatim (input path is byte-perfect); a 4-line message was delivered as ONE paste-framed message (transcript shows a single 4-line bubble, the agent counted "FOUR", and the composer footer showed "paste again to expand"); messages sent while the agent was mid-turn were queued by Claude Code, not lost.
Additionally, the same scenarios run through appRouter.createCaller — the wire layer the MCP/SDK/CLI verbs actually hit — verifying the zod schemas, the submit default, and the TRPC error mapping (NOT_FOUND / FORBIDDEN / BAD_REQUEST / UNAUTHORIZED). Full suite (12 tests) run 15+ consecutive times, all green.
Run locally (needs the tsx loader for the router tree, and Electron-as-Node because better-sqlite3 here is built for the Electron ABI):
cd packages/host-service
ELECTRON_RUN_AS_NODE=1 <electron-bin> --import <tsx>/dist/loader.mjs --test src/terminal/terminal.send-snapshot.node-test.ts
Also: bun run lint clean, bun run typecheck 35/35, mode-tracker + terminal router bun tests 16/16 (bun 1.3.14, matches .bun-version).