Coding-agent dispatch API
plugins/coding_agent — the ACP client library behind the acp delegate type (ADR 0024, ADR 0025): it launches a CLI coding agent (protoCLI, Claude Code, Codex, …) over the Agent Client Protocol and pools one client per launch+policy signature. Most plugins never call it — delegate_to (via the delegates plugin's AcpAdapter) is the normal path, and it returns the coder's reply as prose.
This page is for orchestrator plugins that need more than prose: the tapped-dispatch seam streams tool/thought/text callbacks live and returns the wire signals (usage, plan, stop reason, dead end) while still owning the whole client lifecycle — it exists so callers stop reaching into this package's private client pool. Callers holding a parsed Delegate reach the same seam through AcpAdapter.dispatch_tapped. Generated from plugins/coding_agent/__init__.py and plugins/coding_agent/acp_client.py.
TappedResult
One tapped coder turn — the reply plus the wire signals prompt()'s -> str contract cannot carry.
Returned by the public plugins.coding_agent.dispatch_tapped seam (#3235), which snapshots it off the client the moment the turn finishes — before teardown — so an orchestrator reads a per-turn value instead of racing another dispatch for pooled instance state. Frozen: it is a record of a turn that already happened.
| Field | Type | Meaning |
|---|---|---|
reply | str | The coder's accumulated answer text — the same string prompt() returns. |
usage | dict | None | Latest ACP-native context pressure ({used, size} tokens) from a usage_update, or None when the agent never sent one. NOT billable usage (the coder's own provider meters that) — see AcpClient.last_usage. |
plan | list | None | The coder's own live plan (its todo list: {content, status, priority} entries) from the last plan update, or None — not every coder plans. |
stop_reason | str | None | Why the turn ended, straight from ACP's session/prompt result (end_turn, refusal, max_tokens, …), or None when the agent reported none. |
dead_end | str | None | Why the turn is NOT worth retrying (refusal / cancelled), or None when a retry is sane — AcpClient.dead_end()'s answer, precomputed so the caller doesn't re-derive the classification (#2279). |
Functions
close_all
await close_all() -> boolReap EVERY cached ACP client + its subprocess tree — the shutdown hook so a server stop doesn't strand pooled delegate_to agents as init-reparented orphans (the leak that piled up to ~20 GB). Idempotent; returns True if any were closed.
dispatch_tapped
await dispatch_tapped(delegate, prompt: str, *, on_tool: ToolCallback | None = None, on_thought: ProgressCallback | None = None, on_text: ProgressCallback | None = None, on_plan: PlanCallback | None = None, timeout: float | None = None) -> TappedResultRun ONE fully-tapped coder turn against delegate and return a TappedResult.
The public seam for orchestrators that need more than delegate_to's prose reply — live callbacks while the coder works, and the wire signals (usage, plan, stop reason, dead end) when it stops. It exists so callers (the project board's build loop) stop reaching into this package's private client pool (#3235). One call owns the whole lifecycle:
- fresh, private session — the turn runs on its OWN single-shot client, never the pooled one, built with no persisted-session path: nothing to
session/load(a resumed thread would carry memory of a workdir whose contents may no longer exist — the disposable-worktree caller), nothing persisted for a later dispatch to resume. And because the delegate's pooleddelegate_toclient — possibly mid-turn — and its persisted thread are never touched, starting a tapped dispatch can never interrupt an in-flight ordinary dispatch of the same delegate. - permission policy — the delegate's by-kind resolver (ADR 0024) is rebuilt from the spec on every dispatch, honoring
permissions/allow_kinds/deny_kinds/permissions_ceiling. - callback forwarding —
on_toolreceives the structured tool start/end event dicts,on_thoughtthe coder's reasoning deltas,on_textthe answer-text deltas, andon_planthe coder's WHOLE current plan (a list of{content, status, priority}entries, replaced on every ACPplanupdate — claude-agent-acp emits one per TaskCreate/TaskUpdate), exactly asAcpClient.promptstreams them. All optional and best-effort: a raising callback never breaks the turn. - cancel kills the child —
asyncio.CancelledErrordrops the private handle and synchronously SIGKILLs the coder's whole process tree before re-raising, so stopping the caller stops the coder (no awaits on the cancellation path). A cancel that lands after the turn finished, mid-teardown, is covered too: the interrupted graceful close falls back to the same synchronous SIGKILL. - teardown on every exit — success or failure, the private client is dropped from the registry and its subprocess reaped; a tapped dispatch never leaves a child behind (and never evicts a client a pooled dispatch is using).
Args:
delegate— the dispatch target — a spec mapping (command+workdirrequired;name/permissions/env/… optional) or a delegate-shaped object such as the delegates plugin'sDelegatedataclass.prompt— the user turn to send.on_tool— async callback for structured tool start/end event dicts.on_thought— async callback for the coder's reasoning-text deltas.on_text— async callback for answer-text deltas.on_plan— async callback for the coder's live plan — the full, normalized entry list on every update (the last one also ridesTappedResult.plan).timeout— seconds to await the turn; defaults to the delegate'stimeout_s(else 600).
Raises AcpError on any transport/protocol failure — with the child already torn down either way.
evict_client
await evict_client(spec: dict) -> boolDrop the exact cached client for spec AND terminate its subprocess.
The dispatch/relaunch paths _CLIENTS.pop(...) on an AcpError only forget the handle, leaving the child to be reaped by GC. A caller that dispatches into a short-lived, per-call workdir (e.g. a disposable git worktree) needs a deterministic reap — otherwise each scoped workdir leaves its own AcpClient subprocess behind (the cache key includes workdir). This pops the cached client and awaits client.close() so the process actually dies. Returns True if a live client was closed; idempotent.
evict_clients
await evict_clients(spec: dict) -> boolTerminate every cached conversation variant for one configured delegate.
A delegate removal has the base configuration, not each per-call conversation_key. Match the stable launch/policy prefix and leave every unrelated delegate untouched. Idempotent and best-effort.
forget_session
await forget_session(spec: dict) -> boolForget the persisted ACP session for spec — evict the live client AND delete its saved session id — so the NEXT dispatch starts a fresh session/new instead of session/load-resuming the old thread.
The persisted session (#970) lets a dispatch reattach a prior thread, which is right when the same workdir keeps its contents across calls. But a caller that recreates the workdir fresh per attempt (the project_board loop's disposable git worktree) wants the opposite: a resumed thread would carry memory of a diff the wiped tree no longer has, so the coder thinks it's already done (→ no diff) or edits against stale assumptions. Calling this first keeps the coder's memory in step with the (empty) tree. Returns True if anything was cleared; idempotent.