11423676
docs: agent surface shipped — decisions recorded, deferrals named
a73x 2026-08-13 20:03
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -102,6 +102,16 @@ muxd stats # wire stats: deltas vs snapshot bytes, attached clients | |||
| 102 | make bench # typing-workload byte-ratio measurement | 102 | make bench # typing-workload byte-ratio measurement |
| 103 | ``` | 103 | ``` |
| 104 | 104 | ||
| 105 | `muxa` is the agent-facing client — verbs `status`, `capture`, `send`, | ||
| 106 | `run`, `await`, one JSON object each, against a local socket or | ||
| 107 | `--quic HOST`. `muxa run "make test"` sends the command line, waits for the | ||
| 108 | shell to return it, and prints the exit code with the output rows; no | ||
| 109 | polling and no sleeps, because the daemon holds the wait. It attaches at | ||
| 110 | 0×0 so it never resizes the session a human is using, and every reply names | ||
| 111 | the `mechanism` that answered — an exit code is real only under `marks` | ||
| 112 | (OSC 133 shell integration, injected at spawn), absent under the `pgid` and | ||
| 113 | `settle` fallbacks. | ||
| 114 | |||
| 105 | Multiple clients may attach to one session; the grid follows the most | 115 | Multiple clients may attach to one session; the grid follows the most |
| 106 | recently active client — typing, attaching, or resizing claims it (latest | 116 | recently active client — typing, attaching, or resizing claims it (latest |
| 107 | wins). A session survives logout (this assumes systemd-logind's default | 117 | wins). A session survives logout (this assumes systemd-logind's default |
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -2981,3 +2981,145 @@ must be painted at the origin (`clear` first); EOF on a piped mux | |||
| 2981 | client aborts the QUIC dial mid-handshake — probes must hold stdin | 2981 | client aborts the QUIC dial mid-handshake — probes must hold stdin |
| 2982 | open and detach with 0x1c, or rc=1 "did not answer" lies about the | 2982 | open and detach with 0x1c, or rc=1 "did not answer" lies about the |
| 2983 | network. | 2983 | network. |
| 2984 | |||
| 2985 | ## 2026-08-13 (agent surface — native LLM integration) | ||
| 2986 | |||
| 2987 | Twelve tasks, shipped the same day as M17: OSC 133 command boundaries in | ||
| 2988 | the daemon, three new frame pairs, and `muxa`, a fourth binary that speaks | ||
| 2989 | JSON to an agent's shell tool. An agent now *knows* when a command | ||
| 2990 | returned, with its exit code and its output span, locally or over QUIC — | ||
| 2991 | the thing `tmux send-keys` + `capture-pane` structurally cannot say. The | ||
| 2992 | spec is `docs/superpowers/specs/2026-08-13-agent-surface-design.md`; what | ||
| 2993 | follows is what the implementation decided, which is not always what the | ||
| 2994 | spec guessed. | ||
| 2995 | |||
| 2996 | **Output spans are rows, not seqs — and rows are locators, not anchors.** | ||
| 2997 | The tracker's `seq` is a viewport delta generation: a whole command's | ||
| 2998 | output can share one, and a row loses its seq the moment it scrolls into | ||
| 2999 | history. So a mark records the absolute screen row (`historyRows() + | ||
| 3000 | cursor.y`) at mark time and output recovery is the existing | ||
| 3001 | `fetch_scrollback`, unchanged. The honesty is on the field, not in prose: | ||
| 3002 | `Engine.MarkEvent.row` says that pruning past `max_scrollback` shifts the | ||
| 3003 | origin (a command longer than the scrollback can leave `end_row < | ||
| 3004 | start_row`), that resize reflow renumbers history, and that alt-screen | ||
| 3005 | marks live in a coordinate space where `historyRows()` is 0. Point a human | ||
| 3006 | at output with it; never key durable state on it. | ||
| 3007 | |||
| 3008 | **An await is answered from a persisted snapshot, never from live tracker | ||
| 3009 | state.** Real shell integration emits `D;code` and `A` in one burst, so | ||
| 3010 | both fold in a single pty read and the phase is back to `at_prompt` before | ||
| 3011 | any await ever looks. A gate on `cmd.phase == .returned` therefore loses | ||
| 3012 | deterministically — it was written that way first, and the test that | ||
| 3013 | caught it drives a real shell rather than a hand-built mark sequence. | ||
| 3014 | `last_return` is the fix and the one owner: stamped at the `D`, carrying | ||
| 3015 | the exit code, the span AND the seq that qualifies it, so the answer and | ||
| 3016 | the reason it qualifies are the same record and no later reading of live | ||
| 3017 | state can drift out from under it. It also answers correctly once the | ||
| 3018 | *next* command is already running, which is the general rule worth | ||
| 3019 | keeping: **an await asks about a past event; live state describes now.** | ||
| 3020 | |||
| 3021 | **OSC 133 interception wraps the dependency's handler rather than patching | ||
| 3022 | it.** ghostty-vt already parses semantic prompts including the `err` code, | ||
| 3023 | but its stock handler drops the code and exposes no callback. `vt.Stream(H)` | ||
| 3024 | is generic over the handler, so `MuxHandler` intercepts `.semantic_prompt` | ||
| 3025 | and forwards every other action verbatim: terminal state stays identical, | ||
| 3026 | the pinned dep stays unmodified, and there is no byte-stream scanning | ||
| 3027 | anywhere. The engine's hardcoded `vt.TerminalStream` became | ||
| 3028 | `vt.Stream(MuxHandler)` and nothing else moved. | ||
| 3029 | |||
| 3030 | **Awaits resolve at the run loop's 100ms tick, with no deadline folding | ||
| 3031 | into poll.** The spec expected settle/timeout/pgid deadlines to fold into | ||
| 3032 | `wait_ms` the way QUIC's `timeoutMs` does. They do not need to: nothing in | ||
| 3033 | `checkAwaits` blocks, the granularity bound is the tick the daemon already | ||
| 3034 | beats at, and an agent's cheapest verb costs a round trip anyway. The pass | ||
| 3035 | sits after every arm that can move the session on — so it sees this pump's | ||
| 3036 | marks, pgid and silence — and *before* the QUIC `drainAll`, because a | ||
| 3037 | resolved await queues a frame and `drainAll` is what puts it on the wire. | ||
| 3038 | The other order costs every remote await a whole extra poll cycle. | ||
| 3039 | |||
| 3040 | **Session death is an answer on the lifecycle verbs and an error on the | ||
| 3041 | rest.** `muxa run` and `muxa await` print | ||
| 3042 | `{"reason":"session_ended","exit_code":N}` and exit 0 — a command that | ||
| 3043 | killed its own shell answered the question that was asked. `status`, | ||
| 3044 | `capture` and `send` have no answer to give, so they print the house's | ||
| 3045 | `{"error":…,"detail":…,"exit_code":…}` shape and exit 1. **Field note that | ||
| 3046 | cost a test:** `send`'s ack round-trip reliably WINS the race against a pty | ||
| 3047 | death, so `send` cannot be relied on to report a session that is dying — | ||
| 3048 | the next call is what sees it. That is a property of the ordering, not a | ||
| 3049 | flake. | ||
| 3050 | |||
| 3051 | **`--timeout` is the daemon's window; the client adds bounded grace.** The | ||
| 3052 | daemon's clock starts when it reads the request, so a client that waits | ||
| 3053 | exactly `--timeout` always loses the race to the reply and reports a | ||
| 3054 | timeout the daemon never had. The grace is `min(30s, max(2s, 4 × | ||
| 3055 | connect_ms))`: flat 2s over a unix socket, RTT-derived over QUIC from the | ||
| 3056 | one measurement the client already has (its own handshake), capped so a | ||
| 3057 | handshake that took a minute cannot buy a minute of grace. The cost is | ||
| 3058 | named because agents budget wall clock: a `--timeout N` wait can overshoot | ||
| 3059 | N by the grace window. | ||
| 3060 | |||
| 3061 | **Reconnect is at-most-once for the wire and exactly-never for input.** | ||
| 3062 | One redial per process, on `ConnectionLost` and on nothing else (a | ||
| 3063 | `SendStalled` peer is still there and has already spent the flush bound | ||
| 3064 | proving it). What is re-sent is the attach and the `await_req` with the | ||
| 3065 | ORIGINAL `since_seq` — idempotent by construction, and the reason a return | ||
| 3066 | that landed inside the gap is answered instead of missed — and never | ||
| 3067 | `run`'s input. A command line lost with the connection surfaces as an | ||
| 3068 | honest timeout, not as `make deploy` running twice: **a wait may be | ||
| 3069 | repeated because asking twice changes nothing; an input may not, because it | ||
| 3070 | changes everything.** A redial that fails is recorded rather than | ||
| 3071 | swallowed, so the verb reports both halves — `connection lost; reconnect | ||
| 3072 | failed: <err>` — and a second tear after a spent redial says so in its own | ||
| 3073 | words. | ||
| 3074 | |||
| 3075 | **`settled` means output went quiet, not that the process exited.** The | ||
| 3076 | settle and pgid resolutions carry `exit_code: null` and a `phase` derived | ||
| 3077 | from marks, which on a markless shell reads `at_prompt` — the tracker | ||
| 3078 | never saw a `C`, so it is telling the truth about what it knows. Only | ||
| 3079 | `mechanism == "marks"` carries a trustworthy exit code, which is why every | ||
| 3080 | reply names its mechanism. An agent that reads `exit_code` without reading | ||
| 3081 | `mechanism` is reading a guess. | ||
| 3082 | |||
| 3083 | ### Field limitations, shipped knowingly | ||
| 3084 | |||
| 3085 | - **zsh under the `ZDOTDIR` shim never sources `~/.zshenv`.** zsh looks for | ||
| 3086 | `.zshenv` under `$ZDOTDIR`, and the shim directory has none. The `.zshrc` | ||
| 3087 | is handed back (the common case); a `.zshenv` shim that restores | ||
| 3088 | `ZDOTDIR` the way ghostty's does is the roadmap fix. | ||
| 3089 | - **The bash shim's DEBUG trap displaces a user's own DEBUG trap** — | ||
| 3090 | silently, and that is bash-preexec, atuin and iTerm2's integration. The | ||
| 3091 | roadmap fix is coexistence via bash-preexec detection. Two limits of the | ||
| 3092 | membership guard that keeps `PROMPT_COMMAND`'s own members from being | ||
| 3093 | counted as commands: a typed command textually identical to a | ||
| 3094 | `PROMPT_COMMAND` member loses its marks (degrading to pgid/settle, not to | ||
| 3095 | a wrong answer), and a compound string member (`a; b`) can re-arm the | ||
| 3096 | guard. Arrays — bash 5.1's default and what the shim prefers — are exact. | ||
| 3097 | - **A SIGKILLed daemon orphans its `mux-shellint-{pid}` directory.** | ||
| 3098 | Bounded and per-pid, cleaned on every ordinary exit; a startup sweep of | ||
| 3099 | dead-pid directories is the roadmap item. `prepare()` failing is degraded | ||
| 3100 | and never fatal: the session runs on pgid and settle, and says so on | ||
| 3101 | stderr, because refusing to start a daemon over an optional enhancement | ||
| 3102 | would invert the module's premise. | ||
| 3103 | - **`muxa` has no idle-timeout flag, by design.** It takes | ||
| 3104 | `quic.default_idle_ms` and lets `--timeout` be the only bound on a wait; | ||
| 3105 | `muxd`, `mux` and `muxweb` keep `--quic-idle-ms`. | ||
| 3106 | |||
| 3107 | ### Deferred, and named so the deferral reads as a choice | ||
| 3108 | |||
| 3109 | The spec's non-goals stand: **no MCP server** (a wrapper over `muxa` needs | ||
| 3110 | no protocol change, so it can be layered whenever someone wants it), **no | ||
| 3111 | read-only or capability-scoped auth** (one key is still full control, which | ||
| 3112 | is the same posture every other transport has), **no input attribution** | ||
| 3113 | (an attached human cannot tell which keystrokes were the agent's), **no | ||
| 3114 | semantic event subscriptions** beyond the `cmd_state` push, and no | ||
| 3115 | single-shot `muxa drive` (`send` + `await` + `capture` composes it). Added | ||
| 3116 | by the reviews: the `.zshenv` shim, bash-preexec coexistence, and the | ||
| 3117 | shim-directory startup sweep. | ||
| 3118 | |||
| 3119 | **Paid in a separate commit, and only after the e2e pinned the spellings:** | ||
| 3120 | `parseQuicAddr`/`resolveHost` now live in `quic.zig` as one owner for the | ||
| 3121 | dial-address grammar, taking an allocator (`client.zig`'s copy hardcoded | ||
| 3122 | `std.heap.page_allocator`, so the fold is also a fix). A human typing | ||
| 3123 | `mux quic://HOST:PORT` and an agent typing `muxa --quic HOST:PORT` were | ||
| 3124 | parsing the same grammar through two copies, which is how one flag becomes | ||
| 3125 | two dialects. | ||
docs/roadmap.md
| Old | New | ||
|---|---|---|---|
| @@ -5,7 +5,8 @@ The forward view, one item per line, ranked. History and evidence live in | |||
| 5 | this file at each milestone close and whenever the queue reorders; the | 5 | this file at each milestone close and whenever the queue reorders; the |
| 6 | queue's order is set by the user, not by this file. | 6 | queue's order is set by the user, not by this file. |
| 7 | 7 | ||
| 8 | **Now:** M1–M15 complete; `v0.0.1-2` published as a Linux tarball; in | 8 | **Now:** M1–M15 and M17 complete, plus the agent surface; published as a |
| 9 | Linux tarball, tagged through `v0.0.1-5`; in | ||
| 9 | field trial on real VMs, and the trial is now producing the queue. | 10 | field trial on real VMs, and the trial is now producing the queue. |
| 10 | Trial feedback outranks everything below — what actually hurts in use is | 11 | Trial feedback outranks everything below — what actually hurts in use is |
| 11 | better data than any of this ranking, and the proof is that the three | 12 | better data than any of this ranking, and the proof is that the three |
| @@ -220,6 +221,56 @@ u64 overflow — 14 bytes could panic the hub). Banked follow-ups, led | |||
| 220 | by the headless browser-boot automation ("the page never ran" was the | 221 | by the headless browser-boot automation ("the page never ran" was the |
| 221 | milestone's defining defect), are listed there. | 222 | milestone's defining defect), are listed there. |
| 222 | 223 | ||
| 224 | ## Agent surface — native LLM integration — complete | ||
| 225 | |||
| 226 | Shipped 2026-08-13 (ac9373f..cbd7007 + close-out), twelve tasks. An agent | ||
| 227 | driving a mux session now *knows* when a command returned, with its exit | ||
| 228 | code and the rows its output occupies, over a unix socket or over QUIC — | ||
| 229 | which is the signal `tmux send-keys` + `capture-pane` structurally cannot | ||
| 230 | give and the reason this exists. What shipped: OSC 133 marks injected at | ||
| 231 | spawn (`shellint.zig` — zsh `ZDOTDIR` shim, bash `--init-file`, fish | ||
| 232 | `vendor_conf.d`), a mux-owned ghostty-vt stream handler that surfaces them | ||
| 233 | as row-stamped events, a pure command state machine (`cmd.zig`), three | ||
| 234 | frame pairs (`cmd_state`, `await_req`/`await_reply`, | ||
| 235 | `status_req`/`status_reply`) that ride `muxd proxy` untouched, server-held | ||
| 236 | awaits with a pgid edge and a settle floor under the marks path, and | ||
| 237 | `muxa` — a fourth binary, five verbs, one JSON object per verb, attaching | ||
| 238 | at 0×0 so an agent never claims the human's grid. e2e is `test/agent.sh`, | ||
| 239 | **9 scenarios**, including a QUIC tear healed mid-await with the command | ||
| 240 | proven to have run exactly once. The decisions worth reading before | ||
| 241 | touching it are in decisions.md: rows are locators and not anchors, an | ||
| 242 | await is answered from `last_return` and never from live tracker state, | ||
| 243 | and only `mechanism == "marks"` carries a real exit code. | ||
| 244 | |||
| 245 | **Field limitations, shipped knowingly** (each is a roadmap item, none is a | ||
| 246 | blocker): | ||
| 247 | |||
| 248 | - **zsh loses `~/.zshenv`** under the `ZDOTDIR` shim — the shim directory | ||
| 249 | has none. A ghostty-style `.zshenv` shim is the fix. | ||
| 250 | - **The bash shim's DEBUG trap displaces the user's**, silently: that is | ||
| 251 | bash-preexec, atuin and iTerm2. Coexistence via bash-preexec detection is | ||
| 252 | the fix. Two membership-guard limits stand meanwhile: a typed command | ||
| 253 | textually identical to a `PROMPT_COMMAND` member loses its marks | ||
| 254 | (degrading to pgid/settle), and a compound string member (`a; b`) can | ||
| 255 | re-arm the guard — arrays, the modern default, are exact. | ||
| 256 | - **A SIGKILLed daemon orphans its `mux-shellint-{pid}` directory**; a | ||
| 257 | startup sweep of dead-pid directories is owed. Injection failing is | ||
| 258 | always degraded and never fatal. | ||
| 259 | - **`--timeout N` can overshoot N** by the client's grace window (2s over a | ||
| 260 | socket, up to 30s over a slow QUIC handshake): an agent budgeting wall | ||
| 261 | clock must add it. | ||
| 262 | - **`muxa` has no idle-timeout flag**, deliberately — `--timeout` is the | ||
| 263 | only bound on a wait. | ||
| 264 | |||
| 265 | **Deferred, and named** (the spec's non-goals plus what review added): an | ||
| 266 | MCP server wrapping `muxa`; read-only or capability-scoped auth (one key = | ||
| 267 | full control still); input attribution; semantic event subscriptions | ||
| 268 | beyond the `cmd_state` push; `muxa drive` as single-shot sugar; the | ||
| 269 | `.zshenv` shim; bash-preexec coexistence; the shim-directory startup | ||
| 270 | sweep. The `parseQuicAddr`/`resolveHost` dedup that review endorsed is | ||
| 271 | **paid**, not deferred — one owner in `quic.zig`, landed after the e2e had | ||
| 272 | pinned the spellings. | ||
| 273 | |||
| 223 | ## M16 candidates — and still outranked by trial feedback | 274 | ## M16 candidates — and still outranked by trial feedback |
| 224 | 275 | ||
| 225 | What remains is what was ranked behind M13 and M14 and survived M15, | 276 | What remains is what was ranked behind M13 and M14 and survived M15, |
docs/superpowers/plans/2026-08-13-agent-surface.md
| Old | New | ||
|---|---|---|---|
| @@ -10,6 +10,17 @@ | |||
| 10 | 10 | ||
| 11 | **Spec:** `docs/superpowers/specs/2026-08-13-agent-surface-design.md` — read it first. | 11 | **Spec:** `docs/superpowers/specs/2026-08-13-agent-surface-design.md` — read it first. |
| 12 | 12 | ||
| 13 | > **Status (2026-08-13, all twelve tasks shipped):** this file is the | ||
| 14 | > historical execution record — what was planned and in what order — and it | ||
| 15 | > is deliberately not updated to match what landed. Where the two disagree, | ||
| 16 | > the code and `docs/decisions.md` are the truth. The divergences worth | ||
| 17 | > knowing before reading below: `returned_seq` was deleted in favour of one | ||
| 18 | > owner, `last_return`, which carries the watermark inside the answer; | ||
| 19 | > `checkAwaits` runs before the QUIC `drainAll` at the end of the pump, not | ||
| 20 | > after it; `muxa status` nests the cursor in its JSON; and the bash shim's | ||
| 21 | > final shape (array-aware `PROMPT_COMMAND`, membership guard, trap | ||
| 22 | > installed last) postdates the text in Task 7. | ||
| 23 | |||
| 13 | **Conventions for every task:** | 24 | **Conventions for every task:** |
| 14 | - `ZIG=~/Downloads/zig-x86_64-linux-0.15.2/zig` — run from the worktree root (`.worktrees/agent-surface`). | 25 | - `ZIG=~/Downloads/zig-x86_64-linux-0.15.2/zig` — run from the worktree root (`.worktrees/agent-surface`). |
| 15 | - Full suite: `$ZIG build test`. It must pass before every commit. | 26 | - Full suite: `$ZIG build test`. It must pass before every commit. |
src/quic.zig
| Old | New | ||
|---|---|---|---|
| @@ -39,9 +39,13 @@ pub const default_port: u16 = 4433; | |||
| 39 | /// gone. Long enough that a quiet terminal is not a suspicious one, short | 39 | /// gone. Long enough that a quiet terminal is not a suspicious one, short |
| 40 | /// enough that a client which has genuinely vanished stops being served | 40 | /// enough that a client which has genuinely vanished stops being served |
| 41 | /// within a few seconds of keepalives failing — keepalives run at a third | 41 | /// within a few seconds of keepalives failing — keepalives run at a third |
| 42 | /// of it, so an idle session is never the thing that trips it. Tunable on | 42 | /// of it, so an idle session is never the thing that trips it. |
| 43 | /// both binaries because the reconnect tests need death declared on a | 43 | /// `--quic-idle-ms` tunes it on the three binaries that dial or listen for |
| 44 | /// schedule they can wait for. | 44 | /// a human — `muxd`, `mux`, `muxweb` — because the reconnect tests need |
| 45 | /// death declared on a schedule they can wait for. `muxa` deliberately has | ||
| 46 | /// no such flag and always takes this default: an agent's wait is bounded | ||
| 47 | /// by `--timeout` already, and a second knob over the same wait is one | ||
| 48 | /// more thing for a driver to get wrong. | ||
| 45 | /// | 49 | /// |
| 46 | /// Here for the same reason as `default_port`: two copies of a number both | 50 | /// Here for the same reason as `default_port`: two copies of a number both |
| 47 | /// binaries default to are two numbers, and they drift in silence. | 51 | /// binaries default to are two numbers, and they drift in silence. |