PTY runtime (event journal)
warden has historically supervised agents by polling tmux: capture the pane, classify the text, type answers back. The PTY runtime is the replacement foundation: an ordered, redacted, replayable event journal per session, with a reconnectable WebSocket stream. It is rolled out behind one setting, and the default changes nothing.
runtime: pty: off # off | shadow | onThe setting hot-reloads; a change applies to sessions created afterwards.
For creating and using a plain-shell kind=terminal session (rather than an AI
agent), see Terminal sessions. This guide covers the PTY runtime
that can observe or directly host that session.
| Mode | Agents | Plain-shell terminals | Journal / stream |
|---|---|---|---|
off (default) | tmux, exactly as before | tmux, exactly as before | none |
shadow | tmux, same launch path; output mirrored with tmux pipe-pane | tmux, mirrored | yes, observe-only |
on | as shadow | hosted directly on a warden-owned PTY (no tmux session) | yes; interactive for terminals |
- Shadow never writes to a pane. The pollers keep deciding status and answering approvals exactly as today; the journal is a side channel.
- Turning it back to
offstops every mirror (the panes keep running). Direct terminals keep running until they exit. - Restart: tmux panes survive a daemon restart, and the next daemon re-adopts their
mirrors on a new epoch (
lifecycle{recovered}, a reset marker and a snapshot of the live screen). A direct terminal’s shell is owned by the daemon and ends with it; the next daemon (inonmode) starts a fresh shell for every running direct terminal on the same journal, so a reconnecting client is resynchronized from the snapshot.
What is in the journal
Section titled “What is in the journal”Every event (output, input, resize, lifecycle, marker, and later state,
prompt, decision) carries a session-wide seq, an epoch (a new process or a
re-adoption), and is redacted before it is written: API keys, tokens, bearer
headers, URL credentials, private keys and typed secrets become [REDACTED:<kind>].
Journals live under <data_dir>/pty/ (direct/<id> and shadow/<id>, 0700/0600)
with generous retention (64 MiB per session, snapshots every 256 KiB of output).
Only the live, interactive attach of a direct terminal ever sees unredacted bytes: that is the user’s own terminal. Everything replayed is redacted.
Streaming a session
Section titled “Streaming a session”GET /api/v1/sessions/{id}/pty upgrades to a WebSocket (same bearer token as the rest
of the API; 409 when the session has no journal). The client speaks first:
{"type":"hello","mode":"observe","cursor":{"session_id":"A-1","epoch":1,"seq":42}}The server answers with a plan (tail, replay or snapshot, plus head, the
replay/live boundary). Its additive fidelity is redacted-text for journal replay
or snapshots and exact-live for a live tail; older clients safely ignore that field.
It then sends one {"type":"event","event":{…}} frame per journaled
event in seq order — the replay from your cursor, then live. Keep the last seq you
saw: reconnecting with it resumes exactly where you left off. {"type":"ack","seq":N}
stores that checkpoint server-side for your client_id, so a later hello without a
cursor resumes from it.
reject— the cursor cannot be served (another session, ahead of the journal): discard state and send a freshhello.resync— you fell behind; sendhelloagain with your last cursor.snapshotplans start with a snapshot marker: reset your screen to its text.
Interactive ("mode":"interactive", direct terminals only, full token): binary
frames you send are keystrokes (journaled as human input, which always wins over
automatic input); {"type":"resize","cols":120,"rows":40} resizes the PTY. You
additionally receive the live, unredacted bytes as binary frames: render output events
with seq <= head from the event data and everything after from the binary frames.
Shadow (tmux-mirrored) sessions and read-only tokens are always observe-only.
Prompt engine and permission postures
Section titled “Prompt engine and permission postures”With runtime.pty: on, prompts read from the stream are normalized (capability,
target, option meaning, cancel path), re-validated against the live screen right
before any key is typed, and answered according to the session’s permission posture.
A stale, changed, ambiguous or unrecognised prompt is never answered; it surfaces as
needs-attention. Strategic questions are never treated as permissions. In shadow
the engine only journals what it would have done.
Antigravity (agy) is integrated first: dangerously-execute-all uses its native
bypass mode and still answers the remaining permission and workspace-trust prompts;
read-only and read-write map to plan and accept-edits. Postures without an
exact native mode (such as allowlisted) are reported as degraded and enforced by
the router from the prompt stream. Formats warden has not seen are classified by
Fast-Brain on a redacted screen and only become learned shapes after deterministic
fixture replay, with an expiry and confidence floor.
Observing the runtime
Section titled “Observing the runtime”Every client renders the same documents:
warden inspect pty runtime <id> # state, typed waiting reason, evidence, input owner, route, posturewarden inspect pty decisions <id> # redacted, append-only decision trace (newest first)warden inspect pty metrics # warden_pty_* snapshot (Prometheus text at /api/v1/pty/metrics.prom)The TUI info pane, the web Agent tab (runtime card + decision table) and the approvals
views show the same data, plus a human-readable summary of the pending permission and
its decision route. A session on dangerously-execute-all carries a conspicuous red
DANGER banner everywhere; the numbered answer buttons stay available.
The trace is bounded by runtime.trace_max_records (default 5000) and
runtime.trace_max_age_days (default 30), stores a description of the chosen input and
never raw bytes or secrets, and a write failure never blocks a decision (it increments
warden_pty_decision_trace_errors_total). Metric labels are closed vocabularies - no
prompt text, paths or secrets. Strategic questions (plan or design choices) are never
auto-answered in any posture; only permission and workspace-trust prompts are.
Alert rules and a Grafana dashboard live in
deploy/observability/;
the full reference (migration, learned-shape safeguards, recovery) is
docs/PTY_OBSERVABILITY.md.
Survival: the PTY holder
Section titled “Survival: the PTY holder”With runtime.pty: on, each plain terminal runs under its own small holder process
(warden ptyholder, started in its own session). The holder — not the daemon — owns the
PTY master and the shell, so restarting, updating or crashing the daemon does not touch
your shells: the next daemon authenticates to the holder (private 0600 socket, peer uid,
a per-session credential, the recorded pid and start time of both holder and shell),
gets the master back and carries on with the same process, same working directory, same
scrollback and a new journal epoch (lifecycle{recovered}). It never relaunches a live
shell. If the holder is gone, the terminal is shown as orphaned with a journal entry that
names the cause and the next step (wd agent restore <id> starts a fresh shell) —
reconnecting is never silently replaced by a new shell. WARDEN_PTY_HOLDER=off keeps shells
as children of the daemon (they then die with it, as before).
Agents stay in tmux; the tmux server is their holder, and a restarted daemon re-mirrors a pane only if its process (pid + start time) is the one it recorded.
Project close/reopen is a different thing: it deliberately ends the shell and starts a fresh one (identity kept). It is never used for a daemon restart.
Attaching: wd attach, the cockpit, controller lease
Section titled “Attaching: wd attach, the cockpit, controller lease”For the normal terminal-session workflow, including creation, project ownership, and restoration, see Terminal sessions.
wd attach <id> attaches your terminal to a direct terminal natively: the local tty goes
raw (Ctrl-C, Ctrl-D, Ctrl-Z, full-screen programs, UTF-8/ANSI all behave as in a local
shell — signals and job control are the shell’s PTY’s), output is replayed then live
without gaps or repeats, window resizes follow your terminal, and the terminal is restored
on every way out. Ctrl-] then . detaches (the shell keeps running), Ctrl-] then
t takes control back, Ctrl-] Ctrl-] sends a literal Ctrl-]. WARDEN_ATTACH=observe watches
read-only. Tmux-hosted sessions still attach with tmux.
The shared ptyattach.Client is the single runtime-aware attach decision point:
direct sessions use /pty; kind=tmux returns tmux attach -t <key>; and an
unknown runtime returns ErrUnknownRuntime with the resolver’s reason rather than
guessing. WARDEN_ATTACH_DETACH_KEY accepts one byte or caret notation (for example
^]); an explicit client option takes precedence.
A session has any number of viewers and one controller. Attaching takes control (human precedence) and demotes the previous controller, which keeps watching; its keystrokes and resizes are rejected, never delivered. Input is tagged with the lease generation and a per-client id, so a flaky connection that resends a command does not run it twice — also across a daemon restart. Raw bytes shown to you are the program’s own (faithful); the journal, snapshots, audit and anything remote-safe are redacted, and typed secrets are journaled as a length only.
§8.2 fixture map
Section titled “§8.2 fixture map”The following named fixtures are the acceptance evidence for the shared protocol; they intentionally exercise both a direct shell and the mirrored legacy-agent path.
| Requirement | Fixture evidence |
|---|---|
| viewers, controller, takeover, handoff and stale/expired fencing | TestF_FENCE_01_OneControllerTakeoverBumpsGeneration, TestF_FENCE_02_StaleGenerationRejected |
| reconnect dedupe, replay ordering and ack resume | TestF_FENCE_03_ReconnectNeverDuplicatesInput, TestTransportInteractiveInputResizeAndReplay, TestReplayThenLiveOrderingMidStream |
| direct shell vs mirrored legacy agent observe-only; read-only bearer | TestTransportShadowIsObserveOnly, TestF_ROLL_03_ShadowNeverMutatesAuthoritativeStatusOrSendsInput, TestF_SURV_04_AgentPaneAdoptionChecksIdentity, TestPTYStreamReadonlyTokenIsObserveOnly |
| malformed/partial and oversized frames; viewer close does not stop shell | TestTransportClosesInvalidAndOversizedClientFrames |
| human precedence, cancellation and secret boundaries | TestInputOwnership, TestFencedSecretInputNeverReachesTheJournal, TestF_PRIV_01_PlantedSecretsAbsentFromDurableAndRemoteViews |
| detach chord, reconnect, raw-terminal restoration and signals | TestF_ATTACH_01_RawModeRestoredOnEveryExit, TestReconnectResumesWithoutDuplicatingInput, TestDetachKeyFromEnv |
| epoch/adoption, too-old/slow-viewer resync and snapshot fidelity | TestTransportReconnectAcrossDaemonRestart, TestShadowReadoptsAcrossDaemonRestart, TestLaggedRawViewerResyncsAtAFreshCut, TestPlanFrameFidelityIsAdditiveForOlderClients, TestTransportReplayIsRedacted |
| legacy raw compatibility | TestBridgeLegacyGoldenRawProtocol (the /attach golden) |
These tests pin that closing a viewer only closes its socket, and reconnect/adoption
does not launch a replacement child. The legacy /attach test remains deliberately
separate from the /pty protocol fixtures.
Compatibility
Section titled “Compatibility”GET /api/v1/sessions/{id}/attachkeeps working for every session. For a direct terminal it bridges the same raw protocol to the runtime (current screen first, then live bytes), so the web UI and the Android app need no change. This legacy bridge is intentionally frozen; new attach/control clients use/pty.wd send/session input and output also route to a direct terminal.- A direct terminal has no tmux session, so
tmux attachdoes not apply to it. Usewd attach <id>(below) — the cockpit’s terminal pane does exactly that — or the daemon’s/attach//ptyendpoints. Useshadowif you prefer tmux for terminals. - The design and the event contract are in
docs/specs/2026-10-19-pty-runtime-contract.md.