PTY Lifecycle and Process Integrity
Wardian is built to handle multiple simultaneous, long-running agent sessions with strict resource and process isolation.
Desktop ownership
Before changing home state or restoring providers, the desktop retains an exclusive OS lock at <wardian-home>/runtime/desktop-owner.lock. A second cooperating desktop using the same home exits with a diagnostic. Quit the old desktop before launching its replacement, or use a distinct WARDIAN_HOME for an independent instance. CLI and headless execution still use conversation leases. Do not delete the lock file: a crash releases its OS lock automatically. Legacy versions without this protocol must be closed explicitly. This lock does not prove provider exit or release prior-provider holds. See Desktop Home Ownership.
Codex owner startup and background index observation share a process-local, per-home and per-agent preparation turn. Observation skips absent SDK homes and yields to queued startup. Startup awaits an active local observation within its original startup deadline and cancellation signal, before preparing the habitat. The existing nonblocking OS preparation lock still rejects competing owners in other processes. Observation revalidates the managed-home mapping under that OS lock; an earlier existence check never authorizes a stale tree.
🌉 Cross-Platform PTY Layer
Wardian utilizes the portable-pty crate to provide a consistent PTY interface across different operating systems.
- Windows: Uses ConPTY (Windows Pseudo Console) through the
NativePtySystem. - Linux/macOS: Uses the standard Unix PTY system.
The PTY Model:
- Master: The control end of the PTY, used for reading output and writing input.
- Slave: The application end, where the selected runtime shell hosts the provider command.
Launch-independent terminal presentation
Interactive provider PTYs use Wardian's terminal capabilities, regardless of whether the desktop was started by a user or an automation shell. Wardian clears the launcher's NO_COLOR, NODE_DISABLE_COLORS, and FORCE_COLOR overrides before setting TERM=xterm-256color and COLORTERM=truecolor. Other provider environment values remain intact; explicit provider command-line options still apply.
The first visible, mounted, interactive Agents or Workbench terminal claims an unowned runtime and reports its viewport before revealing it. A restored terminal repeats this handshake when registration initially precedes its PTY. Recovery reports the current pane dimensions before requesting ownership, and checks again when queued activation runs that no owner has appeared. Read-only, hidden, and suspended views stay passive, and a second view cannot automatically take an existing owner's lease.
Windows console startup handshake
The bundled ConPTY requests the cursor position and primary device attributes before the provider starts. Wardian answers this opening sequence through the generation-scoped backend input channel. The reader holds fragmented opening queries until it recognizes the complete handshake, then consumes those queries so a mounted renderer cannot answer them again. Nonmatching bytes, the optional window command, subsequent output, and an incomplete prefix at EOF are preserved. A fresh embedded PTY reports cell 1,1 and VT100 advanced video capabilities. Startup can therefore proceed while its terminal pane is hidden or still mounting. Subsequent provider queries retain their existing handlers.
ConPTY 1.24 waits up to three seconds for the device-attributes reply. That wait consumes the provider's startup budget when only a frontend renderer can answer. The backend handshake removes this dependency while preserving the existing provider deadlines. See Microsoft's ConPTY DA1 startup change for the upstream wait behavior.
🛡️ Process Integrity (Windows Job Objects)
To prevent orphaned provider and console-host processes when Wardian crashes or is force-closed, the Windows implementation uses Job Objects via the win32job crate.
- On startup, Wardian creates an app-lifetime
win32job::Job. - The
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEflag is enabled. - Wardian assigns the backend process to that job before restoring or spawning interactive agents.
- Provider shells, CLIs, ConPTY console hosts, and descendants inherit the job from process creation time.
- When the Wardian process terminates, the job object is closed by the OS, which automatically kills all processes assigned to it.
New Windows Claude PTY runtimes also have a dedicated non-breakaway job. Their main thread is suspended until assignment succeeds, closing the post-launch descendant race. Fresh resume and clear reject new job members, retain and join the current members' handles, and verify the direct child and empty job before releasing their newly created prior-session hold. Other providers continue to use process-tree termination, with per-agent fallback jobs if app-level supervision cannot be installed. Legacy runtimes do not gain an exit receipt from post-launch assignment. See Verified Windows Claude Process Stop.
At startup, Wardian restores an agent as headless only while an unexpired background execution lease protects its conversation. Before starting an interactive provider process, it checks persisted leases under the cross-process lease lock and acquires a lifecycle transition lease. In particular, Codex's app-server owner starts before its PTY child, so the candidate scan must finish before that owner starts; a later scan would mistake Wardian's own owner for a duplicate. For a Codex provider process with a valid WARDIAN_SESSION_ID, that explicit identity takes precedence over incidental agent IDs in its launch arguments, while an explicit resume of another session still counts as a candidate. Unmarked or malformed processes use the conservative command-line fallback. Unreadable or semantically invalid lease data blocks that spawn until inspected. A persisted headless status or WARDIAN_SESSION_ID marker alone does not establish a live provider: descendants such as a marked Python server must not prevent restore.
The transition lease remains active through provider startup and publication. Before stopping an owned runtime for clear or fresh resume, Wardian persists an exact-owner hold on its previous provider session. This hold conflicts by old session only, does not set Headless, and survives retarget and restart. An unconfirmed stop cannot free that identity; a restart cannot silently expire the hold. Resume and clear transfer their exact lifecycle lease to the spawn watcher. Under the cross-process file lock, spawn retargets that acquisition to the selected provider session, including fresh_provider_session_id when resume_session is empty, and rejects another active owner of that session; the candidate scan still runs at the spawn boundary. Reusing a held old session is withheld. The mock provider's identity-confirmed Init is its bootstrap evidence because it has no later prompt signal. For every provider, release waits until the exact runtime generation and status incarnation are published. Other providers release on Idle, Error, or Off after that publication. A rejected or uncertain initialization event cannot signal bootstrap. A terminal status before publication retains exclusion because provider liveness is uncertain. New-agent registration also waits for roster commit and any provisional Codex attachment; failure or cancellation stops renewal and retains its startup lease until finite expiry. If resume or clear aborts after spawn but before publication, the attempted process stop does not prove exit. The watcher stops renewal but leaves the exact lease persisted until its finite expiry. An explicit failure publishes Error; cancellation can leave the existing placeholder until recovery. Retry must wait for expiry and any provider candidate must be inspected. Release uses the exact acquisition identity so a late event cannot remove a replacement lease.
Repair an uncertain previous-provider hold only after stopping every Wardian instance that shares the home and verifying that the old provider session has no live writer. Back up runtime/conversation-leases.json, remove only the hold record whose owner_kind, owner_id, and acquisition_id match the inspected entry, then restart. Keep the hold when provider exit remains uncertain. A direct PTY child exit or a successful process-tree kill request alone cannot establish that every provider descendant stopped on every supported OS.
For non-Codex clear and fresh resume, the old-session hold is created immediately before detaching the still-owned runtime, after archive and identity preparation. An earlier preflight error leaves no old-session hold. If clear aborts after detach but before stop, Wardian releases the exact hold after restoring the same runtime incarnation and handles. Codex native-owner shutdown creates the hold under the roster lock immediately before runtime capture, after stop preflight; an error after capture retains it because exit may be uncertain. Once provider spawn succeeds, setup failure or cancellation retains the spawn lease through its finite expiry; the readiness watcher releases the exact acquisition explicitly after publication. Resume, clear, and startup restore own a publication disposition through roster commit. Cancellation before commit signals the watcher to stop renewal and retain only the existing finite lease; commit disarms that signal without an await gap.
Startup recovery does not automatically kill persisted process trees on Windows, Linux, or macOS. A live process that appears to invoke the same provider with the saved session marker is ambiguous without a proven launch identity; Wardian holds recovery for manual inspection instead of killing it or starting a second provider. Process metadata may be unavailable on some hosts; the scan is best-effort and an empty result cannot prove that no provider exists. Windows Job Object cleanup when the app exits and termination of a provider owned by the current runtime remain separate lifecycle operations.
🔁 Spawning Lifecycle
Spawning an agent follows a deterministic sequence in manager::spawn_agent:
- Open PTY: Create a new master/slave pair.
- Resolve Runtime Shell: Select the configured shell profile (
Auto, discovered shell, orCustom). - Build Provider Command: Assemble the provider executable plus provider-specific flags from the selected
AgentConfig.provider_config. - Wrap for Host Shell: Convert the provider command into a shell-hosted invocation that respects the selected shell family.
- Spawn: The PTY slave spawns the shell-hosted command.
- Piping:
- A Writer Thread is spawned to handle input from the UI.
- A Reader Thread is spawned to capture output, parsing it for JSON logs and status transitions.
- Registration: The
ActiveAgenthandle is added to theAppState.
Shell-hosted Launch Notes
- Automation shell-command nodes and headless provider runs use the same shell resolver as interactive PTY sessions.
- On Windows, provider shims are host-aware: PowerShell hosts invoke
.cmdand.batshims directly through PowerShell, while POSIX-like hosts such as Git Bash or WSL may route Windows shims throughcmd.exefor compatibility. - On Linux and macOS, Wardian resolves shells from the standard shell list and executes the provider command through that shell's command-string mode.
Input Readiness and Interaction Delivery
PTY input is a transport, not Wardian's communication source of truth. The interaction control plane owns structured messages, asks, replies, delivery attempts, and Inbox evidence. The PTY writer is only one possible way to deliver an interaction to a live provider runtime.
Each interactive provider runtime has a provider input generation. The generation increments whenever Wardian creates or reattaches a runtime boundary, including spawn, resume, clear, and provider reattach. Readiness observations are valid only for the generation that produced them.
ProviderInputState {
session_id,
generation,
state: unknown | booting | ready | busy | action_required | unavailable,
ready_evidence: provider_event | prompt_detected | title_detected | manual_status,
observed_at
}Delivery follows these rules:
- Ready evidence for the current generation can drain queued interaction delivery.
- Booting, busy, action-required, unavailable, or missing input-sender states keep delivery queued with a precise reason.
- Readiness or status from an older generation cannot drain queued work for a newer runtime.
- Provider action-required status remains provider-owned. It usually represents a provider permission or authentication prompt, not a Wardian human-in-the-loop interaction.
- Codex readiness can use prompt detection as release evidence, but it must not depend on a fixed sleep before text injection.
This model prevents first-input races where Wardian writes into a provider before the provider prompt is actually ready. It also keeps Inbox and CLI behavior tied to durable interaction and provider events rather than terminal repaint artifacts.
Testing Boundaries
PTY behavior cannot be validated by browser-only UI tests.
- Browser Playwright smoke tests are useful for layout, navigation, and non-native UI regressions.
- Native Tauri runtime tests are required for:
- Tauri
invokebehavior - PTY-backed terminal rendering
- provider spawn and resume behavior
- shell-hosted process launch behavior
- Tauri
When debugging or testing PTY issues, treat browser smoke results as insufficient evidence. Use the native runtime harness for any claim about terminal or provider behavior.
📐 Terminal Resizing
Terminal resizing is presentation-aware. Each visible terminal reports its desired viewport without touching the PTY. Only the broker's active lease owner may submit an epoch-bearing resize with a monotonically increasing geometry sequence. The broker serializes native PTY resize, parser resize, canonical geometry update, and the geometry stream event as one commit; stale, mirror, or reordered requests are rejected nonfatally.
Desktop geometry is clamped to 20..500 columns and 8..200 rows. Remote geometry uses 20..240 columns and 8..80 rows. Mirrors scale, pan, or letterbox the canonical owner grid locally instead of applying smallest-client-wins geometry. This keeps desktop rendering stable when a phone or narrow split is attached.
See Terminal Presentation Broker for the generation, lease, snapshot, and ownership-transfer protocol.
🖥️ Frontend Terminal Runtime
Wardian's frontend terminal stack is built on xterm.js and is intentionally treated as a runtime layer, not just a view component.
Renderer Strategy
- Wardian uses xterm's WebGL renderer for mounted terminal views when available. WebGL is preferred because xterm's
customGlyphssupport for block and box-drawing characters does not apply to the DOM renderer, and provider TUIs such as Claude Code rely on those glyphs for mascot/status rendering. - If WebGL is unavailable or loses its context, Wardian falls back to xterm's built-in DOM renderer rather than failing terminal initialization.
- Renderer instances are not the source of runtime truth. Each presentation has an independent renderer, while the Rust broker parser, snapshots, and ordered event stream remain canonical if it must be recreated.
- Renderer retirement is lease-bound. Output/reset/refresh operations capture one renderer identity before awaiting; retirement releases its budget slot immediately but defers physical disposal until every in-flight operation finishes. Post-await work may mutate only the captured renderer generation.
- Agents keeps resident xterm renderers stable within the process budget while scrolling. Intersection controls WebGL promotion separately, so leaving the viewport does not cause destroy/recreate flicker. Above the budget, residency changes only when an approaching card needs capacity.
- Provider integrations must not depend on renderer-specific behavior.
Capability Handling
Terminal capability negotiation is centralized in src/features/terminal/terminalCapabilities.ts.
That layer is responsible for responding to standard terminal queries such as:
- device status reports
- resize and pixel-size queries
- DECRQM mode checks
- OSC palette queries
- OSC 10/11 foreground and background color queries
- synchronized output toggles
Provider-specific terminal adapters should only exist when a provider genuinely requires non-standard behavior. Capability replies should otherwise be implemented once in the shared terminal layer.
Codex is an exception: its backend responder lives in src-tauri/src/manager/codex_terminal_theme.rs, and the frontend suppresses automatic color replies. On Windows, the backend also leaves OSC 10/11 foreground/background queries unanswered. Codex 0.153.4 waits only 100 ms for these replies, then uses GetConsoleScreenBufferInfoEx; a reply arriving later can enter its user-input queue. Wardian cannot establish that the provider's query window is still open from a PTY read. Modern bundled ConPTY forwards the queries without answering them itself, so this policy relies on Codex's Windows console-palette fallback, not a duplicate-response assumption about ConPTY.
Startup remains bounded by that provider fallback. Wardian's existing renderer normalizes Codex chrome for light/dark themes, including theme changes, without sending unsolicited colors. The native console palette need not match every renderer color; this policy does not promise exact provider-side theme discovery. Unix OSC 10/11 replies, CSI 996 and OSC 4 behavior, and other providers' capability handling remain unchanged. The broker regression checks exact native-writer bytes around fragmented queries and paste/submit. Native transcript assertions must continue comparing exact submitted prompts; never repair a terminal-input mismatch by stripping archive text or merging unequal requests.
Protocol references: Codex Windows probe and ConPTY response ownership.
Broker Snapshot and Replay Model
Wardian preserves terminal state across presentation remounts and independent desktop/remote renderers while the PTY runtime is live.
That means:
- switching tabs
- zooming or restoring groups
- remounting the terminal component
should not discard the active terminal buffer.
Terminal contents are runtime state and are never written into the workbench document. A process restart can restore the tab but not a terminated PTY's screen contents.
The session model is split into two layers:
- a Rust broker parser that continuously receives PTY output and owns canonical in-process screen, geometry, bounded snapshot, and replay state;
- independent mounted presentation terminals that consume one ordered stream and can be disposed and reconstructed from a snapshot/barrier.
When a presentation remains resident within the process-wide budget, Wardian reuses its renderer across tab, layout, and viewport transitions. If it was suspended or evicted, the presentation applies a fresh bounded broker snapshot, discards events at or below the snapshot barrier, and then replays consecutive later events. It must resync again on a cursor gap or generation change.
Formatted snapshot replay first advances the broker's oldest-first history into xterm scrollback, then restores the absolute visible grid and cursor. Writing history only onto the visible screen would let the snapshot's cursor-home and erase-display commands delete it, particularly when history is shorter than the viewport. Empty history rows remain significant. The pure decoder in terminalSnapshotReplay.ts is exercised with the actual xterm parser and sanitized retained Claude snapshot-write bytes. This repairs replay of existing broker history; it does not infer history from provider repaint frames.
Redraw and Scrollback Normalization
Some TUIs repaint by moving the cursor home and rewriting the current viewport instead of using the alternate screen buffer. Wardian normalizes the cases that would otherwise diverge from user expectations:
- A clear-screen preamble made from many
EL + newlinewrites followed by cursor-home is treated as a real clear-and-home operation. This prevents TUI redraws, such as Claude's mascot frame, from being copied into scrollback during maximize/restore. - Synchronized home-redraw TUIs are marked as transient screen renderers. Before a row-shrinking resize, Wardian moves the local xterm cursor home so xterm does not promote the old visible TUI frame into scrollback before the provider redraws at the new size.
- After any resize, Wardian arms one duplicate-redraw suppression window. If the next synchronized home-redraw batch is mostly already present in the parser buffer, Wardian drops that repaint instead of letting xterm append a second copy of the same transcript to scrollback.
- Codex interactive sessions follow the agent-local terminal screen setting. In Fullscreen mode, Codex owns the alternate screen and its transcript scrolling. In inline Scrollback mode, Wardian journals overlapping home-redraw frames into xterm scrollback. Codex still emits a sliding viewport in that mode, so Wardian reconstructs dropped frame lines before applying the next repaint.
PTY Output Batching
The frontend drain path batches PTY output before writing into xterm instead of issuing one write per small chunk. This reduces render pressure during bursty output and improves scrolling behavior for TUI-heavy providers such as OpenCode.