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
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ enum AgentSessionWorkspace {
// The Chief host's own acpmux runs the session (`chief:<home id>`); absent: this Mac's.
let host = invocation["host"]?.stringValue.flatMap { $0.isEmpty ? nil : $0 }
let group = invocation["group"]?.stringValue.flatMap { $0.isEmpty ? nil : $0 }
if let group, !validGroupName(group) {
throw ActionFailure(message: "agent.openSessionWorkspace: group must be a non-empty human label, not a cmux id; omit group or use a label such as \"GUI push\"")
}
let task = try open(session: session, name: name, key: key, cwd: cwd, host: host, group: group, services: context.services)
context.services.registry.track(task)
}
Expand Down Expand Up @@ -151,6 +154,18 @@ enum AgentSessionWorkspace {
sidebar.groupFlow.newGroup(of: ids, name: name, byUser: false)
}

/// Sidebar group arguments are labels. Object ids are rejected so an
/// automation retry cannot turn an id into a persistent visible title.
static func validGroupName(_ name: String) -> Bool {
let label = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !label.isEmpty else { return false }
let lower = label.lowercased()
if ["grp_", "ws_", "tab_", "pane_", "win_", "workspace_group:"].contains(where: lower.hasPrefix) {
return false
}
return UUID(uuidString: label) == nil
}

/// The first listed group named `name` that may hold workspace `id` (the drag's rule,
/// `SidebarEdits.canMove`: a machine row's group holds that machine's workspaces), in any section and
/// either organization; nil when none may. Only the first group of that name was tried before, so a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,11 @@ import Testing
])]
#expect(AgentSessionWorkspace.predecessor(named: "pane-motion", inGroup: Self.lanes, in: sections) == nil)
}

@Test func groupNamesRejectObjectIdsAndBlankLabels() {
for name in ["", " ", "grp_012345", "ws_012345", "550e8400-e29b-41d4-a716-446655440000"] {
#expect(!AgentSessionWorkspace.validGroupName(name), "\(name)")
}
#expect(AgentSessionWorkspace.validGroupName("GUI push"))
}
}
18 changes: 12 additions & 6 deletions cmux-tui/crates/acpmux/docs/agent-surfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ by who is calling, not by what is possible.

| You are | Start an agent for a task | Talk to it, wait | Show it to the person |
| --- | --- | --- | --- |
| A script, a person, or an agent with a shell | `acpmux new -n NAME --worktree REPO[@BASE] --prompt-file F --report-to ME --group G -d` | `acpmux send`, `acpmux wait`, `acpmux last` | `--group G` on the same command (runs `cmux agent open`) |
| A script, a person, or an agent with a shell | `acpmux new -n NAME --worktree REPO[@BASE] --prompt-file F --report-to ME -d` | `acpmux send`, `acpmux wait`, `acpmux last` | Optional `--group G` on the same command (runs `cmux agent open`) |
| An agent inside acpmux that wants a child of another harness, from its shell | `acpmux run --parent $ACPMUX_SESSION_ID -m codex PROMPT` (prints its reply), or `acpmux new --parent ... -d` | as below: it is the same sub-session | it lists under the parent's chat |
| An agent inside acpmux that wants a child (`cmux-subsessions` MCP) | `start_session {prompt, name, worktree: true}` | `send_message`, `wait_for_sessions`, `get_session` | `app_agent_open {session, group}` through the cmux MCP |
| The cmux app or an agent with the cmux MCP | the app's own New Chat, or one of the two above | the chat pane | `app_agent_open` (`cmux agent open SESSION [--group G]`), or a tab of a pane: `app_agent_open_tab` (`cmux agent open-tab SESSION --target PANE`) |
Expand Down Expand Up @@ -48,11 +48,17 @@ acpmux new -n gui/composer \
every other run of characters becomes one `-`, at most 48 characters
(`Gui/Composer` is session `gui-composer`, folder `gui-composer`, branch
`cmux/gui-composer`); the name as given is the sidebar title.
- **Sidebar.** `--group G` runs `cmux agent open SESSION --group G` with a
workspace key derived from the session id, so reopening the same session
finds its row. Use `--name NAME --reuse` to reuse a launch; an unnamed launch
creates a new session each time. A failed sidebar step warns; the prompt is
still sent. It needs the cmux app on this machine. With `--host HOST`, the
- **Sidebar (opt-in).** `--group G` is a persistent app-side effect: it runs
`cmux agent open SESSION --group G` with a workspace key derived from the
session id, and places the workspace in the named sidebar group (made when
missing). Use a human label, never a `grp_…`/`ws_…`/UUID object id; omit it
unless the person asked for placement. Reopening the same session finds its
row. Use `--name NAME --reuse` to reuse a launch; an unnamed launch creates a
new session each time. A failed sidebar step warns; the prompt is still
sent. To recover, run `cmux --json workspace group list`, then use
`cmux workspace group remove --workspace <WORKSPACE>` or
`cmux workspace group <GROUP> delete` for an unwanted group.
It needs the cmux app on this machine. With `--host HOST`, the
returned session ID is mirrored locally, so the workspace opens here while
the agent runs there. A wrapper executing acpmux on HOST must omit `--group`
there and run `cmux agent open ID --group G` on the controller Mac afterward.
Expand Down
14 changes: 11 additions & 3 deletions cmux-tui/crates/acpmux/skills/acpmux/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,21 @@ acpmux session kill --purge NAME # delete (only sessions you c
`send` on a busy or blocked session never refuses: the prompt queues and the
output says `queued behind …`.

Start an agent for a task (its own worktree, its brief, shown in the app) with
one command; running it again reuses the session and worktree and sends nothing:
Start an agent for a task (its own worktree and brief) with one command; running
it again reuses the session and worktree and sends nothing:

```bash
acpmux new -n NAME --worktree REPO[@BASE] --prompt-file BRIEF.md --report-to ME --group "GROUP" -d
acpmux new -n NAME --worktree REPO[@BASE] --prompt-file BRIEF.md --report-to ME -d
```

`--group "GROUP"` is optional person-facing app state. It runs `cmux agent
open`, creates a workspace and places it in the named cmux sidebar group (and
may create that group); it does not move focus. Use a human label only, never a
`grp_…`, `ws_…` or other object id. Omit it unless the person asked for this
sidebar placement. To recover from an unwanted placement, inspect
`cmux --json workspace group list`, then move/delete the affected workspace or
group with `cmux workspace group …`; rerunning without `--group` is safe.

A child of another harness under your own chat (a Codex child of a Claude chat): it lists
under your chat, and your sub-session tools read, message and stop it.

Expand Down
37 changes: 37 additions & 0 deletions cmux-tui/crates/acpmux/src/cli/run/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ impl Launch {
}
};
let file_prompt = args.prompt_file.as_deref().map(read_prompt_file).transpose()?;
if let Some(group) = &args.group {
validate_group(group)?;
}
if args.reuse && name.is_none() {
bail!("--reuse needs --name: the session to reuse is found by it");
}
Expand Down Expand Up @@ -151,6 +154,23 @@ pub(crate) fn session_name(name: &str) -> Result<String> {
Ok(out)
}

/// A group is a person's label, not a cmux object id. Rejecting ids before
/// `session/new` keeps a bad launcher from creating persistent sidebar junk.
pub(crate) fn validate_group(group: &str) -> Result<()> {
let label = group.trim();
let lower = label.to_ascii_lowercase();
let id_prefix = ["grp_", "ws_", "tab_", "pane_", "win_", "workspace_group:"]
.iter()
.any(|prefix| lower.starts_with(prefix));
let uuid = uuid::Uuid::parse_str(label).is_ok();
if label.is_empty() || id_prefix || uuid {
bail!(
"--group must be a non-empty human label, not a cmux id; use e.g. `--group \"GUI push\"`, or omit --group"
);
}
Ok(())
}

/// `REPO[@BASE]`: the last `@` splits off the base (a ref or commit),
/// unless the whole of it is a folder.
fn split_worktree(spec: &str) -> (&str, Option<&str>) {
Expand Down Expand Up @@ -474,6 +494,23 @@ mod tests {
assert!(session_name("//").is_err());
}

#[test]
fn a_group_is_a_human_label_and_never_an_object_id() {
for group in [
"",
" ",
"grp_012345",
"ws_012345",
"workspace_group:42",
"550e8400-e29b-41d4-a716-446655440000",
] {
let error = validate_group(group).unwrap_err().to_string();
assert!(error.contains("human label"), "{group:?}: {error}");
assert!(error.contains("omit --group"), "{group:?}: {error}");
}
assert!(validate_group("GUI push").is_ok());
}

#[test]
fn a_remote_session_can_join_a_group_without_an_explicit_name() {
let prepared = launch(&[
Expand Down
Loading