Two findings on the ACP backend: Blocking: cancel() only sent session/cancel, so a child that ignores the notify or wedges the prompt left result hung forever — the model-facing tool awaits result before its finally disposes, so the parent cancellation hung and the child stayed alive, violating the SubagentRun.cancel() contract (result settles aborted). The result path now races the ACP drive against a cancelSettled promise that requestCancel resolves, so result settles aborted the instant a cancel is requested, regardless of the child. dispose() still kills+reaps the process. New MOCK_IGNORE_CANCEL mock mode (receives cancel, never resolves the prompt, never exits) drives a regression proven to hang without the race. Nit: the drive-path catch was an empty broad catch that discarded the error (AGENTS.md forbids). Because cancellation is now handled by the race arm, a rejection reaching the catch is always a genuine child-level error — bind it, flatten to error, and surface the original via a new AcpRunSpec.onError sink that the provider wires to ctx.logger.warn, so a real fault is preserved.
@deepseek-ai/dsh-subagent-acp
The out-of-process ACP subagent backend: runs each child agent in a spawned subprocess, driven over the Agent Client Protocol (ACP) as the client. Registers a SubagentProvider on ctx.subagents (the subagent seam), alongside the in-process -spawn/-fork backends — multiple backends coexist by name.
It is the direction-inverted twin of the server-side bridge in @deepseek-ai/dsh-acp: that package is the ACP agent (it answers initialize/newSession/prompt); this one is the ACP client (it calls them and implements the sessionUpdate/requestPermission callbacks). Point the configured command at the acp-agent example to "talk to our own process".
What it does
start(request) spawns the configured command, wraps its stdio in an ACP ClientSideConnection, and drives one session: initialize → newSession → prompt. The child's streamed agent_message_chunk text becomes the SubagentResult.output; the prompt's terminal StopReason maps to the stop reason. dispose() kills the subprocess and awaits its exit.
Fresh process per run. Each start spawns a new child, runs exactly one ACP session, and disposes it. Persistent-process pooling is a future optimization (see the RFC).
Unlike the in-process backends, the child does NOT share this cordis context — it is a separate process with its own session, model client, and tools. So this backend:
- injects only
subagents(noctx.agents); - advertises NO start-time capabilities (an out-of-process child can't enforce the parent's depth/tool-filter);
- ignores
request.parent.
Config
| Key | Type | Default | Notes |
|---|---|---|---|
providerName |
string | acp |
Registry name on ctx.subagents. |
command |
string | — (required) | The executable to spawn for each run (the child ACP agent). |
args |
string[] | [] |
Arguments passed to command. |
cwd |
string | parent cwd | Working directory for the child process and its ACP session. |
permission |
'allow' | 'reject' |
reject |
How to auto-answer the child's session/request_permission prompts. reject declines every prompt (answer cancelled); allow approves via the first allow-shaped option. The first cut surfaces no prompt to a human. |
env |
Record<string,string> | {} |
Extra env vars for the child (e.g. its own DEEPSEEK_API_KEY). Forwarded on top of a credential-scrubbed copy of the parent env, so an explicit key reaches the child while ambient secrets do not leak implicitly. |
- id: subagent-acp
name: '@deepseek-ai/dsh-subagent-acp'
config:
providerName: acp
command: node
args: ['--import', 'tsx', './packages/ui/acp-agent/src/bin.ts', './examples/acp-agent/cordis.yml']
permission: reject
env:
DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY
StopReason mapping
ACP StopReason → harness SubagentStopReason:
| ACP | harness |
|---|---|
end_turn |
completed |
max_tokens |
max-tokens |
refusal |
refusal |
cancelled |
aborted |
max_turn_requests |
error (no clean equivalent; the task did not finish) |
| (unknown) | error |
A spawn/transport/RPC failure resolves error (or aborted if a cancel was requested) — result never rejects on a child-level failure, per the seam contract.
Environment scrub
Credential-shaped ambient vars (/KEY|SECRET|TOKEN/i) are NOT forwarded to the child by default — the parent harness's own secrets must not leak into a spawned process implicitly. The child's OWN credentials are supplied explicitly via config.env, layered AFTER the scrub, so an intended DEEPSEEK_API_KEY survives while an incidental AWS_SECRET_ACCESS_KEY does not.
Testing
- Keyless (
subagent-acp.spec.ts): spawns a scripted mock ACP server subprocess (tests/mock-acp-server.ts) and drives it through the real backend over real ACP stdio — connection setup, client callbacks, the prompt round-trip, stop-reason mapping, cancellation (including the early-cancel race and a torn-pipe-after-cancel), permission auto-answer, and quiescent disposal. No model, no key. - With-key e2e (
subagent-acp.e2e.ts): the harness drives ITSELF — the backend spawns the realacp-agentexample process and a real model in that child answers a prompt and does real file work (verified on disk). Self-skips withoutDEEPSEEK_API_KEY.
TODO(acp-subagent-replay): snapshot-tier coverage of an ACP child is a separate replay shape (each child is its own PROCESS with its own single-agent replay, distinct from the in-process per-session keying), deferred — see the RFC.
Plugin export shape
Named name / inject / Config / apply, with no default export: the cordis Loader's unwrapExports does exports.default ?? exports, so a stray default would collapse the module to the bare function and drop the inject namespace (see docs/postmortem/0001).