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
39 changes: 16 additions & 23 deletions Sources/Mobile/AgentChat/AgentChatSessionRegistry+ChildRuns.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,20 @@ import Foundation

/// Child-run (subagent) bookkeeping from the parent session's hook events.
///
/// Two shapes exist on the wire. Claude spawns children through its spawn
/// tool, named `Task` before 2.x and `Agent` after (both are still on the
/// wire; see `isTaskSpawn`), so a child's life is bracketed by that tool's
/// `PreToolUse`/`PostToolUse` pair (the payload carries `description` and
/// `subagent_type`). Codex, pi and OMP emit dedicated
/// `SubagentStart`/`SubagentStop` events instead. Neither child ever runs
/// hooks of its own, so this bookkeeping is
/// the ONLY view cmux has of nested agents; the state machine in
/// `nextState(previous:event:)` deliberately keeps ignoring these events (a
/// child's lifecycle says nothing about whether the PARENT is working).
/// Claude opens children through its spawn tool's `PreToolUse`, named `Task`
/// before 2.x and `Agent` after (both are still on the wire; see
/// `isTaskSpawn`), and closes them on `SubagentStop`. Its spawn tool's
/// `PostToolUse` is ignored for Claude because a background spawn returns at
/// detach while the child keeps running. Codex, pi and OMP use their
/// dedicated `SubagentStart`/`SubagentStop` pair, with the FIFO fallback when
/// a request id is absent. Neither child ever runs hooks of its own, so this
/// bookkeeping is the ONLY view cmux has of nested agents; the state machine
/// in `nextState(previous:event:)` deliberately keeps ignoring these events
/// (a child's lifecycle says nothing about whether the PARENT is working).
///
/// Honest limits: a background spawn returns from `PostToolUse` immediately
/// while the child keeps running, so background children read as settled the
/// moment they detach; without per-child ids from the CLI, a `stop`/`Stop`
/// closes every open child (a stopped parent has no running foreground
/// children). Claude straddles both shapes rather than only the first: it
/// emits `SubagentStop` too (`CMUXCLI+ClaudeHookSettings` installs the hook,
/// `FeedEventClassifier` maps it), and `requestId` decodes only
/// `_opencode_request_id`, so for Claude both that event and the spawn
/// tool's `PostToolUse` fall through to the FIFO branch of `closeChild` and
/// one child closes two rows. With parallel spawns that settles a sibling
/// early; fixing it needs a correlation id or a per-source rule, not a
/// comment.
/// Honest limits: without per-child ids from the CLI, a dedicated stop event
/// can close the oldest open child, while a parent `stop`/`Stop` closes every
/// open child (a stopped parent has no running foreground children).
extension AgentChatSessionRegistry {
nonisolated static func applyChildRunEvent(
_ record: inout AgentChatSessionRecord,
Expand All @@ -40,7 +31,9 @@ extension AgentChatSessionRegistry {
label: taskLabel(from: event.toolInputJSON),
at: event.receivedAt
)
case .postToolUse where isTaskSpawn(event):
// Claude's PostToolUse arrives when a background spawn detaches. The
// child remains running until Claude emits SubagentStop.
case .postToolUse where isTaskSpawn(event) && event.source != "claude":
closeChild(&record, id: event.requestId, at: event.receivedAt)
case .subagentStart:
openChild(
Expand Down
68 changes: 59 additions & 9 deletions cmuxTests/AgentChatChildRunTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,13 @@ struct AgentChatChildRunTests {
tool: String? = nil,
input: String? = nil,
requestId: String? = nil,
source: String = "claude",
at seconds: TimeInterval
) -> WorkstreamEvent {
WorkstreamEvent(
sessionId: "sess",
hookEventName: name,
source: "claude",
source: source,
toolName: tool,
toolInputJSON: input,
requestId: requestId,
Expand All @@ -61,8 +62,14 @@ struct AgentChatChildRunTests {
event: event(.postToolUse, tool: "Task", requestId: "r1", at: 40)
)
#expect(rec.children.count == 1)
#expect(rec.children[0].isRunning)

AgentChatSessionRegistry.applyChildRunEvent(
&rec,
event: event(.subagentStop, requestId: "r1", at: 50)
)
#expect(!rec.children[0].isRunning)
#expect(rec.children[0].endedAt == Date(timeIntervalSince1970: 40))
#expect(rec.children[0].endedAt == Date(timeIntervalSince1970: 50))
}

@Test func nonTaskToolsDoNotCreateChildren() {
Expand All @@ -87,21 +94,26 @@ struct AgentChatChildRunTests {
&rec,
event: event(.postToolUse, tool: "Agent", requestId: "r9", at: 30)
)
#expect(rec.children[0].isRunning)
AgentChatSessionRegistry.applyChildRunEvent(
&rec,
event: event(.subagentStop, at: 40)
)
#expect(!rec.children[0].isRunning)
}

@Test func missingRequestIdClosesOldestOpenChild() {
var rec = record()
AgentChatSessionRegistry.applyChildRunEvent(
&rec, event: event(.preToolUse, tool: "Task", at: 10)
&rec, event: event(.preToolUse, tool: "Task", source: "codex", at: 10)
)
AgentChatSessionRegistry.applyChildRunEvent(
&rec, event: event(.preToolUse, tool: "Task", at: 20)
&rec, event: event(.preToolUse, tool: "Task", source: "codex", at: 20)
)
#expect(rec.children.filter(\.isRunning).count == 2)

AgentChatSessionRegistry.applyChildRunEvent(
&rec, event: event(.postToolUse, tool: "Task", at: 30)
&rec, event: event(.postToolUse, tool: "Task", source: "codex", at: 30)
)
#expect(rec.children.filter(\.isRunning).count == 1)
#expect(rec.children[0].endedAt != nil)
Expand All @@ -111,15 +123,53 @@ struct AgentChatChildRunTests {
@Test func subagentStartStopTrackChildren() {
var rec = record()
AgentChatSessionRegistry.applyChildRunEvent(
&rec, event: event(.subagentStart, requestId: "c1", at: 5)
&rec, event: event(.subagentStart, requestId: "c1", source: "codex", at: 5)
)
#expect(rec.children.count == 1)
AgentChatSessionRegistry.applyChildRunEvent(
&rec, event: event(.subagentStop, requestId: "c1", at: 25)
&rec, event: event(.subagentStop, requestId: "c1", source: "codex", at: 25)
)
#expect(rec.children[0].endedAt == Date(timeIntervalSince1970: 25))
}

@Test func claudeSubagentStopClosesFirstChildWithoutClosingSibling() {
var rec = record()
AgentChatSessionRegistry.applyChildRunEvent(
&rec, event: event(.preToolUse, tool: "Task", at: 10)
)
AgentChatSessionRegistry.applyChildRunEvent(
&rec, event: event(.preToolUse, tool: "Task", at: 20)
)

AgentChatSessionRegistry.applyChildRunEvent(
&rec, event: event(.postToolUse, tool: "Task", at: 30)
)
#expect(rec.children[0].isRunning)
#expect(rec.children[1].isRunning)

AgentChatSessionRegistry.applyChildRunEvent(
&rec, event: event(.subagentStop, at: 31)
)
#expect(rec.children[0].endedAt == Date(timeIntervalSince1970: 31))
#expect(rec.children[1].isRunning)
}

@Test func claudeBackgroundSpawnStaysRunningUntilSubagentStop() {
var rec = record()
AgentChatSessionRegistry.applyChildRunEvent(
&rec, event: event(.preToolUse, tool: "Task", at: 10)
)
AgentChatSessionRegistry.applyChildRunEvent(
&rec, event: event(.postToolUse, tool: "Task", at: 11)
)
#expect(rec.children[0].isRunning)

AgentChatSessionRegistry.applyChildRunEvent(
&rec, event: event(.subagentStop, at: 20)
)
#expect(!rec.children[0].isRunning)
}

@Test func stopClosesAllOpenChildren() {
var rec = record()
AgentChatSessionRegistry.applyChildRunEvent(
Expand All @@ -137,10 +187,10 @@ struct AgentChatChildRunTests {
@Test func settledChildrenPruneAfterRetention() {
var rec = record()
AgentChatSessionRegistry.applyChildRunEvent(
&rec, event: event(.preToolUse, tool: "Task", requestId: "a", at: 0)
&rec, event: event(.preToolUse, tool: "Task", requestId: "a", source: "codex", at: 0)
)
AgentChatSessionRegistry.applyChildRunEvent(
&rec, event: event(.postToolUse, tool: "Task", requestId: "a", at: 10)
&rec, event: event(.postToolUse, tool: "Task", requestId: "a", source: "codex", at: 10)
)
#expect(rec.children.count == 1)
// Any later event past the retention window prunes it.
Expand Down
4 changes: 2 additions & 2 deletions docs/custom-sidebars.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,8 +325,8 @@ with:
`cmux hooks omp|pi subagent-start|subagent-stop` with JSON
`{"session_id": "<parent session>", "agent_id": "<stable child id>",
"description": "<child label>"}`: start opens the child on the parent
record, stop closes it by `agent_id` (or the oldest running child when the
id is absent).
record, stop closes the oldest running child (FIFO). The `agent_id` field is
not read; only `_opencode_request_id` can correlate a stop event to a child.
- `tabs` (per workspace) — array of surfaces. Always: `id`, `title`,
`focused` (Bool), `pinned` (Bool). When available: `directory`, `branch` +
`dirty`, `ports` (array of Int).
Expand Down
Loading