--- status: draft (ported from alknet 2026-08-17; alknet-tty → alktty, alknet-tty-local → alktty's `local` feature module, alknet/tty → alk/tty, alknet-core → alkcall::core, alknet-call → alkcall, ADRs renumbered 052..093 → 001..008) last_updated: 2026-08-17 --- # alktty — Overview The terminal session protocol crate: a `ProtocolHandler` on `alk/tty` that pumps a bidirectional byte stream (stdin/stdout/stderr) with a JSON control channel (resize, signal, eof, exit) over a framed bidi stream, decoupled from the backend that allocates the PTY via a `TtyBackend` trait. This document covers the crate's purpose, the two-carriage model in brief, its dependency edges, the ALPN, and the backend location map. Component details are in the sibling documents. ## What `alktty` is the terminal session protocol crate for the ALPN-as-service architecture (alknet ADR-001). It registers the `alk/tty` ALPN on the shared endpoint and implements the `ProtocolHandler` trait (alknet ADR-002, alknet ADR-007). The `TtyAdapter` receives a `Connection`, accepts one bidi stream per terminal session, reads a single JSON negotiation frame, switches to a raw chunk format, and pumps bytes bidirectionally for the life of the session — backend-agnostic. The guiding insight that shapes the crate: > A terminal session is not an SSH concern, or a Docker concern — it is > a terminal concern. SSH and Docker are just two backends that can > allocate a PTY. The alknet-docker POC proved that the hard part of interactive attach — bidirectional byte pumping over a framed stream with a 1-byte stream-type multiplexer — is the same problem regardless of whether the backend is `bollard::attach_container()` or russh's `pty_request`. The POC's raw chunk format is the seed of alktty's wire format. alktty extracts that pattern into its own crate and ALPN; the backends (Docker, SSH, local process) implement a `TtyBackend` trait; the `alk/tty` handler is backend-agnostic. This dissolves the PTY hedge in the alknet-ssh research (DP-5): PTY is not an SSH feature delegated to a separate crate, it's a tty feature that SSH happens to be able to provide. ## Why The crate's purpose is to be the terminal session library for downstream consumers. A hub that runs agent workspaces in containers wires `DockerTtyBackend` into the `TtyAdapter` and gets interactive terminal sessions over `alk/tty`. A coordinator that runs `cargo test` remotely wires `LocalTtyBackend` (pipe mode) and gets the runner pattern (a process whose stdin/stdout/stderr/exit-code stream over a framed bidi connection) — the same shape as GitHub/Gitea Actions runners, just over alk's transport instead of HTTP polling. A browser terminal (xterm.js over WebTransport, when WebTransport revives) connects to `alk/tty` directly and gets raw bytes without implementing SSH or the call protocol. The key architectural insight: **the wire format and the backends invert at the `TtyBackend` trait.** alktty owns the wire format, the negotiation frame, the chunk codec, the control channel, and the session lifecycle; the backends own the PTY allocation (docker exec with `tty: true`, russh `pty_request` + `shell_request`, `portable_pty::openpty`). The adapter is backend-agnostic and testable with a mock backend (in-memory pipes). See [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md). ## The Two-Carriage Model in Brief A `alk/tty` bidi stream has two phases (full detail in [tty-wire.md](tty-wire.md), decided in [ADR-001](decisions/001-wire-format-and-two-carriage.md)): 1. **Negotiation (JSON carriage).** The client writes a single length-prefixed JSON frame carrying the terminal parameters, backend selector, command, and environment. The framing is a 4-byte big-endian length prefix + UTF-8 JSON body, self-contained in alktty (the format coincides with alkcall's framing by convention; alktty does not depend on alkcall's internal wire types — see Dependencies below). 2. **Raw carriage.** After the negotiation frame, the stream switches to the chunk format (`[stream_type: u8][length: u32 be][payload]`) for the life of the session. Five stream types: 0=stdin (client→server), 1=stdout (server→client), 2=stderr (server→client), 3=ctrl-in (client→server, JSON control messages: resize, signal, eof), 4=ctrl-out (server→client, JSON control messages: exit). There is no `call.responded`/`call.completed` — this is not the call protocol; the raw-carriage byte pump is its own wire format after the single JSON negotiation frame. This is the pattern the docker POC validated and the SSH research independently arrived at: JSON for the structured request, raw bytes for the body, which is the part that is actually bytes. The full rationale (why not JSON for everything; the two-carriage decision; the Phase 7 control-channel split) is in [ADR-001](decisions/001-wire-format-and-two-carriage.md) §Context. ## Dependencies ``` alktty (default — wasm-clean) ├── alkcall::core (ProtocolHandler, Connection, AuthContext, Identity, AccessControl, │ OwnershipProvider — alknet ADR-050 for terminal sessions as resources) └── (no backend deps — portable_pty, bollard, russh are in the backend crates / `local` feature) alktty (local feature) — non-wasm by design └── adds: portable-pty, tokio-util, tokio/process, tokio/rt-multi-thread ``` alktty is dependency-light: alkcall (the handler interface and auth) only. The negotiation framing is a self-contained ~30-line module in alktty (4-byte BE length prefix + UTF-8 JSON body on tokio's `AsyncRead`/`AsyncWrite`). The heavy backend dependencies (`portable_pty`, `bollard`, `russh`) live in the backend crates, not here. alktty does **not** depend on alkcall's internal wire types — see [ADR-006](decisions/006-negotiation-framing-self-contained.md). ### Why no alkcall-internal-wire-types dependency An earlier draft had alktty depending on alkcall for the `FrameFramedReader`/`FrameFramedWriter` "framing utility." A pre-implementation check found this was unsound: `FrameFramedReader`'s `read_frame()` is hardcoded to deserialize `EventEnvelope` — the length-prefix read and the type-specific deserialize are one entangled call, not a separable utility. alktty's negotiation payload is a `NegotiateRequest`, not an `EventEnvelope`, so the claimed reuse did not exist in a usable form. alktty implements its own framing (the format coincides with alkcall's by convention; the implementations are independent). The ~30 lines of length-prefix framing is an idiom, not a domain abstraction worth a cross-crate dependency. See [ADR-006](decisions/006-negotiation-framing-self-contained.md) for the full decision and alknet ADR-003 Amendment 2 for the dependency-edge clarification. alktty stays lean — it has no `portable_pty` (default), no `bollard`, no `russh`, no alkcall-internal-wire-types. The `TtyBackend` implementations are opaque `Arc` from the adapter's perspective: constructed by the assembly layer at startup, stored in the adapter's backend map, dispatched by the `backend` field of the negotiation frame. ## ALPN | ALPN | Handler | Transport | Browser? | |------|---------|-----------|----------| | `alk/tty` | `TtyAdapter` | QUIC bidi stream (direct) or `alk/channels` (multiplexed) | Yes (when WebTransport revives — alknet ADR-040 parked) | `alk/tty` is a custom ALPN per the alknet ADR-006 `alk/` convention (renamed from `alknet/` in alkcall 0.1.1). The `TtyAdapter` registers for it; the endpoint's `HandlerRegistry` maps `alk/tty` to the adapter instance. One ALPN per connection (alknet ADR-006); within a connection, multiple bidi streams carry independent sessions (one session per stream — see [tty-adapter.md](tty-adapter.md)). The browser terminal case: a browser (xterm.js) connects via WebTransport to `alk/tty` and gets raw bytes. The browser doesn't need to implement SSH or the call protocol for the terminal use case — only if it wants SSH-specific features (port forwarding, SFTP). This is a cleaner browser story than "run a WASM SSH client." WebTransport is deferred per alknet ADR-044; when it revives, the `alk/tty` ALPN is reachable over WebTransport's ALPN-stream-proxy (alknet ADR-040, parked). ### Channels mode (ADR-008) The same `alk/tty` protocol also runs inside an `alk/channels` connection: a channel with ALPN `alk/tty` carries the TTY session, and the channels layer strips its 8-byte header before handing TTY the payload. The same `wire.rs` code runs in both modes; only the `BiStream` source differs. See [ADR-008](decisions/008-channels-pure-channel-multiplexing.md) and [tty-adapter.md](tty-adapter.md). ## Backend Location Map The decomposition principle: the trait lives where the types live (alktty); the implementations live where their transport dependencies live. ``` alktty (default — lean, no portable_pty, no bollard, no russh) ├── TtyBackend trait (the contract — ADR-002) ├── TtyHandle, TtyControl (the handle shape backends produce) ├── TtyParams, TerminalParams (the allocation request) ├── TtyAdapter (ProtocolHandler on alk/tty — session lifecycle) ├── wire format (ChunkReader/ChunkWriter, ControlMessage — ADR-001) └── negotiation framing (self-contained ~30-line module; format coincides with alkcall's by convention — ADR-006) alktty (local feature module — ADR-003; folded in from the old alknet-tty-local sibling crate) ├── LocalTtyBackend (impl TtyBackend — portable_pty for PTY, std::process for pipe) ├── portable_pty dependency (PTY allocation — the heavy dep, here not in alktty default) └── libc (signal forwarding — REQ-TTY-02, Unix only) alknet-docker (or alktty-docker adapter — future crate, out of scope here) └── DockerTtyBackend (impl TtyBackend — wraps bollard::attach_container / exec with tty:true) alknet-ssh (future crate — out of scope here) └── SshTtyBackend (impl TtyBackend — wraps russh pty_request + shell_request/exec_request) ``` alktty never sees `portable_pty` (default), `bollard`, or `russh`. The backend implementations are opaque `Arc` from the adapter's perspective. alktty stays lean; the backend crates own their transport dependencies. The local backend's module placement (folded into alktty behind a `local` feature, resolving the alknet cyclic-dep workaround) is decided in [ADR-003](decisions/003-local-backend-placement.md); the docker and SSH backends are future crates (out of scope for this spec set — see [tty-backend.md](tty-backend.md) §"Backend implementations" for where they live). ## Feature Gates ```toml # alktty Cargo.toml [features] default = [] local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"] ``` - `default` — the wire format, `TtyAdapter`, and the `TtyBackend` trait. No backend implementations; the assembly layer registers backends from their own crates. A docker-only or ssh-only deployment uses the default features and depends on `alknet-docker` / `alknet-ssh` (or their own backend crate) directly. **The default crate compiles to `wasm32-unknown-unknown`** — this is what makes the downstream TS/Python adapter story work (a wasm-compiled alktty is the protocol layer for a sandboxed adapter). - `local` — enables `alktty::local::LocalTtyBackend`, pulling in `portable_pty` (PTY mode) and `tokio::process` (pipe mode). A consumer that wants the local backend (terminal or runner) enables this feature. Inherently non-wasm — `portable-pty` + `tokio::process` need a real OS. The local backend's `portable_pty` dependency is the heavy dep that motivates the feature gate — a docker-only deployment should not pull in PTY allocation code. See [ADR-003](decisions/003-local-backend-placement.md). ## Architecture (component pointers) - **[tty-wire.md](tty-wire.md)** — the wire format: the negotiation frame (JSON carriage, self-contained length-prefixed framing), the raw chunk codec (`[stream_type: u8][length: u32 be][payload]`), the five stream types, the control channel (split into `STREAM_CTRL_IN` / `STREAM_CTRL_OUT` halves, JSON control messages), sentinels, and the fixed-channel-set rationale. - **[tty-bast.md](tty-bast.md)** — the BAST (Binary Abstract Syntax Tree) document for the `alk/tty` wire format; a normative JSON spec conforming to the BAST meta-schema at `https://alk.dev/bast/v1/schema`, validatable by any JSON Schema Draft 2020-12 validator. - **[tty-backend.md](tty-backend.md)** — the `TtyBackend` trait, `TtyParams`, `TtyHandle`, `TtyControl`. The inversion point between the wire-format adapter and the backends. Carries REQ-TTY-01 (backends need not be natively async; the bridging pattern is a documented strategy). Notes where the docker/SSH backend crates live (future, out of scope here). - **[tty-adapter.md](tty-adapter.md)** — the `TtyAdapter` (`ProtocolHandler` on `alk/tty`): the session lifecycle, the three-pump bidirectional driver (stdout→client, client→backend, exit→exit-chunk), negotiation errors, the exit-chunk ordering (ADR-004), access control (terminal sessions as runtime-spawned resources per alknet ADR-050), session-cancel cleanup (ADR-005). - **[tty-local.md](tty-local.md)** — the `local` feature module: `LocalTtyBackend` via `portable_pty` (PTY mode) and `tokio::process::Command` (pipe/runner mode). Carries REQ-TTY-02 (signal forwarding to the foreground process group). The blocking→async bridge pattern (the three std threads feeding tokio mpsc/oneshot) is the reference for any future blocking-API backend. ## Design Decisions | Decision | ADR | Summary | |----------|-----|---------| | Wire format and two-carriage model | [ADR-001](decisions/001-wire-format-and-two-carriage.md) | `alk/tty` ALPN; JSON negotiation frame then raw chunks; fixed channel set 0-4; control as JSON; Phase 7 control-channel split | | `TtyBackend` trait and `TtyHandle` | [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) | The backend inversion point; `exit_code` as `Future`; backends need not be natively async (REQ-TTY-01) | | Local backend placement | [ADR-003](decisions/003-local-backend-placement.md) | alktty folds the local backend in behind a `local` feature (resolves the alknet cyclic-dep workaround); PTY vs pipe per-session | | Exit code on a control chunk | [ADR-004](decisions/004-exit-code-on-control-chunk.md) | `{"type":"exit","code":N}` on `STREAM_CTRL_OUT`; "exit chunk is last" invariant; adapter owns the ordering | | Backend cleanup on session cancel | [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) | Dropping `exit_code` future (cancel) MUST kill the session target; the adapter triggers it by dropping the `TtyHandle` | | Self-contained negotiation framing | [ADR-006](decisions/006-negotiation-framing-self-contained.md) | alktty implements its own length-prefixed framing; format coincides with alkcall's by convention, not by code reuse | | TTY inside channels (reversed) | [ADR-007](decisions/007-tty-inside-channels.md) | Historical: the two-mode TTY design (direct vs inside-channels); reversed by ADR-008/093 | | Channels pure channel multiplexing | [ADR-008](decisions/008-channels-pure-channel-multiplexing.md) | TTY always uses its 5-byte format; the channels layer carries it transparently in the payload (reverses ADR-007) | ## Open Questions - **OQ-43** (resolved): `TtyControl` as a `Clone` trait object. - **OQ-44** (deferred(scope)): Terminal modes (TTY modes). - **OQ-45** (resolved): Flow control for high-throughput stdout — no application-level windowing; QUIC per-stream flow control is the backpressure mechanism. - **OQ-46** (deferred(scope)): Runner API surface. - **OQ-47** (resolved): Stdin closure canonical signal. ## References - alknet ADR-001 — ALPN-based dispatch - alknet ADR-002 — ProtocolHandler trait - alknet ADR-003 + Amendments 1 & 2 — crate decomposition (no-handler-depends-on-another-handler; alktty depends on alkcall only; backends depend on alktty for the trait) - alknet ADR-006 — `alk/` ALPN convention; one ALPN per connection; new ALPN for incompatible versions - alknet ADR-007 — `Connection`, `accept_bi`, the handler-receives- Connection pattern - alknet ADR-050 — dynamic resource ownership (terminal sessions as runtime-spawned resources; the adapter's access-control shape declares against this model) - `/workspace/@alkdev/alknet/docs/architecture/decisions/` — the alknet originals of the ADRs ported here as 001..008, plus the alknet ADRs referenced by alknet number above (which are not ported into alktty's ADR range because they are not tty-specific)