chore: add .gitignore and AGENTS.md; fix implementation-specialist conventions
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.
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
# 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-008), 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-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.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`), and Phase 3 (`local` backend) are landed.
|
||||
Phase 4 (architecture docs + BAST schema + renumbered ADRs) and
|
||||
Phase 5 (tests, including integration tests in `tests/` 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 — `TtyBackend` trait (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 `local` feature (resolves the
|
||||
alknet cyclic-dep workaround; the local backend is folded in)
|
||||
- ADR-055 → 005 — exit-code reporting (`{"type":"exit","code":N}` on
|
||||
`ctrl_out`; `code: -1` on wait-failure)
|
||||
- ADR-056 → 006 — control-message split (`ctrl_in` client→server,
|
||||
`ctrl_out` server→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 `EventEnvelope` framing)
|
||||
- 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.
|
||||
Reference in New Issue
Block a user