feat(terminals): send/read/close follow-ups to running terminal agents

Merged
SA
3mo ago
+1404-7

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:

VerbHost procedureNotes
listterminal.listSessions (existing)daemon+DB truth (#5823), so background agents show up
sendterminal.send (new)bracketed-paste framing + Enter, server-side
readterminal.snapshot (new)non-destructive screen + recent scrollback
closeterminal.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.write path. 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/headless buffer (already fed every output chunk) and translateToString()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/read recover such sessions through the same adoptOnly path 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, explicit hostId targeting (#5885) and tool annotations (#5917)

  • SDK: client.terminals.list/send/read/close, params take explicit hostId like terminals.create

  • CLI: superset terminals list/send/read/close, --host defaults to this machine, same findWorkspaceOnHost pattern as terminals 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: false stages 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).

https://claude.ai/code/session_01C4cXgxgH3A7k3qHSLEyFNJ