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
305 lines
17 KiB
Markdown
305 lines
17 KiB
Markdown
# AGENTS.md
|
||
|
||
Operating instructions for opencode agents working in this repo. opencode
|
||
auto-loads this file as instructions, overriding the built-in defaults for
|
||
this project. Custom agents in `.opencode/agents/` inherit these rules
|
||
unless their own prompts say otherwise.
|
||
|
||
## Git Workflow
|
||
|
||
**Commit and push when reasonable.** When a change is complete and
|
||
verified (build + lint + tests pass), commit and push to `origin/main`
|
||
without asking. This overrides the built-in default of "only commit when
|
||
explicitly asked."
|
||
|
||
The workflow:
|
||
|
||
1. Make the change
|
||
2. Verify: `cargo test`, `cargo clippy --all-targets -- -D warnings`,
|
||
`cargo fmt --check`, `cargo doc --no-deps` if docs changed
|
||
3. Inspect `git status` and `git diff` before staging — stage only the
|
||
intended files, never secrets
|
||
4. Write a concise commit message matching the repo style (see `git log
|
||
--oneline -10`). For multi-point changes, use a summary line plus a
|
||
body with bullet points and a verification block.
|
||
5. `git push origin main`
|
||
6. Report the commit hash and the verification summary
|
||
|
||
Exceptions — **do not** commit or push without asking:
|
||
|
||
- The change is exploratory / speculative (you're not sure the user wants
|
||
it kept)
|
||
- The user is actively reviewing the diff and may ask for changes
|
||
- The change touches the wire format or the `TtyBackend` trait shape
|
||
(one-way doors — see "Wire formats are stable" and "`TtyBackend` trait
|
||
shape is a one-way door" below; the 5-byte chunk header, the
|
||
negotiation-frame layout, the `NegotiateRequest` JSON shape, and the
|
||
trait signature are wire-stable contracts once consumers exist)
|
||
- You'd be force-pushing, amending a published commit, creating an empty
|
||
commit, or skipping hooks
|
||
|
||
Never commit secrets, keys, or credentials. If a commit fails or hooks
|
||
reject it, fix the issue and create a new commit — do not amend the
|
||
failed one.
|
||
|
||
Git identity is preconfigured (`glm-5.2 <glm-5.2@alk.dev>`). Do not
|
||
change `git config`, skip hooks, or use `git commit -i`.
|
||
|
||
## Project Conventions (Rust / TTY protocol crate)
|
||
|
||
This is the `alk/tty` terminal-session protocol crate — a
|
||
producer/consumer protocol crate on top of alkcall channels. It folds
|
||
the old `alknet-tty` (wire/adapter/backend-trait half) and
|
||
`alknet-tty-local` (`portable_pty` + `tokio::process` half) from the
|
||
alknet mono-repo into a single crate with a `local` feature. The
|
||
conventions below apply to all work in `src/` and `tests/`. They mirror
|
||
`.opencode/agents/implementation-specialist.md` §Project Conventions
|
||
and are repeated here so they apply to every session, not just spawned
|
||
implementation agents.
|
||
|
||
1. **No comments in code** unless the user explicitly asks. This is a
|
||
project-wide convention. Doc comments (`///`, `//!`) are fine and
|
||
expected on public API. Inline `//` comments only when the user asks
|
||
or when a non-obvious safety/correctness constraint would otherwise be
|
||
missed (e.g., "zero-length stdin is the EOF sentinel — the codec does
|
||
not special-case it; the adapter interprets `length == 0` chunks").
|
||
|
||
2. **Error handling** — `thiserror` for library error types
|
||
(`TtyError`, `NegotiateError`, `WireError`, `HandlerError`/`StreamError`
|
||
come from alkcall::core). No panics in library code. No `unwrap()` or
|
||
`expect()` outside tests. If you reach for `unwrap`, the error path
|
||
wasn't specified — stop and decide what should actually happen. For
|
||
poisoned `RwLock`/`Mutex`, use
|
||
`unwrap_or_else(|e| e.into_inner())` so a panic in one operation does
|
||
not cascade to other operations.
|
||
|
||
3. **`tokio` is the async runtime** — all I/O is async. The adapter's
|
||
per-session pumps, the channels integration, and `TtySession` are all
|
||
async. Use `tokio::sync` primitives (`oneshot`, `mpsc`) for
|
||
request/exit correlation and stdout/stderr streams. The PTY bridge in
|
||
`src/local/pty.rs` is the one exception: it uses three dedicated std
|
||
threads feeding tokio mpsc/oneshot channels (the `portable_pty` API
|
||
is blocking) — and it's behind the `local` feature.
|
||
|
||
4. **WASM target is load-bearing** — the default crate (no `local`
|
||
feature) MUST compile 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). `Cargo.toml` uses
|
||
`tokio = { default-features = false, features = ["rt", "sync",
|
||
"io-util", "macros"] }` (the wasm-clean subset alkcall uses) and
|
||
`local` adds `tokio/process` + `tokio/rt-multi-thread`. **Do NOT use
|
||
`features = ["full"]`** — it pulls in `signal`/`fs`/`net` which break
|
||
`wasm32-unknown-unknown`. The adapter's `tokio::spawn` for per-session
|
||
pumps is fine on wasm (the wasm tokio runtime supports `spawn`); the
|
||
`local` module's std threads, `tokio::process::Command`, and
|
||
`libc::kill` are the non-wasm parts, and they're feature-gated.
|
||
`libc` stays under `cfg(unix)` (it's already there for
|
||
`signal_from_name` and the pipe-mode `kill` path — neither runs on
|
||
wasm because the only callers are in `local`).
|
||
|
||
5. **Wire formats are stable** — two wire formats live in this crate,
|
||
both one-way doors:
|
||
- **5-byte chunk header** (`[stream_type: u8][length: u32 BE]
|
||
[payload]`) — the raw chunk codec in `wire.rs`. Five stream types:
|
||
`STREAM_STDIN=0`, `STREAM_STDOUT=1`, `STREAM_STDERR=2`,
|
||
`STREAM_CTRL_IN=3`, `STREAM_CTRL_OUT=4`. Zero-length data chunks are
|
||
sentinels (zero-length stdin = EOF from client; zero-length stdout
|
||
= "drained" from server); control chunks are never zero-length.
|
||
Changing the header, the stream-type values, or the sentinel
|
||
semantics breaks all peers. See ADR-052 (ported as alktty ADR-001).
|
||
- **Negotiation frame** (4-byte BE length prefix + UTF-8 JSON
|
||
`NegotiateRequest` body) — self-contained per ADR-057 (ported as
|
||
alktty ADR-006), not reused from alkcall's `EventEnvelope` framing
|
||
(the payload is `NegotiateRequest`, not `EventEnvelope`; alkcall's
|
||
`FrameFramedReader` is hardcoded to deserialize `EventEnvelope`).
|
||
The `NegotiateRequest` JSON shape is wire-stable once consumers
|
||
exist.
|
||
|
||
Per ADR-093 (ported as alktty ADR-008): TTY always uses its 5-byte
|
||
format, even inside channels. The channels layer strips its 8-byte
|
||
header and hands TTY the payload transparently — the same `wire.rs`
|
||
code runs in both direct (`alk/tty`) and channels (`alk/channels`)
|
||
modes; only the `BiStream` source differs.
|
||
|
||
6. **`TtyBackend` trait shape is a one-way door** — the trait
|
||
(`src/backend.rs`) is the inversion point between the wire-format
|
||
adapter and the backend crates (alktty's own `local` feature module,
|
||
future `alknet-docker`, `alknet-ssh`). alktty defines the trait; the
|
||
backends implement it. Changing the trait shape after backends exist
|
||
is a rewrite across crates. The adapter holds a
|
||
`HashMap<String, Arc<dyn TtyBackend>>` keyed by the negotiation
|
||
frame's `backend` string and pumps the `TtyHandle` fields
|
||
bidirectionally; backends produce handles, they do not write to the
|
||
wire. `TtyError` is `#[non_exhaustive]` so new variants are additive
|
||
(two-way-door extension within the one-way trait shape). See ADR-053
|
||
(ported as alktty ADR-002).
|
||
|
||
7. **Producer/consumer, not server/client** — inherited from alkcall.
|
||
Both sides of a `alk/tty` or `alk/channels` connection can initiate.
|
||
A producer exposes TTY (direct ALPN via `TtyAdapter::ProtocolHandler`,
|
||
or via `channels::register_openable`); a consumer opens a session
|
||
(`TtySession::connect_direct` / `open_via_channels`). Both sides can
|
||
be both simultaneously — connection direction (who opened it) is
|
||
independent of TTY direction (who's the shell, who's the client).
|
||
Avoid "server" and "client" framing in docs and API names; use
|
||
"producer" and "consumer," or "accept side" / "connect side" for the
|
||
connection-establishment half specifically.
|
||
|
||
8. **Module structure** — one module per file under `src/`, re-exported
|
||
from `src/lib.rs`. Public API surface is `lib.rs` re-exports. The
|
||
crate has three concerns:
|
||
- **Producer half** — `src/adapter.rs` (`TtyAdapter` + `drive_session`
|
||
for direct `alk/tty`) and `src/channels.rs` (`register_openable`
|
||
helper + `TtyOpenHandler` for the `alk/channels` multiplexed path).
|
||
- **Consumer half** — `src/session.rs` (`TtySession` typed client
|
||
with `connect_direct` and `open_via_channels` constructors).
|
||
- **Shared** — `src/wire.rs` (chunk codec), `src/control.rs`
|
||
(control-message enum + `signal_from_name`), `src/negotiation.rs`
|
||
(negotiation frame + `NegotiateRequest`), `src/backend.rs`
|
||
(`TtyBackend` trait, `TtyHandle`, `TtyParams`, `TtyError`).
|
||
The `src/local/` module (the folded `alknet-tty-local`) is behind the
|
||
`local` feature and never imported from the shared/producer/consumer
|
||
modules — only re-exported from `lib.rs` under
|
||
`#[cfg(feature = "local")]`.
|
||
|
||
9. **`local` feature isolation** — the `local` module is the only
|
||
non-wasm part of the crate. The shared/producer/consumer modules
|
||
(`wire`, `control`, `negotiation`, `backend`, `adapter`, `channels`,
|
||
`session`) MUST NOT import from `crate::local` or reference any
|
||
`local`-only type (`LocalTtyBackend`, `portable_pty`,
|
||
`tokio::process::Command`). The producer's `TtyAdapter` takes a
|
||
`HashMap<String, Arc<dyn TtyBackend>>` — the local backend is
|
||
injected at the assembly layer, not wired in by the adapter. A
|
||
regression where a non-`local` module pulls in `portable_pty`,
|
||
`tokio::process`, `std::thread`, or `libc` (outside the existing
|
||
`cfg(unix)` calls in `control.rs`/`pipe.rs`) silently breaks the
|
||
downstream TS/Python adapter story.
|
||
|
||
10. **BAST document for the wire format** — `docs/architecture/tty-bast.md`
|
||
(Phase 4, not yet written) will be the machine-readable spec for the
|
||
`alk/tty` wire format, conforming to the BAST meta-schema at
|
||
`https://alk.dev/bast/v1/schema`. BAST is plain JSON — no
|
||
dependency required to author or consume it. alktty does **not**
|
||
depend on alktype; the hand-rolled `ChunkReader`/`ChunkWriter` in
|
||
`wire.rs` is the runtime codec, the BAST document is the
|
||
human-readable contract that describes what those types round-trip.
|
||
If runtime validation against the BAST becomes desirable later,
|
||
alktype becomes an optional dep and the BAST document is already
|
||
there to feed it. Do not roll your own offset map or validator for
|
||
complex formats — use alktype (as an optional dep) when that
|
||
surfaces.
|
||
|
||
11. **Access control** — the producer's direct-ALPN path keeps the
|
||
existing ad-hoc scope check (`has_scope(identity, TTY_OPEN_SCOPE)`)
|
||
for the scope gate and optionally consults `OwnershipProvider` for
|
||
the resource-ownership check (`provider.owns(id_ref, kind, &id,
|
||
"tty")` — the 4-arg shape alkcall adopted). The channels path gets
|
||
both for free via `ChannelCore::register_openable`, which wires
|
||
`AccessControl` into the operation spec — the registry runs the ACL
|
||
before the wrapper, so the `OpenHandler` only needs to validate
|
||
params and spawn the protocol. Terminal sessions are resources per
|
||
ADR-050 (ported as alktty ADR-003); `OwnershipStore::record(&self,
|
||
identity, resource_type, resource_id)` is the 3-arg shape (alkcall
|
||
dropped the old `action` arg).
|
||
|
||
12. **Feature flags** — the only feature is `local` (default = `[]`).
|
||
`local` is inherently non-wasm (`portable-pty` + `tokio::process`
|
||
need a real OS); enabling `local` on wasm is a build error by
|
||
design. Verify both `cargo test` (default) and
|
||
`cargo test --all-features` pass. Do not add a feature flag that
|
||
pulls non-wasm deps into the default crate.
|
||
|
||
13. **Naming** — Rust standard: `snake_case` for functions/variables/
|
||
modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for
|
||
constants (`STREAM_STDIN`, `TTY_OPEN_SCOPE`, `CHUNK_HEADER_LEN`).
|
||
|
||
14. **No `unsafe`** — the crate has zero `unsafe` blocks. The PTY
|
||
bridge's `libc::kill(-pgid, sig)` and `libc::kill(pid, sig)` calls
|
||
are safe `libc` crate APIs (not `unsafe` blocks in this crate);
|
||
bounds-checked slice access via `get(..)`/`ok_or_else` is the
|
||
pattern. Do not introduce `unsafe` for performance.
|
||
|
||
## Verification Commands
|
||
|
||
Run these before committing. All must pass.
|
||
|
||
```bash
|
||
cargo test
|
||
cargo clippy --all-targets -- -D warnings
|
||
cargo fmt --check
|
||
cargo doc --no-deps
|
||
cargo test --all-features
|
||
cargo check --target wasm32-unknown-unknown # the default crate stays wasm-clean
|
||
cargo clippy --target wasm32-unknown-unknown -- -D warnings
|
||
cargo publish --dry-run --allow-dirty # before a release
|
||
```
|
||
|
||
The wasm check is the structural guard for the "default crate is
|
||
wasm-clean" invariant — run it whenever a non-`local` module changes.
|
||
`cargo test --all-features` exercises the `local` backend (PTY mode is
|
||
`#[cfg(unix)]`; pipe mode is cross-platform).
|
||
|
||
## Architecture Context
|
||
|
||
- `docs/plans/project-setup.md` — the current plan (phases 0–5). Phase 0
|
||
(scaffold hygiene), Phase 1 (port core types), Phase 2 (channels
|
||
integration + `TtySession`), Phase 3 (`local` backend), and Phase 4
|
||
(architecture docs + BAST schema + renumbered ADRs) are landed.
|
||
Phase 5 (tests, including integration tests in `tests/` at the crate
|
||
root) is not yet done.
|
||
- `docs/architecture/` is created in Phase 4. The alknet ADRs
|
||
referenced below (052, 053, 054, 055, 056, 057, 077, 093) are ported
|
||
and renumbered into alktty's ADR range (001..008) in
|
||
`docs/architecture/decisions/`. The alknet originals at
|
||
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain the
|
||
authoritative source for any ADR not yet ported — read them before
|
||
non-trivial changes to the wire format, trait, or adapter.
|
||
- Key ADRs that inform this crate's design (alknet numbers → ported
|
||
alktty numbers, 1:1 in the order the plan lists them):
|
||
- ADR-052 → 001 — two-carriage wire format (JSON negotiation + raw
|
||
chunks); the 5-byte chunk header. The Phase 7 control-channel
|
||
split (`STREAM_CTRL_IN` = 3 client→server, `STREAM_CTRL_OUT` = 4
|
||
server→client) is an amendment inside this ADR, mirroring alknet
|
||
(it was not a standalone ADR there either).
|
||
- ADR-053 → 002 — `TtyBackend` trait (the inversion point between
|
||
wire-format adapter and backend crates)
|
||
- ADR-054 → 003 — local backend placement. Ported with the
|
||
single-crate rewrite: alktty folds the local backend in behind a
|
||
`local` feature (resolves the alknet cyclic-dep workaround that
|
||
motivated the original sibling-crate decision; the ADR records both
|
||
the original alknet decision and the alktty consolidation).
|
||
- ADR-055 → 004 — exit-code reporting (`{"type":"exit","code":N}` on
|
||
`ctrl_out`; `code: -1` on wait-failure)
|
||
- ADR-056 → 005 — backend cleanup on session cancel (drop of
|
||
`exit_code` future kills the session target; the
|
||
kill-on-`Drop` contract on the `TtyBackend` trait)
|
||
- ADR-057 → 006 — negotiation framing is self-contained (not reused
|
||
from alkcall's `EventEnvelope` framing)
|
||
- ADR-077 → 007 — TTY inside channels (reversed by ADR-093/008; kept
|
||
for historical context with its reversal notice pointing to 008)
|
||
- ADR-093 → 008 — TTY always uses its 5-byte format inside channels
|
||
(reverses ADR-077; channels strips its 8-byte header transparently)
|
||
- ADR-050 (dynamic resource ownership) is an alkcall/alknet-core ADR,
|
||
not a tty-specific one — it is not ported into alktty's ADR range.
|
||
The access-control work that declares against the ADR-050 model
|
||
(scope-gate at negotiation, backend-driven `resource_id()` ownership
|
||
check) is described in `tty-adapter.md` and the ADR-001/002 ported
|
||
docs, which reference ADR-050 by its alknet number.
|
||
- If a TODO references a "Phase 7" note or a design direction that an
|
||
ADR has since decided against, the TODO is stale — remove it and
|
||
align with the ADR. Do not implement the rejected design.
|
||
- The crate was ported from `/workspace/@alkdev/alknet/crates/
|
||
alknet-tty/src/` (wire, control, negotiation, backend, adapter) and
|
||
`/workspace/@alkdev/alknet/crates/alknet-tty-local/src/` (local
|
||
backend, pty, pipe). The source architecture docs at
|
||
`/workspace/@alkdev/alknet/docs/architecture/crates/tty/` are the
|
||
port origin for the Phase 4 spec docs.
|
||
- alkcall (`/workspace/@alkdev/alkcall`) is the upstream dependency.
|
||
All types formerly in `alknet-core` (`Connection`, `ProtocolHandler`,
|
||
`BiStream`, `BidiStreamSource`, `AuthContext`, `Identity`,
|
||
`IdentityProvider`, `AccessControl`, `OwnershipProvider`,
|
||
`OwnershipStore`, `InMemoryOwnershipStore`, `HandlerError`,
|
||
`StreamError`) come from `alkcall::core`. alkcall is v0.1.x —
|
||
breaking changes are expected at this major-zero stage; this is the
|
||
first real consumer, so we find and fix issues upstream rather than
|
||
working around them. Pin `alkcall = "0.1.1"` and bump deliberately. |