Drain idle injection flushes before agent teardown, snapshot approval and subagent provider inputs, and gate subagent lifecycle events on real child readiness. Align the RFCs and generated contracts with the hardened behavior.
@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. run.started resolves after newSession publishes the remote session and rejects when initialization fails or cancellation wins first; the service emits no start/end pair for a child that never became live. 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 | process 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. |
disposeEofGraceMs |
number | 6000 |
Dispose ladder tier 1: how long the child gets to quiesce on its own after stdin EOF (flush persistence, tear down its nested subprocesses) before SIGTERM. |
disposeGraceMs |
number | 3000 |
Dispose ladder tier 2: grace between SIGTERM and the SIGKILL escalation. |
- 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
The child env is built by buildChildEnv from @deepseek-ai/dsh-subagent-subprocess — the ambient env minus credential-shaped vars, with config.env layered on top after the scrub; the pattern and full semantics live there. For this backend that means the parent harness's own secrets never leak into the spawned agent implicitly, while the child's OWN DEEPSEEK_API_KEY is supplied deliberately via config.env and survives.
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).