Problem
ACP sessions spawned via sessions_spawn(runtime: "acp") never close the loop with the parent session. When an ACP turn completes (success or error), the parent that spawned or messaged the session has no way to learn:
- That the turn finished
- What the final assistant reply was
- Whether the turn errored
This contrasts with standard subagents, which have a full completion pipeline: lifecycle event → subagent registry → announce flow → parent re-entry.
The same gap exists for follow-up messages via sessions_send — the parent sends a message to an existing ACP session but never gets notified when the resulting turn completes.
Root Cause (traced in source)
Two things are missing from the ACP path:
1. No lifecycle event emission. Standard subagent runs go through runEmbeddedPiAgent, which emits emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } }). ACP turns go through tryDispatchAcpReply → acpManager.runTurn(), which transitions to idle/error state but never emits a lifecycle event. The event bus never learns the turn ended.
2. No subagent registry entry. spawnSubagentDirect calls registerSubagentRun() after dispatch. spawnAcpDirect does not. Without a registry entry, the announce pipeline (runSubagentAnnounceFlow) is never triggered.
The existing parent stream relay (acp-spawn-parent-stream.ts) already listens for lifecycle phase: "end" events per runId — it just never receives one from ACP. And it's only set up at spawn time, not for follow-up sessions_send calls.
Existing Community Work
There are 7 open issues and 6 open PRs addressing parts of this:
Issues
PRs (all open, no maintainer reviews)
Each PR tackles a piece independently. I'd like to propose a unified approach that addresses the full pipeline.
Proposed Approach: Plug ACP into the existing subagent completion pipeline
Rather than creating a parallel ACP-specific completion path, the cleanest fix is to make ACP emit the same signals that the existing subagent infrastructure already consumes. Three small, focused changes:
Change 1: Emit lifecycle events from ACP dispatch
File: src/auto-reply/reply/dispatch-acp.ts
After acpManager.runTurn() completes, emit the standard lifecycle event (phase: "end" or phase: "error"). This makes ACP turns visible to the same event bus that the subagent pipeline, parent stream relay, and control UI all consume.
Change 2: Register ACP spawned runs in subagent registry
File: src/agents/acp-spawn.ts
After successful ACP dispatch, call registerSubagentRun() with cleanup: "keep" (ACP sessions manage their own lifecycle) and expectsCompletionMessage: true. This enables the existing announce flow to handle ACP completion delivery.
Change 3: Enhance completion relay with assistant reply capture
File: src/agents/acp-spawn-parent-stream.ts
On phase: "end", read the last assistant reply from the child session (reusing readLatestSubagentOutput) and include it in the parent system event — not just "run completed in Xs."
Scoping: per-run, not per-session
The completion callback should be per-run (tied to a specific runId), not per-session. If the parent spawns a turn, it gets notified when that turn completes. If someone else messages the same ACP session, no callback to the original parent. This matches how subagents already work and is the natural scoping since a run ≈ a turn.
For sessions_send follow-ups: once Change 1 lands, the parent stream relay (or announce flow) would need to register a listener for the new runId. This could be handled by registering in dispatch-acp.ts for any ACP run where the session has a spawnedBy parent, or by extending sessions_send to set up a per-run callback when targeting ACP sessions.
Questions for Maintainers
-
Is plugging ACP into the existing subagent registry + announce pipeline the right approach? Or would you prefer a separate ACP-specific completion path? The subagent pipeline is mature and handles dedupe, retries, and multiple delivery modes — reimplementing that seems wasteful.
-
Should this be 2-3 small PRs or one cohesive PR? I'm leaning toward separate PRs (lifecycle events first, then registry, then relay enhancement) since each is independently useful and testable. But if you'd prefer one PR that tells the full story, that works too.
-
For sessions_send follow-ups: should the completion callback registration happen in the ACP dispatch path (automatic for any ACP turn with a parent) or in sessions_send (explicit, only when the sender opts in)?
-
Any concerns about registering ACP runs with cleanup: "keep"? ACP sessions have their own lifecycle management; the registry entry would be for announce/completion tracking only, not cleanup.
-
Relationship to the unified streaming refactor (docs/experiments/plans/acp-unified-streaming-refactor.md): These changes are compatible with the refactor plan and could be seen as incremental steps toward it. The lifecycle event emission (Change 1) is exactly what the refactor's canonical event contract calls for.
Happy to take feedback and adjust the approach before writing code.
Problem
ACP sessions spawned via
sessions_spawn(runtime: "acp")never close the loop with the parent session. When an ACP turn completes (success or error), the parent that spawned or messaged the session has no way to learn:This contrasts with standard subagents, which have a full completion pipeline: lifecycle event → subagent registry → announce flow → parent re-entry.
The same gap exists for follow-up messages via
sessions_send— the parent sends a message to an existing ACP session but never gets notified when the resulting turn completes.Root Cause (traced in source)
Two things are missing from the ACP path:
1. No lifecycle event emission. Standard subagent runs go through
runEmbeddedPiAgent, which emitsemitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } }). ACP turns go throughtryDispatchAcpReply→acpManager.runTurn(), which transitions toidle/errorstate but never emits a lifecycle event. The event bus never learns the turn ended.2. No subagent registry entry.
spawnSubagentDirectcallsregisterSubagentRun()after dispatch.spawnAcpDirectdoes not. Without a registry entry, the announce pipeline (runSubagentAnnounceFlow) is never triggered.The existing parent stream relay (
acp-spawn-parent-stream.ts) already listens for lifecyclephase: "end"events perrunId— it just never receives one from ACP. And it's only set up at spawn time, not for follow-upsessions_sendcalls.Existing Community Work
There are 7 open issues and 6 open PRs addressing parts of this:
Issues
PRs (all open, no maintainer reviews)
Each PR tackles a piece independently. I'd like to propose a unified approach that addresses the full pipeline.
Proposed Approach: Plug ACP into the existing subagent completion pipeline
Rather than creating a parallel ACP-specific completion path, the cleanest fix is to make ACP emit the same signals that the existing subagent infrastructure already consumes. Three small, focused changes:
Change 1: Emit lifecycle events from ACP dispatch
File:
src/auto-reply/reply/dispatch-acp.tsAfter
acpManager.runTurn()completes, emit the standard lifecycle event (phase: "end"orphase: "error"). This makes ACP turns visible to the same event bus that the subagent pipeline, parent stream relay, and control UI all consume.Change 2: Register ACP spawned runs in subagent registry
File:
src/agents/acp-spawn.tsAfter successful ACP dispatch, call
registerSubagentRun()withcleanup: "keep"(ACP sessions manage their own lifecycle) andexpectsCompletionMessage: true. This enables the existing announce flow to handle ACP completion delivery.Change 3: Enhance completion relay with assistant reply capture
File:
src/agents/acp-spawn-parent-stream.tsOn
phase: "end", read the last assistant reply from the child session (reusingreadLatestSubagentOutput) and include it in the parent system event — not just "run completed in Xs."Scoping: per-run, not per-session
The completion callback should be per-run (tied to a specific
runId), not per-session. If the parent spawns a turn, it gets notified when that turn completes. If someone else messages the same ACP session, no callback to the original parent. This matches how subagents already work and is the natural scoping since a run ≈ a turn.For
sessions_sendfollow-ups: once Change 1 lands, the parent stream relay (or announce flow) would need to register a listener for the newrunId. This could be handled by registering indispatch-acp.tsfor any ACP run where the session has aspawnedByparent, or by extendingsessions_sendto set up a per-run callback when targeting ACP sessions.Questions for Maintainers
Is plugging ACP into the existing subagent registry + announce pipeline the right approach? Or would you prefer a separate ACP-specific completion path? The subagent pipeline is mature and handles dedupe, retries, and multiple delivery modes — reimplementing that seems wasteful.
Should this be 2-3 small PRs or one cohesive PR? I'm leaning toward separate PRs (lifecycle events first, then registry, then relay enhancement) since each is independently useful and testable. But if you'd prefer one PR that tells the full story, that works too.
For
sessions_sendfollow-ups: should the completion callback registration happen in the ACP dispatch path (automatic for any ACP turn with a parent) or insessions_send(explicit, only when the sender opts in)?Any concerns about registering ACP runs with
cleanup: "keep"? ACP sessions have their own lifecycle management; the registry entry would be for announce/completion tracking only, not cleanup.Relationship to the unified streaming refactor (
docs/experiments/plans/acp-unified-streaming-refactor.md): These changes are compatible with the refactor plan and could be seen as incremental steps toward it. The lifecycle event emission (Change 1) is exactly what the refactor's canonical event contract calls for.Happy to take feedback and adjust the approach before writing code.