Files
alktty/README.md
T
glm-5.3-flash 58da42a021 docs: remove em dashes from repo prose and forbid them going forward
- Rewrote ~1090 em dashes across docs/ (excluding docs/reviews/
  history), README, CHANGELOG, and .opencode/agents prompts with varied
  restructures (colons, semicolons, parentheses, commas, sentence
  splits); ADR decisions, status lines, wire-shape examples, and code
  fences preserved
- Rewrote 234 em dashes in src doc comments plus a handful of existing
  inline comments and one test assert message; no code, JSON literals,
  or wire shapes touched
- Added Project Convention 15: no em dashes in prose (doc comments,
  markdown, agent prompts, commit messages); restructure instead; no
  spaced hyphens as a substitute
Verification: cargo test; cargo clippy --all-targets -- -D warnings;
cargo fmt --check; cargo doc --no-deps; cargo test --all-features;
cargo test --manifest-path fuzz/shared/Cargo.toml; cargo check and
clippy for wasm32-unknown-unknown with -D warnings; em dash grep over
src + docs + agent prompts returns no matches outside docs/reviews/
2026-11-01 13:11:32 +00:00

146 lines
5.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<String, Arc<dyn alktty::TtyBackend>> = 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<String, Arc<dyn alktty::TtyBackend>> =
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), which writes the negotiation frame and 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), where 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 of the form
`[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