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
312 lines
17 KiB
Markdown
312 lines
17 KiB
Markdown
---
|
|
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<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](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<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](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/<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) |