ctl C1: opt-in control plane — channel + spawn/list, spawn tree, caps - #2
Merged
Conversation
…tree, caps
amux can now host a *controllable, observable* hierarchy of agents. This is the
first milestone of the amux-ctl-control-plane design (severe/critical: a hosted
agent gains process-spawn power, so the safety model is the heart of it).
What's added (all behind --allow-ctl; off by default):
- ipc.rs: a zero-dep local control channel — Windows named pipe / unix socket,
with a non-blocking Listener::poll/respond and a client request(). The Windows
path is the C0-verified PIPE_NOWAIT design; the roundtrip is a permanent test.
- ctl.rs: the `amux ctl` client + protocol. `spawn` (open a visible worker pane)
and `list` (the spawn tree as JSON). evaluate_spawn() is the pure safety guard
(agent allowlist + --max-depth recursion cap), unit-tested in isolation.
- main.rs: --allow-ctl / --max-depth flags; each pane is stamped with a global
agent_id and born with AMUX_CTL/AMUX_PANE in its env (merged with identity
secrets, which stay per-spawn and unlogged); the run loop drains the channel
each tick and applies requests as pane ops (apply_ctl).
Safety (design §5): opt-in off by default; agents-only allowlist ({claude},
extensible per-session via AMUX_CTL_ALLOW); --max-depth (default 6, 0=unlimited)
bounds recursion, never width; every worker is a normal visible/killable pane in
the org chart. No --allow-ctl ⇒ no pipe, `amux ctl` refuses, behavior identical.
Verified: `python dev.py check` green — 112 lib + 43 integration tests. New
coverage: pure guard (allowlist/depth/extra-allow), request build/parse
roundtrip, flag parsing, the ipc roundtrip (C0 promoted), and three live e2e
tests driving a real amux over the pipe (list roundtrip, spawn opens a visible
worker window, off-allowlist refusal). fmt + clippy clean.
Not in C1 (C2–C3): ctl send/status/kill, identity delegation, --here splits.
JSBtechnologies
added a commit
that referenced
this pull request
Sep 4, 2026
From the fleet review of 133a1ca. `install()` discarded `signal()`'s return, so a SIG_ERR left the disposition at SIG_DFL: the process would still die where it stood and orphan every agent, silently reintroducing the exact bug this module was written to fix. A cleanup path that can fail to arm without saying so is worse than none, because it reads as covered. Now checks against SIG_ERR and warns on stderr naming the signal. install() runs at the top of main, before raw mode, so the warning is readable. The reviewer was right and this was mine — the original commit asserted the handler was installed rather than verifying it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_017KoBz1RcGsUbYdMSCR7LaK
JSBtechnologies
added a commit
that referenced
this pull request
Sep 4, 2026
`pty.kill()` is SIGKILL to the direct child alone. Everything that child
spawned — for an agent CLI, its MCP servers, language servers and node helpers
— survived and was reparented onto init. This was not a signal-handling edge
case: it leaked on a clean `Ctrl+A q`, and has since day one. Demonstrated by
spawning a grandchild in a pane, quitting normally, and watching it live on. A
fleet review measured 23 leaked agent processes holding 4.6 GB.
Unix has no "kill this tree" primitive, but the machinery was already here and
merely unreachable: `pty` runs `setsid()` in `pre_exec`, so every pane child is
a session leader whose process-group id equals its pid. One `killpg` reaches
the whole subtree. `pty-rs` e8da3c2 exposes the pid; Cargo.lock moves with it.
Two policy choices live in `amux::reap` rather than in `pty`:
* SIGTERM before SIGKILL. The old bare SIGKILL is uncatchable, so it
*guaranteed* orphans — the agent never got the chance to reap its own
children. Terming first lets a well-behaved one tear its tree down itself.
* Signal every group, then wait once. A per-pane grace would make teardown
O(panes) in wall clock: ten panes at 750 ms is a seven-second quit.
Teardown also stops claiming success it did not achieve: any group still alive
afterwards is named on stderr. A cleanup path that fails silently is how 4.6 GB
went unnoticed.
Windows is unchanged and says so in code rather than pretending: it has no
POSIX process groups, and the real answer there is a Job Object created at
spawn with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE — a kernel guarantee stronger
than anything this achieves on unix. Tracked next.
Stated limit: a grandchild that calls setsid() itself leaves the group and this
cannot reach it. macOS offers no container to catch that (no cgroups, no Job
Objects); the durable registry sweep is the intended backstop.
The regression test spawns its grandchild through a NON-interactive shell on
purpose — `sh -i` runs job control, which puts each background job in its own
process group, the one arrangement killpg cannot follow and not how an agent
forks helpers. The grandchild reports its pid to a file keyed by the test
process, because scanning the process table is racy under the parallel suite.
macOS 26.6.2: ./dev.py check green — 304 tests, 0 failures, fmt and dependency
guard clean. Type-checks for x86_64-pc-windows-msvc.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_017KoBz1RcGsUbYdMSCR7LaK
JSBtechnologies
added a commit
that referenced
this pull request
Sep 4, 2026
…fied on real Windows] Fills the src/reap.rs cfg(not(unix)) stub with the durable Windows teardown layer from the design spec: one Job Object per session with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. Every pane is assigned to it; amux holds the sole non-inheritable handle for its whole life; the kernel terminates every process in the job — children inherit it, so grandchildren too — the instant that handle closes, however amux exits, TerminateProcess included. That is the one death mode no unix layer can match. - SessionJob (src/reap.rs): no-op on unix; on Windows creates the job (kill-on-close, non-inheritable handle per R2), assign() adds a pane, Drop closes the handle. Win32 externs + constants + repr(C) structs (IoCounters / JobBasic / JobExtended) transcribed from winnt.h; usize for SIZE_T/ULONG_PTR (correct on 32- and 64-bit). - main.rs: session_job created once in run(), held for the session, each new pane assigned in the registry loop. spawn_watchdog + its var are now cfg(unix) — on Windows the job replaces the watchdog, which there could not signal a group and would block on its pipe forever (R5). amux reap stays as the near-inert manual path. - R1: create/assign failures degrade to a debug warning and a null-handle no-op; a pane is never refused. GetLastError captured immediately after each failing call (before CloseHandle) per review. VERIFIED ON REAL WINDOWS (what the spec's macOS author could not do): acceptance corruption), #2 clean-quit kills the pane's grandchild, #3 taskkill /F of amux still takes the tree down via kill-on-close, #6 full dev.py check green (237 tests). R4 confirmed: hard-killing amux leaves no conhost residue. Adversarially reviewed (rust-reviewer): verdict ship, layout exact on both bitwidths, handle inheritance correctly prevented, every failure path a safe no-op, no double-close. Also (pre-existing, distinct): fixed closing_an_overlay_repaints_the_pane, which shipped in the unix-teardown work typing a bash-only marker into cmd.exe and could never pass on Windows (confirmed failing at clean 43e7a6e). Branched the marker for cmd. Exactly the cargo-check-is-not-a-run gap the spec warned about.
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 & why
First milestone of the amux
ctlcontrol plane (design —roadmap/amux-ctl-control-plane.md): turn amux from a viewer of agents into a runtime for a controllable, observable hierarchy. An agent (or the human) in one pane canspawnandlistother agent panes over a tiny local control channel — every worker a normal, visible, killable pane.Severe/critical per charter §3 (a hosted agent gains process-spawn power) → PR + review. The C0 spike already proved the two load-bearing unknowns (zero-dep named-pipe IPC + non-blocking drain); this builds the real thing on that.
Surface (all behind
--allow-ctl, off by default)How it's built
ipc.rs— zero-dep channel: Windows named pipe (PIPE_NOWAIT, the C0-verified design, in the same kernel32-FFI house style aspty/cred) / unix socket. Non-blockingListener::poll/respond, drained from the run loop with no thread.ctl.rs— theamux ctlclient + protocol.evaluate_spawn()is the pure safety guard (allowlist + depth), unit-tested in isolation.main.rs—--allow-ctl/--max-depth; every pane gets a globalagent_idand is born withAMUX_CTL/AMUX_PANEin its env (merged with identity secrets, which stay per-spawn and unlogged);apply_ctlapplies requests as pane ops.Safety model (design §5) — the heart of the review
--allow-ctl⇒ no pipe,amux ctlrefuses, behavior byte-identical to 0.6.1.{claude}), extensible per session by the human viaAMUX_CTL_ALLOW— never weakens a session that didn't ask.--max-depth(default 6,0=unlimited) bounds recursion, not width — a fork-bomb circuit-breaker; a designed fleet of any size still runs.ctl list, killable.Verification
python dev.py checkgreen — 112 lib + 43 integration tests, dep-guard OK,fmt+clippyclean. New coverage:AMUX_CTL_ALLOW), request build↔parse roundtrip, flag parsing;listroundtrip,spawnopens a visible worker window, off-allowlist refusal returns a clean JSON error.Not in C1 (C2–C3)
ctl send/status/kill, credential delegation scoping,--heresplits.--herecurrently returns a clear "lands in C2" error rather than silently opening a new window.