PTY Rendering

PTY Rendering

PTY (pseudo-terminal) rendering is the live attach surface for agent shells — the operator pane in the dashboard, the `sandboxctl session attach` flow, the AIWG terminal UI. It is the most reconnect-sensitive, sequence-sensitive surface in the system. This document covers the corruption-recovery work shipped during the v1 era (#180 phases 1–4) and the v2 `pty-ws/v1` custom binding that supersedes it for the v2 contract (#202 spec, #214 implementation).

The Rust source of truth for the v2 binding is `management/agentic-sandbox-executor/src/bindings/pty_ws.rs`. The v1 session protocol lives in `management/src/session/` and is documented from the operator's side in `ws-protocol.md`.


v1 PTY corruption history (#180)

The 2026.5.0 release captured four phases of fixes for a recurrent class of bug: the xterm.js renderer drifting out of sync with the PTY's internal grid after a resize, a reconnect, or both. The operator experience was a half-painted screen, ghost characters, or input echoing to the wrong line.

Phase 1 — Floor enforcement on `pty_resize`

The dashboard sent every `resize` event from the browser straight through to the server. Browsers occasionally fire transient `resize` events with bogus values during window-drag — `0×0`, `1×1`, or NaN-coerced-to-1. Each one tore down the PTY's grid and forced a full repaint that lost in-flight bytes.

Fix: client-side floor on `cols >= 20`, `rows >= 5`. Anything below that is dropped before the WebSocket send. Picked from the observation that no real terminal is usable below this floor anyway.

Phase 2 — Debounce + dual-frame stability check

Even with the floor, a rapid window-drag produced a flurry of intermediate dimensions. Each one was a real, valid size — just not one the user would settle on. Sending all of them caused churn.

Fix: 200 ms debounce on outgoing `pty_resize`, plus a dual-frame stability check (the size must hold for two consecutive `requestAnimationFrame` ticks before it's accepted as final). Net effect: one `pty_resize` per drag, not one per pixel of mouse motion.

Phase 3 — Server-side reject below 20×5

Defense-in-depth for Phase 1: any client (not just the bundled dashboard) that sends `cols < 20` or `rows < 5` gets the resize rejected on the server. Trace emitted on accept and on drop so the cause is visible in `mgmt.log` and the dashboard's System tab.

Phase 4 — `term.reset()` on attach, `⟳ Resync` button

The hardest case: an already-attached pane reconnects after a brief disconnect (laptop sleep, WebSocket idle timeout). The server's view of the screen is correct; the client's xterm.js buffer is stale. Replaying the diff doesn't help because the diff is computed against the wrong baseline.

Fix part A (automatic): every `attach_session` from the dashboard now calls `term.reset()` on the xterm.js instance before resubscribing. This forces a full repaint from a known clean state.

Fix part B (manual): a `⟳ Resync` button (`pane-resync-btn` in the dashboard) gives the operator a single-click escape hatch when the auto-reset doesn't catch every edge. Implementation is at `management/ui/app.js:899` (`resyncPane(agentId)`); it calls `term.reset()`, drops the WS subscription, and re-attaches.

The relevant traces (`pty_resize accepted`, `pty_resize rejected`, `JoinSession attempt`, `JoinSession replay window`, `libvirt_blocking` duration warnings) are documented in the 2026.5.0 CHANGELOG entry for #188 sections A–C and surface in the ring-buffer logs documented in `transport-audit.md`.


Multi-controller architecture

The formal v1 session protocol — and its v2 successor — model a PTY as a multi-tenant resource. There are two roles:

RoleCapabilities
`Controller`May send input (`SessionInput`), may resize (`SessionResize`). Multiple controllers may coexist; the server serializes their writes.
`Observer`Read-only. Receives `SessionFrame` output and `MembershipChanged` events but cannot send input.

Source: `management/src/session/mod.rs:38` — "Multiple `Controller`s may coexist; server serializes their writes. An `Observer` attachment is locked read-only — the client must request `Controller` role explicitly via a separate verb."

Role transitions emit a `MembershipChanged` event to every attached client. The payload includes the full current membership snapshot (every client, with its role) — clients replace local state from the snapshot rather than reconciling deltas, which eliminates a class of split-brain bugs where two clients disagree on who is controller.


Replay buffer

The formal v1 session registry keeps a small hot replay window in RAM so reconnecting clients and fresh observers can converge quickly without making every long-lived TUI session a memory sink. The default hot window is the previous three 80x24 screenfuls:

  • `DEFAULT_HOT_SCREENS = 3`
  • `DEFAULT_MAX_FRAMES = 72`
  • `DEFAULT_MAX_BYTES = 23,040` raw bytes per session

That hot window is intentionally not the long-term transcript. Older output and keyframe frames spill to a durable per-session JSONL archive under the management data directory at `pty-transcripts/<session-id>.jsonl`. The hot ring is the low-latency attach/reconnect cache and is included in transcript searches. Older evicted history is read through the same transcript API from durable spill:

`GET /api/v1/sessions/{id}/transcript?from_seq=&to_seq=&stream=&q=&limit=`

Supported filters are sequence range, stream (`stdout`, `stderr`, `log`), substring search via `q`/`pattern`, and a bounded result limit. Operators can tell when a session is overflowing its hot window through `/api/v1/sessions` replay counters and the Prometheus series documented in `telemetry.md`.

The v2 `pty-ws/v1` binding still follows the contract-level replay constants while the migration proceeds:

  • `REPLAY_MAX_FRAMES: usize = 1000`
  • `REPLAY_MAX_AGE_HOURS: i64 = 24`

A reconnecting client passes `replay_from=<sequence>` and receives every frame since that sequence number if it is still hot. If the requested `replay_from` precedes the oldest retained sequence the server returns the documented out-of-range error and the client must call `request_keyframe` to get a fresh baseline.

`Keyframe` synthesizes a start-fresh snapshot: the current screen state encoded as a single frame the client can apply directly to a freshly-reset xterm.js. Keyframes are emitted on demand and on role transition so a newly-promoted controller gets the current state without replaying from session start.


Migration: v1 session protocol → v2 `pty-ws/v1` binding

The v1 session protocol multiplexes legacy agent-scoped verbs and formal session verbs (`JoinSession`, `LeaveSession`, `SessionInput`, `SessionResize`, `SessionFrame`) over the single `:8121` WebSocket endpoint. Messages are routed by `type`. The dashboard and `sandboxctl` both speak it today.

The v2 binding is a clean break: one WebSocket per `(instance_id, session_id)`, narrowed to the PTY surface, scoped under the per-instance A2A endpoint.

Aspectv1 (legacy `:8121`)v2 (`pty-ws/v1`)
URL`ws://host:8121/` (shared with agent-scoped verbs)`wss://host/agents/{instance_id}/sessions/{session_id}/attach`
MultiplexingMany sessions per WS, routed by `type`.One session per WS.
SubprotocolNone negotiated.`pty-ws.v1` (echo MUST match).
Frame envelope`type`-tagged JSON, ad-hoc shape per verb.A2A envelope: `{op, id, ts, sequence, replay_from, service_parameters, extensions, payload}`. See `docs/contracts/bindings/pty-ws/v1/spec.md`.
ReplayImplicit; server replays during `JoinSession`.Explicit `replay_from=<seq>` with documented out-of-range error.
KeyframesImplicit re-paint on attach.Explicit `pty.request_keyframe` verb.
Role assignmentImplicit — first attach is controller.Explicit `pty.request_role` / `pty.release_role` verbs.
AuthBearer over query string (legacy).Bearer / mTLS over WS upgrade (auth enforcement deferred per `pty_ws.rs` rustdoc; arriving in a follow-up patch).
Spec stabilityDe facto stable from v1 era.`beta` tier per ADR-020; graduates to `stable` after v2.0 conformance harness validates.

The v2 binding was authored as ADR-020 (PTY custom protocol binding) because A2A's three standard transports — HTTP+JSON, JSON-RPC, gRPC — cannot model interactive terminal attach efficiently. PTY I/O is full-duplex, low-latency, and produces frames at keystroke cadence. A custom binding is permitted by the A2A spec when the standard transports are functionally inadequate, provided functional equivalence and data-model preservation are maintained (§5 of the A2A custom-binding rules; see spec §1.1).

Migration deviations

The implementation rustdoc at the top of `pty_ws.rs` documents two deliberate deviations from the full spec, tracked separately:

1. Envelope shape. The implementation uses the simpler `{op, seq, payload}` shape from the issue brief rather than the longer `{op, id, ts, sequence, replay_from, service_parameters, extensions, payload}` shape in spec §3. Behavioral contracts (replay, role assignment, error vocabulary) match the spec. 2. Auth. Bearer / mTLS enforcement at the WS upgrade is deferred. The existing `InstanceLayer` still resolves `{instance_id}` (404 on miss); per-token validation arrives in a follow-up patch.

A real PTY process plumbing lands behind the `PtyBridge` trait (#237). When `AppState::pty_bridge.is_real() == true`, `pty.session_input` and `pty.session_resize` are forwarded to the bridge; the bridge's output stream feeds `output` frames into the session. The default `NoOpPtyBridge` preserves legacy broadcast-echo behavior for tests.


xterm.js integration patterns

The dashboard renders every pane as an xterm.js instance bound to a single PTY session. The patterns the dashboard observes (and that any integrator should follow) are:

  • Reset on attach. Always call `term.reset()` before re-binding

the WebSocket output stream. Phase 4 of #180 made this mandatory; without it, stale buffer contents combine with replayed frames to produce ghost output.

  • Floor the resize. Never emit `pty_resize` (v1) or

`pty.session_resize` (v2) below 20×5. The server rejects below that floor anyway, but the client-side floor avoids the round-trip.

  • Debounce drags. 200 ms is the dashboard's value; anything in

the 100–300 ms range works. Couple with the dual-frame stability check (`requestAnimationFrame` twice) for window-drag scenarios.

  • Surface the resync. Operators occasionally hit edge cases the

auto-reset doesn't catch — laptop-sleep, NAT timeouts, a particularly aggressive curses TUI. The `⟳` button in `management/ui/app.js` is the operator-side escape valve. UI integrators should expose an equivalent.

  • Filter `MembershipChanged`. Operators don't need to see every

join/leave; the dashboard logs them at debug level and only surfaces a toast when the operator's own role changes (promotion to Controller, demotion to Observer).


See also

session protocols on `:8121`. Authoritative reference for current dashboard behavior.

— full v2 binding specification with envelope schema, replay semantics, and conformance rules.

mapping for the rest of the surface (REST, gRPC).

accepted/rejected` traces surface in operator-visible logs.

  • `CHANGELOG.md` — 2026.4.x and 2026.5.0 entries for #180 phases.