Files
alktty/AGENTS.md
T
glm-5.2 b3f50d1836 phase 4: architecture docs + BAST schema + renumbered ADRs
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
2026-08-17 10:52:26 +00:00

17 KiB
Raw Blame History

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 handlingthiserror 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 halfsrc/adapter.rs (TtyAdapter + drive_session for direct alk/tty) and src/channels.rs (register_openable helper + TtyOpenHandler for the alk/channels multiplexed path).
    • Consumer halfsrc/session.rs (TtySession typed client with connect_direct and open_via_channels constructors).
    • Sharedsrc/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 formatdocs/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.

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 05). 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.