feat: input authority + process signals — "take the wheel + kill" (ADR-0033)#137
Merged
Conversation
Completes the QUIC transport end-to-end: the client can now dial a
`phux server --quic` listener, not just the server accept it. ADR-0007's
last-deferred piece.
- phux-client: `Connection`/`FrameReader`/`FrameWriter` become enums over
UDS and QUIC; a `Dial { Uds | Quic }` threads through the attach +
reconnect chain. The `&Path` driver entries stay as shims so the
existing test/integration surface is untouched.
- New `attach::quic` dialer: rustls TLS 1.3 + the `phux-quic/1` ALPN, one
bidirectional stream, optional bearer-token preamble. Fingerprint pin
(`--cert-fingerprint`, from `phux pair`) for routable hosts; loopback-only
skip-verify; a non-loopback dial without a pin is refused.
- ALPN moves to `phux-protocol::policy::QUIC_ALPN` so server and client
cannot drift; server `tls.rs` re-exports it.
- quinn/rustls promoted to workspace deps (identical features; quinn's
`log` feature stays omitted to avoid tracing/log feature-unification).
- CLI: `--quic`, `--token`, `--cert-fingerprint`, `--tls-server-name` on
`phux attach`.
- Clean teardown: a `CONNECTION_CLOSE` on `shutdown()` (reconnect probe)
and `QuicWriter::Drop`, so the server reaps a detached/probed consumer
immediately instead of at the 30s idle timeout. QUIC reader is buffered
via quinn's `AsyncRead`, preserving the burst-coalescing the UDS path has.
Tests: tests/quic_dial.rs drives the dialer against an in-test quinn
server (handshake, pin accept/reject, token preamble, bidirectional
framing, prompt close). No wire change, so no spec version bump.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ling the TUI A TERMINAL_SNAPSHOT is authoritative full-screen state, but the client was applying it with two render-perf shortcuts that left stale/garbage cells on exactly the lifecycle events that emit a snapshot — attach, reattach, window switch, and resize-resync: - The snapshot paint used the incremental `render_at`, which trusts libghostty's per-row dirty bits. After a `safe_resize` + replay, rows the client still needs can read back `Dirty::Clean` (resize-grow exposing rows, alt-screen transitions), so `render_at` skipped them and left whatever was on the host terminal — the "mangled screen". Both the focused and non-focused snapshot branches now use `render_at_full`, the path built for exactly this (the split-leaves-pane-blank case it documents). - In a coalesced burst the snapshot's paint was deferred whenever a later same-pane TerminalOutput followed, leaving the trailing *incremental* output to paint onto a screen the snapshot never drew. The live coalescing path now excludes TerminalSnapshot from the defer mask via `frame_defers_paint`; ordinary outputs still coalesce, and the headless ingest path (which passes `defer_paint = true` directly) is untouched, so its no-emit invariant holds. Tested: 36 attach/reattach/multipane integration tests pass; a new `frame_defers_paint` unit test locks the snapshot-never-defers invariant. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… windows stop aliasing onto one terminal
The "open vim in one window and it shows up in the other" bug, plus broken
new-window/window-switch and blank screens after closing a window.
`reconcile_loaded_workspace` ran the phux-jy4t foreign-session discard
per window, passing the single global `bootstrap_focus` to each. That guard
("if this tree doesn't contain the focused pane, it's a foreign session —
replace it with `LayoutState::single(focus)`") is only sound for a
single-window workspace. In a multi-window workspace a non-active window
legitimately never contains the focused pane, so the guard rewrote every
non-active window to `single(focus)` — leaving two windows referencing the
same `TerminalId`. Two windows then render one shell.
It fires constantly because the client subscribes to its own session's
layout key, so every `SET_METADATA` the client writes (new window, split)
is broadcast back to it; the echo decodes the workspace and reconciles,
clobbering every non-active window onto the focused pane.
Fix: evaluate the foreign-session signal at WORKSPACE scope. The workspace
is foreign only when the focused pane is a leaf of NONE of its windows;
then discard the whole thing for a clean single pane (phux-jy4t intent
preserved). Otherwise keep every window and only repair each window's focus
to a leaf of its OWN tree — never the global focus. `reconcile_loaded_layout`
no longer discards.
Tested: the three foreign-discard tests move to the workspace entry point;
a new `reconcile_multi_window_does_not_alias_non_active_windows` regression
test locks the fix; 36 attach/reattach/multipane integration tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the supervisory "take the wheel + kill" surface to the COMMAND
envelope and the agent-event stream, all additive (no tag renumbered,
no existing bytes changed):
- Command tags 0x0f ACQUIRE_INPUT, 0x10 RELEASE_INPUT, 0x11 SIGNAL_TERMINAL
- InputMode {Cooperative, Seize} and TerminalSignal {Interrupt, Freeze,
Resume, Terminate, Kill} selectors
- AgentEvent::TerminalControl (tag 0x08) carrying lifecycle, exit_status,
input_holder, action, and actor — the live "who has the wheel" / "frozen"
broadcast and the seed of the audit trail
- TerminalLifecycle / ControlAction enums and ErrorCode::InputLeaseHeld (204;
203 is reserved for UNSAFE_PASTE)
Encode/decode plus an Option<ClientId> codec, with proptest round-trips over
the full taxonomy and explicit per-command/per-signal cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…DR-0033) Implement the supervisory backend on top of the new wire surface: - ServerState owns a per-pane input lease; the input gate (handle_terminal_input / handle_route_input) drops a non-holder's input while a lease is held (still acked, preserving fire-and-forget). Leases release on RELEASE_INPUT and automatically on detach/disconnect. - ACQUIRE_INPUT (cooperative/seize), RELEASE_INPUT, and SIGNAL_TERMINAL command handlers. SIGNAL_TERMINAL delivers a POSIX signal to the pane's process group via nix::killpg — portable_pty already makes the child a process-group leader, so the signal reaches the agent and its whole subtree. Freeze/Resume is the reversible brake; distinct from KILL_TERMINAL, which removes the pane. - The actor owns lifecycle + the event-subscriber list and emits TerminalControl on every lease change and signal; it bypasses the semantic-type filter so every subscriber sees it. The disconnect path broadcasts Released before the lease state is cleared. Adds nix (signal feature) for arbitrary job-control signals. Covered by ServerState lease unit tests and an end-to-end PTY test asserting freeze->frozen, resume->running, and kill terminates the child. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shell affordances for the supervisory surface, mirroring `phux kill`'s
selector-resolve-then-command shape:
- `phux take TARGET` — seize a pane's input lease (Seize mode)
- `phux give TARGET` — release it
- `phux signal TARGET {interrupt|freeze|resume|terminate|kill}` — signal
the pane's process group
Each resolves a selector client-side to one pane and issues a single
control command over a fresh connection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- ADR-0033 records the decision: input authority as a leased single-holder property of a Terminal, process signals distinct from pane teardown, and a broadcast control event that doubles as the audit-trail seed. - L1 §5.1 catalog gains ACQUIRE_INPUT/RELEASE_INPUT/SIGNAL_TERMINAL plus the InputMode/TerminalSignal selectors and prose; the AgentEvent table gains terminal_control (0x08); proto §14 gains INPUT_LEASE_HELD (204). - CHANGELOG entry 0.5.0-draft.6 (additive; PROTOCOL_VERSION stays 0.5.0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds the supervisory surface every operator of a long-running agent wants: seize exclusive input authority over a running pane, and signal the process inside it — freeze, resume, interrupt, terminate, kill. This is ADR-0033, the substrate layer of the "mission control for agent terminals" thesis.
Two holes in the existing code lined up exactly with this:
handle_terminal_input's own comment: "approximated PRIMARY by subscription… per-connection roles are future work");KILL_TERMINAL→ SIGHUP, no signal choice, no reversible pause).This fills both, additively.
How
Wire (
phux-protocol) — additive, no tag renumbered, no existing bytes changed:COMMANDverbsACQUIRE_INPUT(0x0f),RELEASE_INPUT(0x10),SIGNAL_TERMINAL(0x11)InputMode {Cooperative, Seize},TerminalSignal {Interrupt, Freeze, Resume, Terminate, Kill}AgentEvent::TerminalControl(0x08) — the live "who has the wheel" / "frozen" broadcast, carryinglifecycle,exit_status,input_holder,action,actorErrorCode::InputLeaseHeld(204; 203 was already reserved forUNSAFE_PASTE)Server (
phux-server):ServerState, enforced at the gate — a non-holder's input is dropped (still acked, preserving the fire-and-forget invariant). Releases onRELEASE_INPUTand automatically on detach/disconnect, so a dead operator never strands the wheel.nix::killpg.portable_ptyalready makes the PTY child a process-group leader, so a signal reaches the agent and its whole subtree — no spawn-site change.Freeze/Resume(SIGSTOP/SIGCONT) is the reversible brake; distinct fromKILL_TERMINAL, which removes the pane.TerminalControlon every lease change and signal, bypassing the semantic-type filter so every subscriber sees it.CLI (
phux):phux take,phux give,phux signal TARGET {interrupt|freeze|resume|terminate|kill}.Docs: ADR-0033, L1 §5.1 catalog + AgentEvent table + proto §14, CHANGELOG
0.5.0-draft.6(additive;PROTOCOL_VERSIONstays 0.5.0).Why it matters beyond the feature
TerminalControlis already a timestamped, structured record of every takeover/freeze/kill, keyed to an actor and a pane. Persisted next to the VT byte stream (ADR-0013) it becomes replayable, tamper-evident "who supervised what, when" — the audit/compliance artifact — gotten as a side effect of the live-supervision UX. The OSS wedge and the enterprise moat are the same event stream.Tests
ServerStatelease state-machine unit tests (open/acquire/seize/release/detach-clears) + an end-to-end PTY test asserting freeze→frozen, resume→running, and kill terminates the childjust cigreen: 1535 tests pass, fmt + clippy (-D warnings) + deny + docs-check cleanNot in this PR (cleanly separable)
Interactive TUI keybindings + the "FROZEN / @holder has the wheel" chrome — pure consumer logic over the events the backend now emits (ADR-0017). The substrate is done and waiting for it.
🤖 Generated with Claude Code