diff --git a/CHANGELOG.md b/CHANGELOG.md index 512b6d3..db0bbbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.0] - 2026-08-30 + +### Added +- **`ctl` control plane (C2): task and observe the hierarchy.** Building on C1's + channel + `spawn`/`list`: + - `amux ctl send ` — deliver a task to a worker as a submitted + prompt. **Queue-until-idle** (agsess-gated): if the target is mid-turn the + text waits and is delivered (text, then Enter) once it goes idle, so a send + never lands in the middle of a turn. `` is a pane id or role label. + - `amux ctl status []` — a target's live `agsess` status, or (no + target) the caller's subtree roll-up. + - `amux ctl spawn --here` — tile the worker *beside* the pane that spawned it + (same window), so a lead and its ICs sit in one view; default `spawn` still + opens a new window. +- **Subtree-scoped control (Decision 3).** `send`/`status` on a specific target + are scoped to the caller's own subtree; a **root/operator** pane controls + everything. A worker cannot steer a sibling's team — refused with a clear JSON + error. (Pure `in_subtree` guard, unit-tested.) + ## [0.7.0] - 2026-08-30 ### Added diff --git a/Cargo.toml b/Cargo.toml index 56aad17..bc20e60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "amux" -version = "0.7.0" +version = "0.8.0" edition = "2021" rust-version = "1.70" description = "tmux for agents: a multi-agent terminal that hosts your own CLIs in switchable panes with agent-aware chrome. 100% nativelite - zero third-party dependencies." diff --git a/src/ctl.rs b/src/ctl.rs index cf5985b..384dd4a 100644 --- a/src/ctl.rs +++ b/src/ctl.rs @@ -9,8 +9,10 @@ //! *pure* guard — allowlist + depth cap — so the safety rules are unit-tested //! without a pty or a running amux. //! -//! C1 surface: `spawn` (open a visible worker pane) and `list` (the org chart). -//! `send` / `status` / `kill` arrive in C2–C3. +//! Surface: `spawn` (open a visible worker — new window or `--here` split), +//! `list` (the org chart), `send` (queue-until-idle task delivery), and +//! `status` (agsess-backed). `send`/`status` with a target are subtree-scoped. +//! `kill` + identity delegation arrive in C3. use std::process::ExitCode; @@ -38,13 +40,34 @@ pub struct Request { pub cmd: Cmd, } -/// The C1 command set. +/// The command set (C1: spawn/list; C2 adds send/status). #[derive(Debug, Clone, PartialEq)] pub enum Cmd { /// Open a new worker pane running `argv`, tagged `role`. Spawn(SpawnReq), /// Report the spawn tree. List, + /// Feed `text` to a target pane's agent as a submitted prompt (queued until + /// the target is idle). + Send(SendReq), + /// Report status: one target, or (no target) the caller's visible subtree. + Status(StatusReq), +} + +/// A `send` request's payload. +#[derive(Debug, Clone, PartialEq)] +pub struct SendReq { + /// Pane id (numeric) or role label to deliver to. + pub target: String, + /// The task/prompt text to submit. + pub text: String, +} + +/// A `status` request's payload. +#[derive(Debug, Clone, PartialEq)] +pub struct StatusReq { + /// A specific pane id/role, or `None` for the caller's subtree roll-up. + pub target: Option, } /// A `spawn` request's payload. @@ -54,7 +77,8 @@ pub struct SpawnReq { /// The command to host, e.g. `["claude"]`. Must be non-empty and on the /// agent allowlist (checked by [`evaluate_spawn`]). pub argv: Vec, - /// Open in a new window (true, the C1 default) vs. split the caller (later). + /// Open in a new window (true, the default) vs. `--here` split beside the + /// caller (false). pub new_window: bool, } @@ -117,6 +141,60 @@ pub fn evaluate_spawn( Ok(attempted) } +/// Resolve a `send`/`status` target string to a pane's agent id. A numeric +/// target matches by agent id; otherwise it matches by role label. `candidates` +/// is `(agent_id, role)` for every live pane. Returns a clear error when the +/// target is unknown or a role is ambiguous (matches more than one pane). Pure. +pub fn resolve_target( + target: &str, + candidates: &[(usize, Option)], +) -> Result { + if let Ok(id) = target.parse::() { + if candidates.iter().any(|(cid, _)| *cid == id) { + return Ok(id); + } + return Err(format!("no pane with id {id}")); + } + let hits: Vec = candidates + .iter() + .filter(|(_, role)| role.as_deref() == Some(target)) + .map(|(cid, _)| *cid) + .collect(); + match hits.as_slice() { + [] => Err(format!("no pane with id or role {target:?}")), + [one] => Ok(*one), + many => Err(format!( + "role {target:?} is ambiguous ({} panes: {}); use a pane id", + many.len(), + many.iter() + .map(usize::to_string) + .collect::>() + .join(", ") + )), + } +} + +/// Is `target` inside the subtree rooted at `root` (i.e. `root` itself or a +/// descendant of it)? `parents` maps each `agent_id` to its parent. This is the +/// **subtree-scoping guard** (Decision 3): a non-privileged caller may only +/// `send`/`status` panes in its own subtree. Pure; cycle-guarded. +pub fn in_subtree(target: usize, root: usize, parents: &[(usize, Option)]) -> bool { + if target == root { + return true; + } + let parent_of = |id: usize| parents.iter().find(|(i, _)| *i == id).and_then(|(_, p)| *p); + let mut cur = target; + // The tree is shallow (depth-capped) but guard against a malformed cycle. + for _ in 0..4096 { + match parent_of(cur) { + Some(p) if p == root => return true, + Some(p) => cur = p, + None => return false, + } + } + false +} + /// Environment knob (comma-separated) that extends the ctl agent allowlist /// beyond [`bind::AGENT_STEMS`]. Opt-in on top of `--allow-ctl` and set by the /// human who launches amux, so it never weakens the confused-agent guard for a @@ -213,6 +291,23 @@ pub fn parse_request(line: &str) -> Result { }) } Some("list") => Cmd::List, + Some("send") => { + let target = v + .get("target") + .and_then(Value::as_str) + .ok_or_else(|| "send needs a target".to_string())? + .to_string(); + let text = v + .get("text") + .and_then(Value::as_str) + .ok_or_else(|| "send needs text".to_string())? + .to_string(); + Cmd::Send(SendReq { target, text }) + } + Some("status") => { + let target = v.get("target").and_then(Value::as_str).map(str::to_string); + Cmd::Status(StatusReq { target }) + } Some(other) => return Err(format!("unknown command {other:?}")), None => return Err("request has no \"cmd\"".to_string()), }; @@ -249,6 +344,28 @@ pub fn reply_spawned(pane: usize, role: Option<&str>, session: Option<&str>) -> .to_string() } +/// `{"ok":true,"target":,"queued":}` — a `send` was accepted. `queued` +/// is true when the target was busy (delivery waits for it to go idle), false +/// when it will go out immediately. Delivery itself is asynchronous. +pub fn reply_sent(target: usize, queued: bool) -> String { + obj(vec![ + ("ok", Value::Bool(true)), + ("target", i(target)), + ("queued", Value::Bool(queued)), + ]) + .to_string() +} + +/// `{"ok":true,"pane":,"status":"