acpmux: acpmux new|run --parent starts a child of any harness under a chat

Merged
TE
12d ago
+677-32

The ask

claude-sr2's queue, item 2: "acpmux run --parent": a cross-family subagent primitive. A chat (any harness, e.g. Claude) starts a child agent of another family (codex, opencode, …) from its shell with acpmux run --parent <SESSION> ... or acpmux new --parent <SESSION> .... The child then belongs to that parent chat the way a native sub-session does.

Lane xfamily-agents, word for word: "I'm rendering cross-family subagents (codex exec / acpmux run --parent) as Subagent cards in cmux NEXT's transcript + Session Info. What's the event shape your 'acpmux run/new --parent' primitive emits to the parent chat (field names for parent link, harness, model/effort, title/prompt, state running/done/failed, typed result, child session id), and what is the PR number? I can't receive replies directly, so please put the shape in your PR body; I'll read it from there."

What changed

It is built on the sub-session mechanism that already exists. There is no second one.

  • acpmux new|run --parent SESSION (src/cli/run/parent.rs) sends _acpmux/subsession/start, the call the start_session MCP tool already makes, with parent, harness/model (from -m), effort (-e), name (-n), cwd and the first prompt (prompt words, --prompt-file, --report-to). The child is a real sub-session: it has SessionMeta.subsession {startedBy, shortName, depth}, it counts in the parent's children, its startedBy/spawnedBy name the parent, it lists under the parent's chat, and the parent's sub-session tools (get_session, send_message, wait_for_sessions, stop_session) work on it. run (and new without -d) waits for the child's first turn to end and prints its reply (--json: the typed result below), and exits 1 when the turn did not complete. new -d only starts it. Flags a sub-session takes no part of are refused with a reason: --host, --preset, --policy, --mode, --ephemeral, --worktree, --reuse, --group, --prompt-id, --on-permission.

  • Who may name a parent (server/subsessions.rs start_parent). These are the sub-session tools' own checks; there are no new trust rules:

    • a proven caller (the ACPMUX_SESSION_TOKEN connection the CLI already opens from an agent's shell) names itself, or a descendant it may write to (Hub::subsession_write_target, the check send_message and stop_session use). Leaving parent out means the caller, as before.
    • the person (the app's proof) names any session on this daemon.
    • any other connection is refused with subsessions.caller_required. The child runs its parent's policy and mode, so naming a parent would widen what it may do.

    Everything after that is start_subsession unchanged: width/depth/running limits, the folder trust walk, and the cross-family mode guard. A child of another family starts only under a parent in full access, or is moved to an asking mode or closed.

  • effort is a new optional field of _acpmux/subsession/start. It goes to the child's session/new as for acpmux new -e.

  • What the parent chat receives: the subsession mux record the parent already gets when a child closes or reopens, with new fields and one new event value (turn_ended). There is no new event family (hub/subsession_results.rs). It applies to every sub-session, however it was started, so the cmux NEXT sub-session UI (session list startedBy/spawnedBy, the started-sessions card) shows the child with no UI work.

  • started is written when the child's first prompt is accepted (the acceptance callback, before the turn can end), so a child that never runs leaves no started. turn_ended is written where every turn's end is recorded (Hub::record_last_turn: a finished, failed, unstarted, quit or unknown-outcome turn), synchronously and with no log read. Each turn gets exactly one, in order.

  • Docs: docs/agent-surfaces.md gets a row, and skills/acpmux/SKILL.md gets two lines.

Event shape for the parent chat

Where it is emitted: on the parent session's log, as a record with dir: "mux" and kind: "subsession" (RECORD_KIND). Clients get it live as an _acpmux/event notification {sessionId: <parent>, seq, dir: "mux", kind: "subsession", msg: {...}} on an attached parent chat, and in _acpmux/attach pages. Its change kind is subsession. There are two events per child turn cycle (msg.event):

started: once, when the child's first prompt is accepted, and always before that turn's turn_ended. The state is running.

{
  "event": "started",
  "childSessionId": "01a16c8c-0000-7000-8000-00000000c0de",
  "startedBy": "01a16c8c-0000-7000-8000-0000000000aa",
  "shortName": "fix-the-flaky-test",
  "depth": 1,
  "harness": "codex",
  "model": "gpt-5.5",
  "effort": "high",
  "title": "lead/fix-the-flaky-test",
  "prompt": "Fix the flaky test in tests/foo.rs",
  "state": "running"
}

turn_ended: exactly once at the end of each of the child's turns, in order, with the typed result.

{
  "event": "turn_ended",
  "childSessionId": "01a16c8c-0000-7000-8000-00000000c0de",
  "startedBy": "01a16c8c-0000-7000-8000-0000000000aa",
  "shortName": "fix-the-flaky-test",
  "depth": 1,
  "harness": "codex",
  "model": "gpt-5.5",
  "effort": "high",
  "title": "Fix flaky foo test",
  "state": "done",
  "result": {
    "state": "done",
    "turnId": "t-01a16c8d-...",
    "stopReason": "end_turn",
    "reply": "Fixed: the test raced the watcher; it now waits for the first event.",
    "error": null
  }
}

A failed turn:

{"event": "turn_ended", "childSessionId": "…", "startedBy": "…", "shortName": "…", "depth": 1,
 "harness": "codex", "model": "gpt-5.5", "effort": "high", "title": "…", "state": "failed",
 "result": {"state": "failed", "turnId": "…", "stopReason": null, "reply": null,
            "error": "simulated internal error"}}
What you asked forFieldExample / values
parent linkstartedBy (the parent's session id; the record is also on that parent's log), and depth"01a1…00aa", 1
child session idchildSessionId"01a1…c0de"
harnessharness (the profile name the child runs)"codex", "opencode"
modelmodel: the child's current model, else the one the start asked for, else null"gpt-5.5"
efforteffort: the same, for thinking effort"high"
title / prompttitle (the child's title, else its session name), shortName (its name among the parent's children), prompt (started only: the first prompt, masked, at most 500 characters)"lead/fix-the-flaky-test", "Fix the flaky…"
statestate: running on started; done, failed or cancelled on turn_ended (cancelled = the turn was stopped)"done"
typed resultresult on turn_ended: {state, turnId, stopReason, reply, error}. reply is that turn's agent text (masked; its last 2000 bytes, led by … when cut; null if it said nothing). error is the failed turn's message (masked) or nullsee above

The same child also shows in the session list (_acpmux/sessions, each row): startedBy, spawnedBy (the same parent id), shortName, depth and harness on the child, and children (open count) on the parent, with its live lane.state. The record's existing events stay as they were: closed, reopened ({event, childSessionId, shortName}) and limits.

acpmux run --parent … --json prints the typed result of the child's first turn: {sessionId, name, parent, harness, state, result: {state, turnId, stopReason, reply, error}}. Here reply is the child's whole last message, masked and not cut, as acpmux last reads it. A turn that is not done also ends the command with exit 1 and the error envelope on stderr.

Overlap

  • #24150 (lineage as a field, _meta.acpmux.parent on session/new) is orphaned: its owner is not alive (pr-owner who 24150). This PR doesn't touch session/new or lineage. It uses the existing SubsessionLink, which #24150 derives lineage from (Hub::lineage_of), so a child started here gets #24150's parent/root/depth with no change if #24150 lands. The request field is named parent there too.
  • #24365 (a lane's turn ends are reported to its starter as prompts) is orphaned too. This PR prompts nothing: the parent's log gets records, and its agent reads them with its sub-session tools, or run --parent prints the reply in its shell.

Tests

  • tests/hub_integration/run_parent.rs uses fake agents of two harness names and families: lead (family fake) and coder (family opencode).
    • a_chat_starts_a_child_of_another_family_from_its_shell: the parent's shell (its caller token) starts a coder child with parent and effort. The parent's children is 1. The child's startedBy/spawnedBy name the parent, with depth 1 and harness coder. The parent's log gets started (state running, with harness, effort, prompt and link), then turn_ended (state done, result.reply echo: hello, stopReason end_turn). A second child whose turn fails gives turn_ended with state failed and result.error.
    • only_the_parents_own_caller_or_the_person_names_it: a session the caller is not family of is refused (subsessions.not_family). A connection that proves nothing is refused (subsessions.caller_required). The person starts a child under any chat.
    • Red on base (the test-only commit): both tests fail at db83fa0 (run). The started record has no state, and a caller naming another session as parent is not refused: base ignores the field and starts the child under the caller.
    • The same test checks that the parent's log holds exactly started then turn_ended for the child, and that a child stopped mid-turn (session/cancel) ends cancelled.
  • Unit tests: cli/run/parent.rs (the start params from -m/-e/-n, refused flags) and hub/subsession_results.rs (turn end states, a cut reply drops its cut first word before masking).
  • Green at 2ce15411b (the merged head), full crate: cargo test -p acpmux --no-fail-fast over every test binary (run). hub_integration 387 passed, plus every sub-session suite and the new lib tests. Clippy (--all-targets) and rustfmt are clean. The crash ratchet adds no acpmux hit.
  • Reds that are also red on base origin/feat-cmux-next, none of them mine (base run): daemon_chat_open::a_long_chat_opens_on_its_newest_page… and remote_guard_codex_bridge (2 tests) fail the same way on base. session_pool::pooled_sessions_end_with_the_daemon_and_after_a_crash is flaky: it failed 5 of 6 runs at base, and 1 of 3 at this head.

Review

Codex (gpt-6-astra, xhigh), an independent reviewer:

  • Round 1 at ba56f2c: BLOCK. 1: unmasked or uncapped text. 2: started before acceptance. 3: a spawned, out-of-order turn_ended read the log. 4: the quit path was missed. 5: the unstarted turn's error key. 6: --on-permission wait.
  • Fixes in 4b216e1: started is written from the acceptance callback. turn_ended is written synchronously in record_last_turn (every turn-end path, once, in order, with the turn's own text from memory), and its text is masked. The CLI masks its output and drops process::exit (the crash ratchet counts it). On 6: wait is what a child does, so it is accepted on purpose.
  • Round 2 at 4b216e1: BLOCK. 1: the cut-preview edge cases. 2: the CLI's error fallback. 3: the synchronous parent append. Fixes in ea233bf: a cut preview (1997 to 2000 bytes) drops its first word, and the unstarted turn records errorText. On 3: kept synchronous, because it gives exactly-once and ordered records. It is O(1) per turn end, like the existing synchronous subsession_status parent append.
  • Round 3 at 2ce15411b7e2ee2e64cc87ef586b71f0b2612837: SHIP, no findings ("Reviewed head: 2ce15411b7e2ee2e64cc87ef586b71f0b2612837").

CI reds at this head that are also red on base (feat-cmux-next push runs 40153433745 and 40153262766): defaults-snapshot, release-notary, Guard status, and cmux-next checks. The two compile jobs failed at "Set up runner" (infra). The crash ratchet and fast gates pass.

Stop conditions

subsession_turn_ended runs inline at each turn's end: it appends one record and spawns nothing. A session that is not a sub-session costs one lock read. acpmux run --parent makes one _acpmux/wait request, which ends when the child's turn ends, at --timeout (exit 3), or when the connection closes. It adds no timer or observer.

No capture: daemon and CLI only. Nothing the app draws changes: the child lists under its parent through the startedBy/spawnedBy it already reads.

Agent session: big-red
🤖 Generated with Claude Code