A subagent mid-dedash sweep read the 'commit and push when reasonable' rule and pushed its own subset before the coordinator verified the aggregate. State the assembly rule explicitly so spawned tasks report instead.
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 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
for your execution environment, and the adapter handles the wire.
Quick start
Producer: register backends and run the adapter
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
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
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
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 as a ProtocolHandler: negotiation frame → access control → backend allocation → three-pump session driver |
register_openable: 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, which writes the negotiation frame and exposes typed stdin/stdout/stderr/control/exit methods |
TtySession::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).
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'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
reference implementation over portable_pty + tokio::process) needs a
real OS and is non-wasm by design.
Documentation
- Architecture docs, the authoritative spec: ADRs (001–009), the wire format, the backend trait contract, and the local backend's cancel-cleanup semantics.
- API docs: full crate documentation on docs.rs.
License
MIT OR Apache-2.0