Agent-First Terminal Runtime Plan
Status: PTY sessions, structured screen snapshots, multiplexed runtime events, actor-aware input with TTL leases and human preemption, secret input mode, and the feature-gated OpenAPI server, authoritative DOM-rendered local CLI UI, and UI attachment to an already-running server are implemented. Transcript persistence is deferred: no consumer in this runtime or its callers reads one yet, and it would be the only part of the system that writes terminal content to disk. Revive it when a reader exists that can state a retention period and a read scope.
Summary
agent-first-terminal should be a generic local terminal runtime, not an
agent-specific automation layer or application UI data model.
It turns a PTY-backed process into an embeddable, observable, controllable, and replayable UI-producing surface. The stock DOM renderer, other renderers, test harnesses, and higher-level automation controllers all consume the same runtime.
Current Implementation
The runtime currently owns these pieces:
- PTY session creation through
portable_pty. - Generic program and argument execution, with a shell default.
- Multiple independently identified sessions.
- Raw input writes.
- Resize.
- Real foreground process-group signals (
interrupt,terminate, andkillon Unix). - Identified human, agent, renderer, controller, test, and replay input actors.
- Shared and exclusive input leases with monotonic TTL expiry.
- Human input that immediately preempts non-human exclusive input ownership.
- Bounded scrollback.
- Subscriber fan-out for raw output bytes.
- Session metadata and status.
- Structured VT screen snapshots through
vt100. - One bounded, globally sequenced event stream across every session.
- A feature-gated loopback HTTP server with bearer authentication.
- A generated, committed OpenAPI 3.2 contract whose drift is checked by
scripts/test.sh. - A trusted local DOM-rendered window that shares the API server’s authoritative screen and session manager and identifies its writes as human input.
- Authenticated, revocable UI attachment to an already-running server without creating or restarting a PTY session.
Design Principle
Keep terminal runtime semantics separate from task semantics.
The runtime should know about sessions, screens, input actors, events, and
transcripts. It should not know whether a process is Codex, Claude, opencode,
psql, k9s, an installer, or a deployment script. It should not decide that a
user task is complete.
Higher-level controllers can implement agent-specific task logic on top of the runtime when they need it.
Goals
- Provide a reusable terminal runtime for any CLI or TUI process.
- Expose a screen model, not only raw ANSI bytes.
- Support human and programmatic input through a shared coordination model.
- Let humans interrupt or take over programmatic input immediately.
- Preserve enough history for replay and debugging without making raw recording the unsafe default.
- Treat password/token entry as sensitive by default.
- Keep the core runtime independent of presentation and application task semantics.
- Let the CLI expose a human UI without granting browser code the API bearer credential.
Non-Goals
- No Codex-, Claude-, or opencode-specific completion protocol in the runtime.
- No
task.doneorautomation.startAPI at the runtime layer. - No mutation of application data models from terminal output.
- No remote exposure by default.
- No assumption that every terminal session is an agent session.
Primary Use Cases
- Embed long-running local processes such as
npm run dev,cargo test --watch,docker compose up, andtail -f. - Host REPLs and shells such as Python, Node, SQLite,
psql, or debugger consoles. - Render and share operational TUIs such as
k9s,htop,lazygit, SSH sessions, serial consoles, and device logs. - Drive interactive installers and deployment scripts that ask yes/no questions or request secret input.
- Provide a visible, interruptible workbench for local coding agents.
- Record and replay terminal sessions for support, audit, and debugging.
- Test CLI/TUI applications against screen state rather than raw stdout.
- Let a local agent modify user-owned host files while the user can watch and take over.
Architecture
agent-first-terminal
PTY process/session ownership
raw output fan-out
VT/ANSI screen tracker
event stream
input actor coordination
secret input mode
(deferred) transcript and replay policy
afterminal ui
isolated local app window
multi-session DOM rendering from authoritative styled snapshots
human input and process controls
short-lived UI capability
new-runtime or existing-API attachment
agent/controller layer
optional task ids
prompt markers
agent-specific waiting logic
high-level automation policy
Proposed Crate Shape
.
├── src/lib.rs
├── src/runtime.rs
├── src/session.rs
├── src/screen.rs
├── src/events.rs
├── src/input.rs
├── src/lease.rs
├── src/server.rs
└── docs/terminal-runtime-plan.md
server.rs should be optional or feature-gated. The core runtime should be a
Rust API that embedded hosts can use directly.
Core Concepts
Session
A session owns one PTY-backed process plus host-local state.
pub struct TerminalOpenSpec {
pub program: String,
pub args: Vec<String>,
pub cwd: Option<PathBuf>,
pub env: BTreeMap<String, String>,
pub rows: u16,
pub cols: u16,
pub title: Option<String>,
}
The runtime may provide a shell helper, but the generic primitive should be
program + args. The CLI UI defaults to the user’s shell when it opens an
initial session without an explicit program.
Screen
The screen is a host-maintained virtual terminal snapshot derived from raw PTY bytes.
pub struct ScreenSnapshot {
pub seq: u64,
pub rows: u16,
pub cols: u16,
pub title: Option<String>,
pub cursor: CursorState,
pub lines: Vec<ScreenLine>,
pub activity: ActivityState,
}
The screen model is for observation and rendering. It is not a transcript and should not be treated as the only audit source.
Events
Events are the stable observation stream for hosts and controllers.
pub enum TerminalEvent {
SessionOpened { session_id: String },
ScreenChanged { session_id: String, seq: u64 },
OutputChunk { session_id: String },
InputAccepted { session_id: String, actor: ActorId },
InputRejected { session_id: String, actor: ActorId, reason: String },
InputPreempted { session_id: String, previous: ActorId, by: ActorId },
InputLeaseAcquired { session_id: String, lease_id: String, actor: ActorId },
InputLeaseReleased { session_id: String, lease_id: String, reason: String },
SecretInputStarted { session_id: String, reason: String },
SecretInputEnded { session_id: String },
ProcessExited { session_id: String, code: Option<i32> },
}
OutputChunk should not imply that every subscriber can read raw bytes. Raw
stream access is a separate capability.
Input Actors
Every input source should identify itself as an actor:
humanagentrenderercontrollertestreplay
The runtime records actor metadata for accepted input. It should not record secret bytes.
Input Leases
Input leases coordinate concurrent actors without allowing request bytes to interleave. Non-human actors must hold a lease:
{
"actor": "controller",
"mode": "exclusive",
"ttl_ms": 2000,
"preemptible_by": ["human"]
}
Default rule: human input preempts non-human exclusive leases immediately. Multiple shared holders may submit complete chunks; manager serialization defines their order. An exclusive lease reserves non-human input for its holder.
Transcript (deferred)
Transcript policy would be explicit per session (off / redacted / raw),
with redacted the default for anything persisted and raw requiring explicit
host authorization. Nothing implements this: there is no reader. When one exists,
a transcript must write nothing at all during a secret input window — which the
current implementation already satisfies, since that window does not even reach
the replay ring.
Secret Input Mode
Secret input mode is a runtime state a session is put into while a person types something that must not be observed — a password, a recovery phrase, a key. While it is on:
- Raw output is not fanned out to subscribers and not retained for replay, so there is nothing to replay afterwards either.
- The screen snapshot is withheld rather than blanked:
linesis empty,secret_inputis true, and the seq/alt-screen facts stay as they were when the window opened. A caller can still tell the session is taking a secret, which is the one thing it must be able to tell. - Output and input volume produce no events.
chunk_bytesandinput_bytesare the length of what is being echoed and typed; suppressing them entirely is cheaper than reasoning about how much they leak.SecretInputStartedandSecretInputEndedbracket the gap so it reads as deliberate silence rather than a dead session. - Every non-human actor is refused input, signals, and leases with
secret_input_active(HTTP 409, retryable, with a hint naming the event to wait for). The refusal itself is announced asInputRejected, so the agent learns why. - The VT parser keeps consuming output. Skipping it would leave the screen model permanently out of step with the real terminal after every window.
Any actor may start a window — raising the shield is always the safe direction,
so a best-effort prompt detector should be able to. Only a human actor may end
one: an agent that could end it could end it and then read the screen.
Ending waits for the session to go quiet (SECRET_INPUT_SETTLE_MS), because
resuming publication would otherwise release the echo of what was just typed
that the reader thread has not drained yet. Until then, ending is refused with
secret_input_settling and the caller retries; the failure direction is “stays
private”.
What this does not cover: output the program leaves on screen after the window closes is visible again, exactly as it is to anyone looking at the window. The guarantee is that nothing derived from the session’s bytes leaves the runtime while the window is open.
Host API
The machine-facing HTTP endpoints are terminal runtime endpoints, not UI transport. Browser state and actions use AFUI’s retained state and typed calls; there is no terminal-owned WebSocket or SSE protocol:
GET /health
GET /openapi.json
GET /schemas/index.json
GET /schemas/<schema_file>
POST /v1/sessions
GET /v1/sessions
GET /v1/sessions/<id>
GET /v1/sessions/<id>/screen
POST /v1/sessions/<id>/input
POST /v1/sessions/<id>/resize
POST /v1/sessions/<id>/signal
GET /v1/sessions/<id>/secret-input
POST /v1/sessions/<id>/secret-input/actions
GET /v1/sessions/<id>/leases
POST /v1/sessions/<id>/leases
DELETE /v1/sessions/<id>/leases/<lease_id>
DELETE /v1/sessions/<id>
GET /v1/events
Domain responses use the AFDATA envelope (kind: result with a trace, or
kind: error with code/message/retryable/hint), and every response is
passed through AFDATA redaction at the serialization boundary. The discovery
documents are served bare under their own media types. The event stream’s items
are described by the operation’s itemSchema rather than wrapped in a finite
result envelope, and Last-Event-ID resumes after a given global sequence while
it remains in the bounded backlog.
Transcript and a public raw-stream endpoint are not part of this surface; raw bytes remain a host-library interface and never become the browser protocol.
Suggested access levels:
view_screen: read screen snapshots and non-sensitive events.send_input: send ordinary input as an actor.manage_input: create and revoke leases.read_raw: read raw PTY bytes.admin: create, kill, signal, or reconfigure sessions.
These levels are a design intent, not an implemented check: the server has one
bearer credential today. api serve --mode local binds loopback and is the
default; --mode lan binds all interfaces for a trusted IPv4 network and
publishes this machine’s LAN address in the ready event.
Relationship To The Local UI
afterminal ui is a thin trusted renderer over the same
TerminalSessionManager exposed by the OpenAPI server. It can either own a new
local server or attach to an existing one with
--api-url URL [--session-id SESSION_ID] [--mode window|link|session]:
- It lists and switches among all current sessions.
- It receives the newest complete styled VT snapshot as retained AFUI state and renders cells without parsing ANSI bytes in the browser.
- It writes as
human:local-ui, so ordinary typing immediately preempts a non-human exclusive lease. - Input, resize, close, and process-signal controls are typed AFUI calls that invoke the same runtime methods as API clients.
- Its random URL capability is separate from the API bearer credential and is never emitted in normal CLI output.
- An authenticated CLI may ask a running server for multiple independent UI capabilities. Each capability has a sliding idle expiry for crash cleanup; the CLI renews it behind the AFUI delivery and revokes it when that delivery ends.
Promise: the page can rebuild everything it displays from its own URL alone. The server-side screen is the only VT state authority; the browser keeps no parsed terminal state of its own between loads. On first load and reconnect, AFUI delivers the newest complete retained state before declaring the runtime live. There is no browser-owned sequence, SSE gap repair, or private screen refetch protocol. Losing the tab, losing the process, or just hitting reload all land on the same screen through the same URL, with no raw-byte replay and no client-held history required.
The browser UI and capability-management routes are private implementation routes and are not part of the OpenAPI contract. The machine API remains the integration surface for external controllers.
Relationship To Agent Automation
Agent automation is an upper layer.
For example, a Codex/Claude/opencode controller can:
- Open or attach to a terminal session.
- Send a prompt through normal actor input.
- Watch screen and event streams.
- Pause when a human preempts input.
- Detect completion through a marker, hook, or agent-specific event.
- Emit
task.done,task.blocked, ortask.interrupted.
None of those task states belong in agent-first-terminal.
Security Model
Terminal runtime access is sensitive. It can observe command output and may observe secrets if configured poorly.
Required defaults:
- Bind local transports to loopback unless explicitly configured otherwise
(
--mode localis the default;--mode lanis the explicit opt-in). - Require capability tokens for API access, in
Authorization: Bearer. A credential in a query string is not accepted. - Keep styled screen snapshots in retained AFUI runtime state, behind the UI’s short-lived capability; raw bytes remain a host-library interface.
- Withhold everything derived from a session’s bytes while it is taking secret input, and pause non-human actors for the duration.
- Record actor identity for non-secret input.
- Make secret input state unmistakable in any renderer, and make entry and exit observable as events so both the person and the agent know which mode they are in.
- Pass every HTTP response through AFDATA redaction, so a
_secret-suffixed field cannot reach a client by being added somewhere upstream. - Do not derive executable programs or arguments from untrusted terminal output or browser content.
Prior Art To Learn From
- DOM row/cell rendering: selectable, accessible terminal presentation without a second browser-side VT parser.
ttyd, Wetty, GoTTY: PTY over WebSocket and browser terminal hosting.tmate, Upterm: terminal sharing and human takeover.- asciinema, ttyrec: terminal recording and replay.
- Expect, Pexpect: output-driven terminal automation.
- Teleport, Guacamole, ShellHub: terminal access, audit, and policy at larger platform scale.
The intended runtime is smaller than the platform products and more embeddable than the sharing tools.
Migration Plan
Phase 0: Planning Skeleton
- Create the
agent-first-terminalcrate at the repository root. - Keep this document as the initial design record.
- Do not publish a crate until code has moved. Done for Phase 1.
Phase 1: Extract Existing PTY Runtime
- Move the generic session manager into
agent-first-terminal. - Add compatibility tests around open, write, subscribe, resize, kill, and metadata. Done for the extracted PTY runtime.
Phase 2: Add Screen Tracking
- Feed raw PTY bytes into a VT/ANSI screen tracker.
- Expose
screen(session_id)from the Rust API. - Add snapshot sequence numbers and activity timestamps.
- Keep raw byte subscribers in the Rust runtime for embedders. Done.
Phase 3: Add Runtime Events
- Add one bounded multiplexed event stream tagged by session id.
- Emit screen, input, resize, title, and process lifecycle events.
- Keep event payloads safe by default.
The globally multiplexed screen/output/input/lease/resize/signal/process lifecycle stream is done. Title events remain coupled to later title work.
Phase 4: Add Input Actors And Leases
- Require an actor for host-side writes.
- Preserve a simple write helper for trusted internal callers.
- Implement exclusive leases with TTL.
- Implement human-preempts-controller behavior.
Done. Non-human API actors require a shared or exclusive lease, multiple shared actors can submit atomic chunks, and human input preempts a non-human exclusive lease.
Phase 5: Add Secret Input Mode
- Add explicit enter/exit APIs and a session-level state.
- Withhold output, replay, screen content, and input volume while it is on.
- Suspend every non-human actor for the duration.
- Announce entry and exit so both the person and the agent know the mode.
Done. Transcript policy and storage hooks are deferred with it — see the status note at the top of this document.
Phase 6: Add A Local Human UI
- Add
afterminal ui [SESSION_ID]as a visible server mode. - Let
afterminal ui --api-url URL [--session-id SESSION_ID] [--mode MODE]attach to a running server without creating or restarting a runtime. - Render authoritative styled snapshots with the stock DOM renderer.
- Share one manager between browser input and external API controllers.
- Mark browser input as human so existing preemption semantics apply.
- Keep the short-lived UI capability separate from the API bearer credential.
Done. The CLI supports AFUI Window, Link, and Session delivery, one or many terminal sessions, attachment midway to a separate running server, snapshot replacement through the typed AFUI runtime, and resize, interrupt, terminate, kill, and close controls.
Phase 7: Optional Standalone Host
- Add a feature-gated local runtime server if embedded Rust APIs are not enough.
- Keep loopback/token defaults.
- Do not expose a remote server profile until the security model is reviewed.
The initial standalone host is done for the current session/screen/actor-input/lease/resize/signal/close/event surface. It exports and serves the same OpenAPI 3.2 document.
Open Questions
- How should renderer clients declare actor identity and capabilities, and how do access levels become a real check rather than one bearer credential?
- How much prompt-based secret detection is useful before it becomes misleading?
- What is the minimum replay format: asciinema-compatible, custom JSONL, or both?
Acceptance Criteria For MVP
- An API caller can open a local command, read screen state, send input, and observe events.
afterminal uican display and operate the same sessions without exposing the API bearer token to browser code.- A human input actor can preempt a controller actor.
- A secret can be typed into a live session without any of it reaching the raw byte stream, the replay ring, the screen snapshot, or the event stream, and with every non-human actor refused for the duration.