Port the alknet-tty architecture docs into alktty and add the BAST document for the alk/tty wire format. Docs-only; no Rust source changes. Spec docs (docs/architecture/, flat layout — single-crate repo): - overview.md — crate purpose, two-carriage model, deps, ALPN, backend location map, feature gates - tty-wire.md — 5-byte chunk codec, control channel split (STREAM_CTRL_IN=3 / STREAM_CTRL_OUT=4), sentinels - tty-backend.md — TtyBackend trait, TtyHandle, TtyControl, REQ-TTY-01 (backends need not be natively async) - tty-adapter.md — TtyAdapter, three-pump driver, exit-chunk ordering (ADR-004), cancel cleanup (ADR-005), access control - tty-local.md — LocalTtyBackend (local feature module), PTY + pipe modes, REQ-TTY-02 (signal forwarding to process group) - README.md — architecture index ADRs (docs/architecture/decisions/, renumbered 001..008 from alknet 052,053,054,055,056,057,077,093 in order): - 001 wire format + two-carriage model (incl. Phase 7 control- channel split amendment) - 002 TtyBackend trait + TtyHandle - 003 local backend placement (records both the alknet sibling- crate decision and the alktty single-crate consolidation behind a local feature) - 004 exit code on a control chunk - 005 backend cleanup on session cancel - 006 self-contained negotiation framing - 007 tty inside channels (reversed by 008; kept for historical context with reversal notice) - 008 channels pure channel multiplexing (reverses 007; TTY always uses its 5-byte format) BAST document (docs/architecture/tty-bast.md): - Normative JSON spec for the alk/tty wire format, conforming to the BAST meta-schema at https://alk.dev/bast/v1/schema - 5-byte chunk header (struct, big-endian: stream_type uint8, length uint32) + StreamType enum (Stdin=0..CtrlOut=4) - ControlMessage union (field-name discriminator on type: resize/signal/eof/exit) with documented deviation that on-wire control payloads are UTF-8 JSON, not BAST's binary union encoding - NegotiationFrame (4-byte BE length + UTF-8 JSON body) + NegotiateRequest / TerminalParams JSON shapes - StreamType enum deviation noted: on-wire uint8, not BAST's standard u32 enum index (chunk header is 5 bytes, not 8) - alktty does not depend on alktype; the hand-rolled wire.rs is the runtime codec, the BAST is the human-readable contract AGENTS.md: fixed the ADR mapping table to match the plan's 8-to-8 mapping (the previous table substituted ADR-050 for 054, relabeled 056 as control-message split, dropped 077, and added a new control-split ADR at 006 — inconsistent with both the plan and the prose). ADR-050 (dynamic resource ownership) is an alkcall/alknet- core ADR, not tty-specific, and is not ported; the Phase 7 control split stays as an amendment inside ADR-001, mirroring alknet. Verification (all pass, no Rust source changed): - cargo test (80 passed) - cargo test --all-features (103 passed) - cargo clippy --all-targets -- -D warnings (clean) - cargo fmt --check (clean) - cargo check --target wasm32-unknown-unknown (clean) - cargo clippy --target wasm32-unknown-unknown -- -D warnings (clean) - cargo doc --no-deps: 9 pre-existing intra-doc-link warnings in src/session.rs and src/channels.rs (untouched by this commit; not introduced here) - BAST JSON parses; StreamType indices match wire.rs constants (0=Stdin..4=CtrlOut) - all markdown cross-reference links resolve
17 KiB
status, last_updated
| status | last_updated |
|---|---|
| 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) | 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.
The Two-Carriage Model in Brief
A alk/tty bidi stream has two phases (full detail in
tty-wire.md, decided in
ADR-001):
-
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).
-
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 nocall.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 §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.
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 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<dyn TtyBackend> 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/<name> convention
(renamed from alknet/<name> 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).
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
and 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<dyn TtyBackend> 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;
the docker and SSH backends are future crates (out of scope for this
spec set — see tty-backend.md §"Backend implementations"
for where they live).
Feature Gates
# alktty Cargo.toml
[features]
default = []
local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"]
default— the wire format,TtyAdapter, and theTtyBackendtrait. 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 onalknet-docker/alknet-ssh(or their own backend crate) directly. The default crate compiles towasm32-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— enablesalktty::local::LocalTtyBackend, pulling inportable_pty(PTY mode) andtokio::process(pipe mode). A consumer that wants the local backend (terminal or runner) enables this feature. Inherently non-wasm —portable-pty+tokio::processneed 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.
Architecture (component pointers)
- 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 intoSTREAM_CTRL_IN/STREAM_CTRL_OUThalves, JSON control messages), sentinels, and the fixed-channel-set rationale. - tty-bast.md — the BAST (Binary Abstract Syntax
Tree) document for the
alk/ttywire format; a normative JSON spec conforming to the BAST meta-schema athttps://alk.dev/bast/v1/schema, validatable by any JSON Schema Draft 2020-12 validator. - tty-backend.md — the
TtyBackendtrait,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 — the
TtyAdapter(ProtocolHandleronalk/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 — the
localfeature module:LocalTtyBackendviaportable_pty(PTY mode) andtokio::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 | 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 | The backend inversion point; exit_code as Future; backends need not be natively async (REQ-TTY-01) |
| Local backend placement | ADR-003 | 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 | {"type":"exit","code":N} on STREAM_CTRL_OUT; "exit chunk is last" invariant; adapter owns the ordering |
| Backend cleanup on session cancel | ADR-005 | Dropping exit_code future (cancel) MUST kill the session target; the adapter triggers it by dropping the TtyHandle |
| Self-contained negotiation framing | ADR-006 | alktty implements its own length-prefixed framing; format coincides with alkcall's by convention, not by code reuse |
| TTY inside channels (reversed) | ADR-007 | Historical: the two-mode TTY design (direct vs inside-channels); reversed by ADR-008/093 |
| Channels pure channel multiplexing | ADR-008 | TTY always uses its 5-byte format; the channels layer carries it transparently in the payload (reverses ADR-007) |
Open Questions
- OQ-43 (resolved):
TtyControlas aClonetrait 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/<name>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)