# ADR-003: Local TTY Backend Placement (Single Crate with `local` Feature) ## Status Accepted (ported from alknet ADR-054 2026-08-17, with the single-crate consolidation recorded as the alktty resolution. Cross-references renumbered to alktty's ADR range — ADR-052→001, ADR-053→002, ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008. Alknet ADRs referenced by alknet number (003, 009, 017) are not ported into alktty's ADR range because they are not tty-specific; the alknet originals at `/workspace/@alkdev/alknet/docs/architecture/decisions/` remain authoritative.) ## Context The alknet-tty research (DP-1) posed the placement question for the local-process backend (`std::process::Command` with piped stdio, or `portable_pty` for a real PTY): - **(a) In alktty**: the crate ships with the local backend built-in. Pro: zero-config runner, one crate gets a terminal/process-streaming endpoint. Con: alktty pulls in `portable_pty` even for deployments that only use docker/ssh backends. - **(b) In a sibling crate (`alknet-tty-local`)**: alktty defines the trait (ADR-002); the local backend is a separate crate. Pro: alktty stays dependency-light; consumers opt into the local backend explicitly. Con: one extra crate for the common case. The local backend is the simplest backend and the one that enables 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). It has no heavy dependencies in the pipe case — just `std` — but the PTY case pulls in `portable_pty` (a non-trivial native dependency that builds on Unix `openpty`/`ioctl` and Windows ConPTY). The relevant constraint from ADR-002: alktty itself depends only on alkcall and the wire-format codec (ADR-001). The `portable_pty` dependency does not belong in the core tty crate — a docker-only deployment or an SSH-PTY-only deployment should not pull in PTY allocation code. This is the same inversion as `OperationAdapter` (alknet ADR-017): the trait lives where the types live; the implementations live where their transport dependencies live. ### The alknet mono-repo decision (sibling crate + feature re-export) In the alknet mono-repo, the research recommended (b) sibling crate **behind a feature flag on alknet-tty** for the common case (`features = ["local"]` → re-export from `alknet-tty-local`). This keeps alknet-tty's default dependency surface minimal while making the local backend a one-feature opt-in. The `portable_pty` dependency lives in `alknet-tty-local`; alknet-tty itself never depends on `portable_pty`. The sibling-crate placement was motivated by a cyclic-dependency workaround: `alknet-tty-local` depends on `alknet-tty` for the trait, and alknet ADR-054 wanted `alknet-tty` to re-export `LocalTtyBackend` behind a `local` feature — which cargo rejects (a crate cannot re-export from a sibling crate it depends on via an optional dep AND have that sibling depend back on it). The assembly-layer workaround (consumer depends on both crates directly) worked but was awkward. ### The alktty consolidation (single crate, `local` feature) alktty is a standalone single crate (not a mono-repo). The cyclic-dep workaround that motivated the alknet sibling-crate decision does not apply: the local backend can live in the same crate as the trait, gated behind a `local` cargo feature. The feature gate still keeps `portable_pty` out of the default dependency tree (a docker-only or ssh-only deployment enables neither feature and never pulls in `portable_pty`); the only thing that changes is the crate boundary. This ADR records both decisions: the original alknet sibling-crate decision (preserved as the historical context for why the local backend is a separable concern), and the alktty consolidation (the operative decision — the local backend is folded into alktty behind a `local` feature). ## Decision ### 1. The local backend is folded into alktty behind a `local` feature `LocalTtyBackend` lives in `src/local/` (a feature-gated module), implements `TtyBackend` (ADR-002), and is re-exported as `alktty::local::LocalTtyBackend`. The `local` cargo feature pulls in the heavy deps: ```toml # alktty Cargo.toml [features] default = [] local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"] [dependencies] portable-pty = { version = "0.9", optional = true } tokio-util = { version = "0.7", features = ["io"], optional = true } ``` A consumer that wants the local backend enables `features = ["local"]` on alktty and gets `alktty::local::LocalTtyBackend`. A consumer that only wants docker/ssh backends uses the default features and depends on `alknet-docker` / `alknet-ssh` (or their own backend crate) directly — no `portable_pty` in the dependency tree. This is the same feature-re-export pattern the Rust ecosystem uses for optional heavy dependencies (e.g., `tokio`'s `full` feature pulling in `tokio-util`, `h2`, etc.). The seam is the `TtyBackend` trait; the feature gate is the only thing keeping `portable_pty` out of the default crate. Folding the local backend in is cheaper than the alknet sibling-crate pattern (no cross-crate coordination, no cyclic-dep workaround) and the local backend is small (~1.4k lines) and tightly coupled to the trait. ### 2. The local backend's dependency tree ``` alktty (local feature enabled) ├── alktty (default) (TtyBackend trait, TtyHandle, TtyControl, wire types) ├── alkcall::core (via alktty's re-export; not direct) ├── portable_pty (PTY allocation — the heavy dep, here not in alktty default) ├── libc (signal forwarding — REQ-TTY-02, Unix only) └── tokio (process, rt-multi-thread, mpsc, oneshot, AsyncRead/AsyncWrite) ``` The `local` feature is inherently non-wasm (`portable-pty` + `tokio::process` need a real OS); enabling `local` on `wasm32-unknown-unknown` is a build error by design. The default crate (no features) stays wasm-clean — 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-process backend runs on a real OS, never in a sandbox). ### 3. PTY vs pipe is a per-session choice, not a per-deployment choice `TtyParams.terminal: Option` (ADR-002) selects the mode: - **`terminal: Some(TerminalParams { ... })`** — allocate a real PTY via `portable_pty`. Terminal semantics: resize (via `ioctl(TIOCSWINSZ)`), signal delivery to the foreground process group (via `libc::kill(-pgid, sig)`, REQ-TTY-02), escape-sequence handling (the kernel PTY's line discipline). stdout and stderr are merged (kernel PTY property — one output stream from the slave), so `TtyHandle.stderr` is `None`. - **`terminal: None`** — pipe mode, no PTY. `tokio::process::Command` with `Stdio::piped()` for stdin/stdout/stderr. No resize, no escape-sequence handling, but `kill(pid, sig)` still works for signal forwarding. stdout and stderr are separate streams, so `TtyHandle.stderr` is `Some`. This is the **runner** case — a command-streaming endpoint with no terminal semantics. The same `LocalTtyBackend` serves both; the `allocate()` call branches on `params.terminal`. A deployment that only does terminals always sends `Some`; a deployment that only does runners always sends `None`; a deployment that does both (a hub that runs agents in PTYs and runs `cargo test` as a runner) sends the appropriate one per session. ### 4. The runner pattern is preserved, not specialized The pipe mode (`terminal: None`) is the "runner" generalization the research identified: a process whose stdin/stdout/stderr/exit-code stream over a framed bidi connection. This is functionally identical to GitHub/Gitea Actions runners, just over alk's transport instead of HTTP polling: - A coordinator sends a negotiation frame with `{ "backend": "local", "tty": null, "cmd": ["cargo", "test"] }`. - The endpoint runs `cargo test` with piped stdio, streams stdout/stderr chunks back, sends `{"type":"exit","code":N}` when it finishes (ADR-004). - The coordinator gets reliable completion notification (the exit control chunk + stream close) — no polling. The runner-specific API surface (job management, log persistence, task graph integration) is **out of scope for alktty**. alktty provides the *mechanism* (a framed byte stream for a process); the runner *policy* is a downstream crate's job. This ADR commits to preserving the option (`terminal: None` → pipe mode) and not building runner policy into alktty. See OQ-46. ## Consequences **Positive:** - alktty's default dependency surface is minimal (alkcall + the wire-format codec). A docker-only or ssh-only deployment never pulls in `portable_pty`. - The local backend is a one-feature opt-in (`features = ["local"]`) for the common case — a consumer that wants a terminal/runner endpoint with no docker or SSH gets it with one feature flag, not a separate dependency. - PTY vs pipe is per-session, so one `LocalTtyBackend` serves terminals and runners. A hub that does both doesn't need two backends. - The runner pattern is preserved without baking runner policy into alktty. The mechanism is the framed byte stream; the policy is downstream. - The single-crate placement composes with alknet ADR-003's no-handler-depends-on-another-handler rule: the local backend is in the same crate as the trait, so there is no cross-crate dependency edge to worry about. The docker and SSH backends remain separate crates (real external deps — `bollard`, `russh` — and their own resource models). - The alktty consolidation resolves the alknet cyclic-dep workaround that motivated the original sibling-crate decision. No assembly-layer "depend on both crates directly" pattern; no cargo-rejected feature-re-export-from-an-optional-dep dance. **Negative:** - A consumer that wants the local backend must enable a feature flag (`features = ["local"]`). Forgetting the flag results in the `alktty::local` module not existing — a compile error, not a silent miss. This is the standard Rust feature-flag trade and is self-documenting. - The `local` feature is non-wasm by design. A consumer that targets `wasm32-unknown-unknown` must not enable `local` — the build error is intentional (the local-process backend cannot run in a sandbox). The default crate stays wasm-clean so the downstream TS/Python adapter story works. - The runner-specific API surface (job management, log persistence) is not in alktty. A downstream crate that wants a full runner builds on the pipe mode + the wire format. This is the right layering (mechanism vs policy) but means a "runner crate" is a separate future deliverable, not part of alktty. See OQ-46. ## Door type **Two-way.** The single-crate-with-feature-gate placement is reversible: if the local backend ever grows large enough to warrant splitting out (e.g., a future docker or SSH backend shares enough portable-pty bridge code to motivate a shared "blocking-backend bridge" crate), extracting `src/local/` into a sibling crate behind the same `local` feature is mechanical — the trait is the seam, and the feature gate already exists. The cost of reversal is low (the module becomes a crate; the feature gate switches from `dep:portable-pty` to `dep:alktty-local`), and no downstream consumer breaks (the `alktty::local::LocalTtyBackend` path stays valid if the sibling crate re-exports it). This is a two-way door that is **decided** (single crate + `local` feature), not deferred. The decision is made now; the reversal is cheap if a future split warrants it. See alknet ADR-009 §"What this framework is NOT" — door type classifies reversal cost, not urgency. ## References - alknet ADR-003 + Amendments 1 & 2 — crate decomposition rule (the single-crate placement preserves it; the local backend is in the same crate as the trait, not a separate crate that depends back) - alknet ADR-009 — door-type-as-deferral anti-pattern (this ADR's two-way-door classification is reversal cost, not a deferral) - alknet ADR-017 — the adapter-location-map pattern (trait where types live, implementation where deps live) this ADR follows (the `local` feature is where the deps live) - [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the `TtyBackend` trait this module implements - [ADR-001](001-wire-format-and-two-carriage.md) — the wire format the adapter pumps to/from this backend - [ADR-004](004-exit-code-on-control-chunk.md) — the exit-chunk ordering the local backend's waiter thread feeds - [ADR-005](005-backend-cleanup-on-session-cancel.md) — the cancel- cleanup contract the local backend's `exit_code` future's `Drop` implements - OQ-46 — runner API surface (deferred(scope): mechanism in alktty, policy is a downstream crate) - Spec: [tty-local.md](../tty-local.md) - Port origin: alknet ADR-054 at `/workspace/@alkdev/alknet/docs/architecture/decisions/054-local-tty-backend-sibling-crate.md`