a73x

702b0416

docs: ssh's stderr is mux's, and its last line is the reason

a73x   2026-08-30 10:58

Commit message
docs: ssh's stderr is mux's, and its last line is the reason

The invariant gains the rule; the README's remote section gains the row
the user will actually see; decisions.md records the bug it closes and
that ssh's PROMPTS still have nowhere mux-painted to go.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wi2HnuF1EK8HgViU11YLV

CLAUDE.md
Old New
@@ -234,7 +234,12 @@ paths (`src/cli/main.zig` is the daemon). Test fixtures in `test/`:
234 rename either: a ≤15 daemon probes the candidate for `muxd <version>` and 234 rename either: a ≤15 daemon probes the candidate for `muxd <version>` and
235 this binary answers `mux <version>`, so that one migration is 235 this binary answers `mux <version>`, so that one migration is
236 `mux d stop` then `mux d start -d` — as is 16 to the next release, whose 236 `mux d stop` then `mux d start -d` — as is 16 to the next release, whose
237 daemon execs its candidate as `d run` and finds no such verb. 237 daemon execs its candidate as `d run` and finds no such verb. That ssh's
238 STDERR is a pipe mux reads, never a writer on somebody's screen: its last
239 line is the dial's `handoff.Reason` — quoted by the picker row
240 (`unreachable: <reason>`) and by the entry dial's failure line — and only
241 the entry dial (`HandoffTarget.narrate`) relays the bytes onward, to its
242 own fd 2, and only until the wall takes the screen.
238 - **The layout sidecar is derived convenience, not authored intent.** 243 - **The layout sidecar is derived convenience, not authored intent.**
239 `$XDG_STATE_HOME/mux/layout` stores the pane tree on last detach, from 244 `$XDG_STATE_HOME/mux/layout` stores the pane tree on last detach, from
240 every wall left on a TERMINAL (`shared.is_tty`) — a piped `mux` is a wall 245 every wall left on a TERMINAL (`shared.is_tty`) — a piped `mux` is a wall
README.md
Old New
@@ -197,7 +197,11 @@ the `ssh HOST 'mux d start -d'` line above is optional after the first time.
197 Nothing else starts a daemon, because nothing else spells the flag: the 197 Nothing else starts a daemon, because nothing else spells the flag: the
198 wall's per-host poll, `mux hosts`, every reconnect and `mux --via 'ssh HOST 198 wall's per-host poll, `mux hosts`, every reconnect and `mux --via 'ssh HOST
199 mux d proxy'` run the bare `mux d endpoint`, report the box unreachable and 199 mux d proxy'` run the bare `mux d endpoint`, report the box unreachable and
200 leave it alone. Your 200 leave it alone. When that poll fails, the picker row says why in ssh's own
201 words — `unreachable: ssh: connect to host box port 22: No route to host` —
202 because mux reads that ssh's stderr rather than letting it print over the
203 wall; `mux HOST` relays the same bytes to your terminal as they come, being
204 the one dial you are sitting in front of. Your
201 ssh config (aliases, ports, 205 ssh config (aliases, ports,
202 ProxyJump) all keeps working, since `mux` never parses HOST. That ssh 206 ProxyJump) all keeps working, since `mux` never parses HOST. That ssh
203 fetches the daemon's QUIC port and key once and the session moves onto 207 fetches the daemon's QUIC port and key once and the session moves onto
docs/decisions.md
Old New
@@ -7546,3 +7546,24 @@ body-capable request that declared no length now closes its connection.
7546 - **The `use_pipe` rows are not vestigial.** Through `ssh -J gate box` the 7546 - **The `use_pipe` rows are not vestigial.** Through `ssh -J gate box` the
7547 announced port is unreachable by construction — the jump host is the only 7547 announced port is unreachable by construction — the jump host is the only
7548 route and it carries TCP — so the pipe is that host's only session. 7548 route and it carries TCP — so the pipe is that host's only session.
7549
7550 ## 2026-08-29 — the handoff ssh's stderr is a pipe mux reads
7551
7552 - **The bug.** A hosts line naming a box that is down: the wall polls it over
7553 ssh, ssh dies with `No route to host`, and that line landed on the wall's
7554 ALTERNATE SCREEN, from a writer no repaint of ours can reach — the child's
7555 stderr was inherited. `quiet` was the workaround (picker Enter only), and
7556 `.Ignore` threw the sentence away rather than keeping it.
7557 - **The rule.** The spawn always pipes; mux reads that fd in the announce poll
7558 and on the `Transport` after it, where the pump and the hub poll it beside
7559 the link. Unread it fills at 64k and ssh stops moving the session's bytes at
7560 all — asserted with a child that floods 1 MiB before it serves.
7561 - **`narrate` replaces `quiet`, inverted.** The entry dial alone relays, to its
7562 own fd 2, until the wall takes the screen; silence is the default, so a path
7563 that forgets the field cannot corrupt a paint. `--via` keeps `.Inherit`.
7564 - **What the line buys.** `handoff.Reason` survives fragmentation, keeps
7565 printable ASCII only (the row paints raw, and C1 has a two-byte UTF-8
7566 spelling) and caps at 120. The row budgets it against its spare width, since
7567 `pickerRow` cuts the spelling and never the state. It beats both old failure
7568 lines: `UnterminatedLine` named what mux observed, this names what happened.
7569 - **Deferred.** ssh's PROMPTS (`SSH_ASKPASS`); the dead-host poll pacing.