Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <target> <text>` — 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. `<target>` is a pane id or role label.
- `amux ctl status [<target>]` — 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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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."
Expand Down
223 changes: 218 additions & 5 deletions src/ctl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<String>,
}

/// A `spawn` request's payload.
Expand All @@ -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<String>,
/// 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,
}

Expand Down Expand Up @@ -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<String>)],
) -> Result<usize, String> {
if let Ok(id) = target.parse::<usize>() {
if candidates.iter().any(|(cid, _)| *cid == id) {
return Ok(id);
}
return Err(format!("no pane with id {id}"));
}
let hits: Vec<usize> = 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::<Vec<_>>()
.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<usize>)]) -> 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
Expand Down Expand Up @@ -213,6 +291,23 @@ pub fn parse_request(line: &str) -> Result<Request, String> {
})
}
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()),
};
Expand Down Expand Up @@ -249,6 +344,28 @@ pub fn reply_spawned(pane: usize, role: Option<&str>, session: Option<&str>) ->
.to_string()
}

/// `{"ok":true,"target":<id>,"queued":<bool>}` — 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":<id>,"status":"<label>"}` — a single target's status.
pub fn reply_status_one(pane: usize, status: Option<&str>) -> String {
obj(vec![
("ok", Value::Bool(true)),
("pane", i(pane)),
("status", status.map(s).unwrap_or(Value::Null)),
])
.to_string()
}

/// One node in the org chart, as the run loop knows it.
pub struct TreeNode<'a> {
pub id: usize,
Expand Down Expand Up @@ -378,8 +495,27 @@ pub fn build_request(args: &[String], caller: Option<usize>) -> Result<String, S
Some("list") => {
pairs.push(("cmd", s("list")));
}
Some("send") => {
pairs.push(("cmd", s("send")));
let target = args
.get(1)
.filter(|t| !t.starts_with('-'))
.ok_or_else(|| "send needs a target (pane id or role)".to_string())?;
let text = args[2..].join(" ");
if text.trim().is_empty() {
return Err("send needs text after the target".to_string());
}
pairs.push(("target", Value::String(target.clone())));
pairs.push(("text", Value::String(text)));
}
Some("status") => {
pairs.push(("cmd", s("status")));
if let Some(target) = args.get(1).filter(|t| !t.starts_with('-')) {
pairs.push(("target", Value::String(target.clone())));
}
}
Some(other) => return Err(format!("unknown subcommand {other:?}")),
None => return Err("needs a subcommand: spawn | list".to_string()),
None => return Err("needs a subcommand: spawn | list | send | status".to_string()),
}
Ok(obj(pairs).to_string())
}
Expand Down Expand Up @@ -474,6 +610,83 @@ mod tests {
}
}

#[test]
fn build_and_parse_send_request() {
let line = build_request(&v(&["send", "dev_1", "implement", "X", "TDD"]), Some(0)).unwrap();
let req = parse_request(&line).unwrap();
assert_eq!(req.caller, Some(0));
match req.cmd {
Cmd::Send(sr) => {
assert_eq!(sr.target, "dev_1");
assert_eq!(sr.text, "implement X TDD");
}
_ => panic!("expected send"),
}
}

#[test]
fn send_without_text_is_an_error() {
let err = build_request(&v(&["send", "dev_1"]), None).unwrap_err();
assert!(err.contains("text"), "{err}");
}

#[test]
fn build_and_parse_status_request_with_and_without_target() {
let one = parse_request(&build_request(&v(&["status", "3"]), None).unwrap()).unwrap();
assert_eq!(
one.cmd,
Cmd::Status(StatusReq {
target: Some("3".into())
})
);
let all = parse_request(&build_request(&v(&["status"]), None).unwrap()).unwrap();
assert_eq!(all.cmd, Cmd::Status(StatusReq { target: None }));
}

#[test]
fn resolve_target_by_id_and_role() {
let panes = [
(0usize, Some("ceo".to_string())),
(1, Some("dev_1".to_string())),
(2, None),
];
assert_eq!(resolve_target("1", &panes), Ok(1));
assert_eq!(resolve_target("dev_1", &panes), Ok(1));
assert!(resolve_target("9", &panes).unwrap_err().contains("no pane"));
assert!(resolve_target("ghost", &panes)
.unwrap_err()
.contains("no pane"));
}

#[test]
fn resolve_target_flags_ambiguous_roles() {
let panes = [
(1usize, Some("dev".to_string())),
(2, Some("dev".to_string())),
];
assert!(resolve_target("dev", &panes)
.unwrap_err()
.contains("ambiguous"));
}

#[test]
fn in_subtree_walks_the_parent_chain() {
// 0 (root) → 1 (lead) → 2, 3 (ICs); 4 is a sibling lead's IC.
let parents = [
(0usize, None),
(1, Some(0)),
(2, Some(1)),
(3, Some(1)),
(4, Some(5)),
(5, Some(0)),
];
assert!(in_subtree(2, 1, &parents)); // IC is in its lead's subtree
assert!(in_subtree(1, 1, &parents)); // a pane is in its own subtree
assert!(!in_subtree(4, 1, &parents)); // a cousin is not
assert!(in_subtree(4, 0, &parents)); // everything is under the root
assert!(!in_subtree(1, 2, &parents)); // a parent is not under its child
}

#[test]
fn build_list_request() {
let line = build_request(&v(&["list"]), Some(3)).unwrap();
Expand Down
30 changes: 30 additions & 0 deletions src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,19 @@ impl Tree {
self.focus = new_id;
}

/// Split a *specific* pane `target` in `dir` (not necessarily the focused
/// one), giving the new half pane id `new_id`, and move focus to the new
/// pane. Returns `false` (a no-op) if `target` is not in the tree. This is
/// what lets `ctl spawn --here` tile a worker beside the pane that requested
/// it, even when that pane is not the window's current focus.
pub fn split_pane(&mut self, target: usize, dir: Dir, new_id: usize) -> bool {
let ok = Self::split_at(&mut self.root, target, dir, new_id);
if ok {
self.focus = new_id;
}
ok
}

fn split_at(node: &mut Node, target: usize, dir: Dir, new_id: usize) -> bool {
match node {
Node::Leaf(id) if *id == target => {
Expand Down Expand Up @@ -468,6 +481,23 @@ mod tests {
assert_eq!(back, top_left, "up+left should return to the top-left pane");
}

#[test]
fn split_pane_targets_a_specific_pane_not_just_focus() {
// Build [0 | 1] with focus on 1, then split pane 0 (the *unfocused* one).
let mut t = Tree::new(0);
t.split(Dir::Vertical, 1);
assert_eq!(t.focus(), 1);
assert!(t.split_pane(0, Dir::Vertical, 2));
// 0 became a split into {0, 2}; the tree now holds 0, 1, 2 and focus
// moved to the new pane.
let mut ids = t.ids();
ids.sort();
assert_eq!(ids, vec![0, 1, 2]);
assert_eq!(t.focus(), 2);
// Splitting a pane that isn't there is a no-op.
assert!(!t.split_pane(99, Dir::Vertical, 3));
}

#[test]
fn move_focus_is_a_noop_off_the_edge() {
let mut t = Tree::new(0);
Expand Down
Loading