# alktty Terminal session protocol for the `alk/tty` ALPN: a JSON negotiation frame followed by raw chunks (`[stream_type: u8][length: u32 be][payload]`), a backend-agnostic `TtyBackend` trait, a backend-agnostic producer adapter, and a typed consumer client. A producer/consumer protocol crate on top of [alkcall](https://crates.io/crates/alkcall) channels — no networking, no transport dependencies. Downstream crates (a `alknet-docker` or `alknet-ssh` backend, a sandboxed TS/Python adapter) compose on top of it: implement [`TtyBackend`](https://docs.rs/alktty/latest/alktty/trait.TtyBackend.html) for your execution environment, and the adapter handles the wire. ## Quick start ### Producer — register backends and run the adapter ```rust use std::collections::HashMap; use std::sync::Arc; // `LocalTtyBackend` needs the `local` feature; swap in any `TtyBackend` // implementation (docker, ssh, ...) compiled for your target. let mut backends: HashMap> = HashMap::new(); backends.insert("local".into(), Arc::new(alktty::LocalTtyBackend::new())); let tty_adapter = alktty::TtyAdapter::new(backends); // tty_adapter implements alkcall::core::ProtocolHandler on the `alk/tty` // ALPN — hand it to your transport accept loop: // tty_adapter.handle(connection, &auth).await ``` ### Producer — via `alk/channels` instead of a dedicated ALPN ```rust use std::collections::HashMap; use std::sync::Arc; let backends: HashMap> = std::iter::once(("local".to_string(), Arc::new(alktty::LocalTtyBackend::new()) as _)) .collect(); // In your channels acceptor's `install_channel_zero` hook (per-connection): // alktty::register_openable(&channel_core, Arc::new(backends), None, &mut registry, auth) // Consumers open `channels/tty/sub`; the open op's params ARE the // negotiation (ADR-009) — no second frame on the channel stream. ``` ### Consumer — open a session over the direct ALPN ```rust use alkcall::core::Connection; use alktty::{NegotiateRequest, TtySession}; let connection: Connection = /* dial the transport, negotiate `alk/tty` */; let session = TtySession::connect_direct( connection, NegotiateRequest { carriage: "raw".into(), backend: "local".into(), tty: None, cmd: vec!["bash".into()], cwd: None, env: Default::default(), backend_params: Default::default(), }, ).await?; session.resize(120, 40, 0, 0).await?; session.send_stdin("ls -l\n".into()).await?; session.close_stdin().await?; let mut stdout = session.recv_stdout().await; while let Some(bytes) = futures::StreamExt::next(&mut stdout).await { // The stream ends on the zero-length stdout sentinel ("drained") — // it is never yielded as an item. // ... render bytes ... } let code = session.wait().await?; ``` ### Consumer — open a session via `alk/channels` ```rust use alkcall::channels::client::ChannelClient; use alktty::TtySession; let client: ChannelClient = /* from_connection on an `alk/channels` connection */; let session = TtySession::open_via_channels( &client, serde_json::json!({ "carriage": "raw", "backend": "local", "cmd": ["bash"], "tty": {"term": "xterm-256color", "cols": 120, "rows": 40, "pixel_width": 0, "pixel_height": 0} }), ).await?; ``` ## Architecture Two roles, symmetric per the alk convention (avoid "server"/"client": both sides of a connection can be either): | Role | Direct `alk/tty` | Via `alk/channels` | |------|------------------|--------------------| | **Producer** (runs backends, emits terminal bytes) | [`TtyAdapter`](https://docs.rs/alktty/latest/alktty/adapter/struct.TtyAdapter.html) as a `ProtocolHandler` — negotiation frame → access control → backend allocation → three-pump session driver | [`register_openable`](https://docs.rs/alktty/latest/alktty/channels/fn.register_openable.html) — the registry's ACL gates the open op; the handler spawns the same session driver pre-negotiated | | **Consumer** (types input, renders output) | [`TtySession::connect_direct`](https://docs.rs/alktty/latest/alktty/struct.TtySession.html#method.connect_direct) — writes the negotiation frame, exposes typed stdin/stdout/stderr/control/exit methods | [`TtySession::open_via_channels`](https://docs.rs/alktty/latest/alktty/struct.TtySession.html#method.open_via_channels) — the open op's params are the negotiation | One connection hosts many sessions (one per bidi stream on the direct path, one per channel on the channels path); sessions are independent. The wire format (ADR-001) is two carriages: a 4-byte length-prefixed JSON negotiation frame, then raw chunks — `[stream_type: u8][length: u32 be][payload]` with five stream types (`STREAM_STDIN`=0 … `STREAM_CTRL_OUT`=4; see [`alktty::wire`](https://docs.rs/alktty/latest/alktty/wire/index.html)). Zero-length data chunks are sentinels (stdin EOF from the client, stdout drained from the server); the exit code rides as the final `ctrl_out` control chunk (ADR-005). The codec is [`alktty::wire`](https://docs.rs/alktty/latest/alktty/wire/index.html)'s `ChunkReader`/`ChunkWriter`; the machine-readable contract is the BAST document at `docs/architecture/tty-bast.md`. ### WASM target The default crate (no features) 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. The `local` feature (the [`LocalTtyBackend`](https://docs.rs/alktty/latest/alktty/struct.LocalTtyBackend.html) reference implementation over `portable_pty` + `tokio::process`) needs a real OS and is non-wasm by design. ## Documentation - [Architecture docs](docs/architecture/README.md) — the authoritative spec: ADRs (001–009), the wire format, the backend trait contract, and the local backend's cancel-cleanup semantics. - [API docs](https://docs.rs/alktty) — full crate documentation on docs.rs. ## License MIT OR Apache-2.0