Skip to content

feat: input authority + process signals — "take the wheel + kill" (ADR-0033)#137

Merged
phall1 merged 9 commits into
mainfrom
phux-take-the-wheel
Jun 17, 2026
Merged

feat: input authority + process signals — "take the wheel + kill" (ADR-0033)#137
phall1 merged 9 commits into
mainfrom
phux-take-the-wheel

Conversation

@phall1

@phall1 phall1 commented Jun 17, 2026

Copy link
Copy Markdown
Owner

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:

  • input was unarbitrated (handle_terminal_input's own comment: "approximated PRIMARY by subscription… per-connection roles are future work");
  • termination was fd-drop-only (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:

  • COMMAND verbs ACQUIRE_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, carrying lifecycle, exit_status, input_holder, action, actor
  • ErrorCode::InputLeaseHeld (204; 203 was already reserved for UNSAFE_PASTE)

Server (phux-server):

  • Per-pane input lease in ServerState, enforced at the gate — a non-holder's input is dropped (still acked, preserving the fire-and-forget invariant). Releases on RELEASE_INPUT and automatically on detach/disconnect, so a dead operator never strands the wheel.
  • Signals delivered to the process group via nix::killpg. portable_pty already 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 from KILL_TERMINAL, which removes the pane.
  • The actor owns lifecycle + the event-subscriber list and emits TerminalControl on 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_VERSION stays 0.5.0).

Why it matters beyond the feature

TerminalControl is 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

  • Protocol: proptest round-trips over the full event taxonomy + explicit per-command/per-signal cases
  • Server: ServerState lease 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 child
  • just ci green: 1535 tests pass, fmt + clippy (-D warnings) + deny + docs-check clean

Not 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

phall1 and others added 9 commits June 15, 2026 21:11
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>
@phall1 phall1 merged commit 361327b into main Jun 17, 2026
3 checks passed
@phall1 phall1 deleted the phux-take-the-wheel branch June 17, 2026 07:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant