The scaffold was missing basic repo hygiene, which led to drift: the implementation-specialist agent's Project Conventions section was a verbatim copy from alkcall (referencing OperationEnv, EventEnvelope, Capabilities, alktype as a dep, call.aborted, vendored core types — none of which exist in alktty), so spawned agents got alkcall conventions injected. - .gitignore: target/, node_modules/, .worktrees/ (matches alkcall/alktype) - AGENTS.md: alktty-specific operating instructions — Git Workflow, 14 Project Conventions (5-byte chunk header wire format, TtyBackend trait one-way door, local feature isolation / WASM invariant, BAST-as-doc-not-dep, 3-arg/4-arg ownership shapes, alknet ADR→alktty ADR renumbering plan), Verification Commands (incl. wasm checks), Architecture Context (phase status, ADR index, port origin) - .opencode/agents/implementation-specialist.md: replace stale alkcall Project Conventions section with alktty-appropriate rules that mirror AGENTS.md Verification: cargo test (80 passed), cargo fmt --check, cargo build --target wasm32-unknown-unknown all clean. No source changes.
16 KiB
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:
- Make the change
- Verify:
cargo test,cargo clippy --all-targets -- -D warnings,cargo fmt --check,cargo doc --no-depsif docs changed - Inspect
git statusandgit diffbefore staging — stage only the intended files, never secrets - 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. git push origin main- 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
TtyBackendtrait shape (one-way doors — see "Wire formats are stable" and "TtyBackendtrait shape is a one-way door" below; the 5-byte chunk header, the negotiation-frame layout, theNegotiateRequestJSON 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.
-
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 interpretslength == 0chunks"). -
Error handling —
thiserrorfor library error types (TtyError,NegotiateError,WireError,HandlerError/StreamErrorcome from alkcall::core). No panics in library code. Nounwrap()orexpect()outside tests. If you reach forunwrap, the error path wasn't specified — stop and decide what should actually happen. For poisonedRwLock/Mutex, useunwrap_or_else(|e| e.into_inner())so a panic in one operation does not cascade to other operations. -
tokiois the async runtime — all I/O is async. The adapter's per-session pumps, the channels integration, andTtySessionare all async. Usetokio::syncprimitives (oneshot,mpsc) for request/exit correlation and stdout/stderr streams. The PTY bridge insrc/local/pty.rsis the one exception: it uses three dedicated std threads feeding tokio mpsc/oneshot channels (theportable_ptyAPI is blocking) — and it's behind thelocalfeature. -
WASM target is load-bearing — the default crate (no
localfeature) MUST compile towasm32-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.tomlusestokio = { default-features = false, features = ["rt", "sync", "io-util", "macros"] }(the wasm-clean subset alkcall uses) andlocaladdstokio/process+tokio/rt-multi-thread. Do NOT usefeatures = ["full"]— it pulls insignal/fs/netwhich breakwasm32-unknown-unknown. The adapter'stokio::spawnfor per-session pumps is fine on wasm (the wasm tokio runtime supportsspawn); thelocalmodule's std threads,tokio::process::Command, andlibc::killare the non-wasm parts, and they're feature-gated.libcstays undercfg(unix)(it's already there forsignal_from_nameand the pipe-modekillpath — neither runs on wasm because the only callers are inlocal). -
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 inwire.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
NegotiateRequestbody) — self-contained per ADR-057 (ported as alktty ADR-008), not reused from alkcall'sEventEnvelopeframing (the payload isNegotiateRequest, notEventEnvelope; alkcall'sFrameFramedReaderis hardcoded to deserializeEventEnvelope). TheNegotiateRequestJSON shape is wire-stable once consumers exist.
Per ADR-093 (ported as alktty ADR-007): 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.rscode runs in both direct (alk/tty) and channels (alk/channels) modes; only theBiStreamsource differs. - 5-byte chunk header (
-
TtyBackendtrait 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 ownlocalfeature module, futurealknet-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 aHashMap<String, Arc<dyn TtyBackend>>keyed by the negotiation frame'sbackendstring and pumps theTtyHandlefields bidirectionally; backends produce handles, they do not write to the wire.TtyErroris#[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). -
Producer/consumer, not server/client — inherited from alkcall. Both sides of a
alk/ttyoralk/channelsconnection can initiate. A producer exposes TTY (direct ALPN viaTtyAdapter::ProtocolHandler, or viachannels::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. -
Module structure — one module per file under
src/, re-exported fromsrc/lib.rs. Public API surface islib.rsre-exports. The crate has three concerns:- Producer half —
src/adapter.rs(TtyAdapter+drive_sessionfor directalk/tty) andsrc/channels.rs(register_openablehelper +TtyOpenHandlerfor thealk/channelsmultiplexed path). - Consumer half —
src/session.rs(TtySessiontyped client withconnect_directandopen_via_channelsconstructors). - Shared —
src/wire.rs(chunk codec),src/control.rs(control-message enum +signal_from_name),src/negotiation.rs(negotiation frame +NegotiateRequest),src/backend.rs(TtyBackendtrait,TtyHandle,TtyParams,TtyError). Thesrc/local/module (the foldedalknet-tty-local) is behind thelocalfeature and never imported from the shared/producer/consumer modules — only re-exported fromlib.rsunder#[cfg(feature = "local")].
- Producer half —
-
localfeature isolation — thelocalmodule is the only non-wasm part of the crate. The shared/producer/consumer modules (wire,control,negotiation,backend,adapter,channels,session) MUST NOT import fromcrate::localor reference anylocal-only type (LocalTtyBackend,portable_pty,tokio::process::Command). The producer'sTtyAdaptertakes aHashMap<String, Arc<dyn TtyBackend>>— the local backend is injected at the assembly layer, not wired in by the adapter. A regression where a non-localmodule pulls inportable_pty,tokio::process,std::thread, orlibc(outside the existingcfg(unix)calls incontrol.rs/pipe.rs) silently breaks the downstream TS/Python adapter story. -
BAST document for the wire format —
docs/architecture/tty-bast.md(Phase 4, not yet written) will be the machine-readable spec for thealk/ttywire format, conforming to the BAST meta-schema athttps://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-rolledChunkReader/ChunkWriterinwire.rsis 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. -
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 consultsOwnershipProviderfor the resource-ownership check (provider.owns(id_ref, kind, &id, "tty")— the 4-arg shape alkcall adopted). The channels path gets both for free viaChannelCore::register_openable, which wiresAccessControlinto the operation spec — the registry runs the ACL before the wrapper, so theOpenHandleronly 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 oldactionarg). -
Feature flags — the only feature is
local(default =[]).localis inherently non-wasm (portable-pty+tokio::processneed a real OS); enablinglocalon wasm is a build error by design. Verify bothcargo test(default) andcargo test --all-featurespass. Do not add a feature flag that pulls non-wasm deps into the default crate. -
Naming — Rust standard:
snake_casefor functions/variables/ modules,PascalCasefor types/traits,SCREAMING_SNAKE_CASEfor constants (STREAM_STDIN,TTY_OPEN_SCOPE,CHUNK_HEADER_LEN). -
No
unsafe— the crate has zerounsafeblocks. The PTY bridge'slibc::kill(-pgid, sig)andlibc::kill(pid, sig)calls are safelibccrate APIs (notunsafeblocks in this crate); bounds-checked slice access viaget(..)/ok_or_elseis the pattern. Do not introduceunsafefor 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 0–5). Phase 0 (scaffold hygiene), Phase 1 (port core types), Phase 2 (channels integration +TtySession), and Phase 3 (localbackend) are landed. Phase 4 (architecture docs + BAST schema + renumbered ADRs) and Phase 5 (tests, including integration tests intests/at the crate root) are not yet done.docs/architecture/does not exist yet — it's created in Phase 4. The ADRs referenced below (alknet ADR-052, 053, 054, 055, 056, 057, 077, 093) will be ported and renumbered into alktty's ADR range (001..008) at that time. Until then, the alknet originals at/workspace/@alkdev/alknet/docs/architecture/decisions/are the authoritative source — read them before non-trivial changes to the wire format, trait, or adapter.- Key ADRs that inform this crate's design (alknet numbers → planned
alktty numbers):
- ADR-052 → 001 — two-carriage wire format (JSON negotiation + raw chunks); the 5-byte chunk header
- ADR-053 → 002 —
TtyBackendtrait (the inversion point between wire-format adapter and backend crates) - ADR-050 → 003 — dynamic resource ownership for runtime-spawned terminal sessions
- ADR-054 → 004 — single crate with
localfeature (resolves the alknet cyclic-dep workaround; the local backend is folded in) - ADR-055 → 005 — exit-code reporting (
{"type":"exit","code":N}onctrl_out;code: -1on wait-failure) - ADR-056 → 006 — control-message split (
ctrl_inclient→server,ctrl_outserver→client; the bidirectionality fix) - ADR-093 → 007 — TTY always uses its 5-byte format inside channels (reverses ADR-077; channels strips its 8-byte header transparently)
- ADR-057 → 008 — negotiation framing is self-contained (not reused
from alkcall's
EventEnvelopeframing)
- 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 inalknet-core(Connection,ProtocolHandler,BiStream,BidiStreamSource,AuthContext,Identity,IdentityProvider,AccessControl,OwnershipProvider,OwnershipStore,InMemoryOwnershipStore,HandlerError,StreamError) come fromalkcall::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. Pinalkcall = "0.1.1"and bump deliberately.