@deepseek-ai/dsh-subagent-acp 0.1.1-rc.2 → 0.1.2-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/subagent/subagent-acp/README.md
5
- README.md: 3bccddbca021bed1f8bf5766b9575f3bd7441669
6
- README.zh.md: 7ae89ece0ce4282ad5b9a20142a2ba9b111f6d88
5
+ README.md: 185a06a4003386a18414998e9cbaa314b9599921
6
+ README.zh.md: 1594677f2ecdacd4ca389337fb29bd01ff18a108
package/README.md CHANGED
@@ -1,73 +1,131 @@
1
+ ---
2
+ description: "The out-of-process ACP subagent backend for users and maintainers choosing a delegation provider, configuring a child ACP agent command, or debugging remote child runs."
3
+ kind: "package-reference"
4
+ ---
5
+
1
6
  # @deepseek-ai/dsh-subagent-acp
2
7
 
3
8
  English | [中文](README.zh.md)
4
9
 
5
- The ACP provider runs each subagent in a fresh subprocess and drives it as an Agent Client Protocol client. It is the out-of-process alternative to spawn and fork: the child has its own runtime, session, model configuration, and tools.
10
+ ## Summary
6
11
 
7
- ## Start and ownership
12
+ `dsh-subagent-acp` runs each delegated child in a fresh subprocess and drives it as an Agent Client Protocol client: the child gets its own runtime, session, model configuration, and tools, and it can be any ACP-compatible agent, not just Harness. It is the out-of-process alternative to the in-process spawn and fork backends, sharing only the parent session's working directory with the child. Each run spawns a fresh process, initializes an ACP session, sends the task, and collects the streamed final answer; permission prompts are auto-answered by configuration, so no human is needed. The parent receives only the child's final answer or a safe error — no intermediate messages or tool traffic crosses the boundary. Choose it when the child must be fully isolated from the parent harness and can speak ACP.
8
13
 
9
- `start(request)` resolves the child's working directory, then performs `spawn` → ACP `initialize` → `newSession` before it fulfills. Fulfillment therefore means a remote session is ready and ownership has transferred to the caller. A spawn, initialization, new-session, or pre-publication cancellation failure rejects only after the subprocess has been reaped; a working-directory resolution failure rejects before anything is spawned.
14
+ ## Table of Contents
10
15
 
11
- The working directory is the configured `cwd` override when set, else the delegating parent session's cwd — never the server process's own cwd, because one server process serves sessions from many workspaces. The parent-derived value must be an absolute path naming a directory the harness can enter (search permission — what a subprocess cwd needs), and the same resolved path becomes both the subprocess cwd and the ACP `session/new` workspace.
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
12
22
 
13
- The returned run id is minted in the parent namespace. The child server's session id remains private to ACP wire calls because ACP guarantees it only within that fresh child process; using it as the parent lifecycle id could collide with another remote run or a local agent.
23
+ -----
14
24
 
15
- After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation.
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
16
27
 
17
- `dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then invoke the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL — Windows force-terminates directly) and await the subprocess owner's whole-tree exit proof. Every run uses a fresh process; process pooling is not implemented.
28
+ Mount this provider when a composition needs a fully isolated, out-of-process child that speaks the Agent Client Protocol. The common path is explicit: mount the seam, mount this provider, and give it a command that starts an ACP agent.
18
29
 
19
- ## Capabilities and context
30
+ ### When to choose it
20
31
 
21
- ACP advertises no start-time capabilities because this process cannot enforce the remote child's depth, tool filter, persona, or structured-output runtime. It also reports `inheritsParentContext: false`: the remote session starts fresh, and the only parent-derived input is the workspace cwd described above no conversation context crosses the process boundary.
32
+ Choose this backend when the child must run with its own runtime, model, and tools in a separate process for example an ACP agent from another project or when you want delegation that cannot touch the parent harness. Choose an in-process backend when the child must share the parent's composition or honor parent-enforced capabilities: this provider advertises no optional start-time capabilities, so the seam rejects requests for `agentOptions`, structured output, depth caps, tool filters, or personas rather than silently omitting them.
22
33
 
23
- ## Configuration
34
+ ### Configuration
24
35
 
25
- | Key | Default | Meaning |
36
+ | Field | Default | Meaning |
26
37
  |---|---|---|
27
- | `providerName` | `acp` | Registry name on `ctx.subagents`. |
28
- | `command` | required | Executable spawned for each run. |
29
- | `args` | `[]` | Command arguments. |
30
- | `cwd` | parent session cwd | Working-directory override for the child process and its ACP session; must be non-empty, a relative value resolves against the harness launch directory at load, and the result must name a directory the harness can enter. |
31
- | `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first `allow_once` or `allow_always` option. |
32
- | `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. |
33
- | `disposeEofGraceMs` | `6000` | Positive grace after stdin EOF before platform termination; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). |
34
- | `disposeGraceMs` | `3000` | Positive POSIX grace after SIGTERM before SIGKILL (Windows force-terminates directly); it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). |
38
+ | `providerName` | `acp` | Registry name on `ctx.subagents` |
39
+ | `command` | required | Executable spawned for each run (the child ACP agent) |
40
+ | `args` | `[]` | Command arguments |
41
+ | `cwd` | parent session cwd | Working-directory override for the child process and its ACP session |
42
+ | `permission` | `reject` | Auto-answer permission requests by rejecting, or choosing the first `allow_once` or `allow_always` option (`allow`) |
43
+ | `env` | `{}` | Explicit child environment layered over the credential-scrubbed parent environment |
44
+ | `disposeEofGraceMs` | `6000` | Grace after stdin EOF before platform termination |
45
+ | `disposeGraceMs` | `3000` | Bound for observing structured process facts after failure and, on POSIX, the SIGTERM-to-SIGKILL grace |
46
+
47
+ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-subagent-acp) is the exhaustive source for every accepted field and its JSDoc.
48
+
49
+ A DeepSeek Harness child uses the product launcher and an explicit absolute `DSH_HOME`. The isolated home prevents a nested runtime from discovering the launching person's profiles or credentials; the generic ACP provider does not impose this requirement on non-DSH agents.
35
50
 
36
51
  ```yaml
37
52
  - id: subagent-acp
38
53
  name: '@deepseek-ai/dsh-subagent-acp'
39
54
  config:
40
55
  providerName: acp
41
- command: node
42
- args: ['--import', 'tsx', './packages/examples/acp-demo/src/bin.ts', '--config', './examples/acp-agent/cordis.yml']
56
+ command: dsh
57
+ args: ['--profile', 'acp', '--patch', '/absolute/path/to/acp.patch.yml']
43
58
  permission: reject
44
59
  env:
60
+ DSH_HOME: /absolute/path/to/isolated-child-home
45
61
  DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY
46
62
  ```
47
63
 
48
- ## Stop-reason mapping
64
+ ### What you get
65
+
66
+ A successful run returns the child's final streamed assistant text as the result output. The child's session, model, and tools come from the child process itself — the parent supplies only the task and the working directory. The stop reason maps `end_turn` to `completed`, `max_tokens` to `max-tokens`, `refusal` to `refusal`, `cancelled` to `aborted`, and every other value to `error`. A failed published run preserves partial assistant text in `output` and returns safe structured detail separately in `diagnostic`.
67
+
68
+ ### Failure and recovery
69
+
70
+ A spawn, initialization, or new-session failure rejects before publication, ordinarily after the child process is reaped. If cleanup also fails, the rejection preserves ordered safe startup and teardown facts without claiming whole-tree quiescence. Non-cancellation errors expose only fixed provider, stage, and category facts; the original failure stays on the internal cause chain and in Host diagnostics. After publication, a prompt, transport, or early-process failure resolves as `error` with a safe diagnostic, while local cancellation resolves as `aborted` without failure detail.
71
+
72
+ ### Safe diagnostics
73
+
74
+ A generic diagnostic uses one fixed line: `Subagent failure (provider: ACP; stage: <stage>; category: <category>; ...)`. Optional stop reason, exit code, and signal come only from closed protocol or managed-process facts. Stderr, exception text, task content, tool input, paths, environment values, credentials, and protocol payloads never enter the diagnostic; the shared result boundary limits it to 4096 UTF-8 bytes. A non-completed run that requested permission can add one fixed policy, tool-kind, and decision line. Successful runs and local cancellation omit it.
75
+
76
+ -----
77
+
78
+ <a id="understand-the-implementation"></a>
79
+ ## Understand the implementation
80
+
81
+ <details>
82
+ <summary>Implementation internals — click to expand</summary>
83
+
84
+ This section explains how the backend drives a child over ACP and where the observable behavior comes from; the full contract lives in [Use this package](#use-this-package).
85
+
86
+ ### Design concept
87
+
88
+ - **Full process isolation.** Each child runs in a fresh subprocess with its own session, model, and tools; only the resolved working directory crosses from the parent.
89
+ - **One process per run.** Every run spawns a new process; there is no process pooling.
90
+ - **The ACP wire is the serialization boundary.** Same-process subagent values are not defensively cloned; the protocol is where hostile input is validated.
91
+
92
+ ### Start and ownership flow
49
93
 
50
- | ACP | Harness |
51
- |---|---|
52
- | `end_turn` | `completed` |
53
- | `max_tokens` | `max-tokens` |
54
- | `refusal` | `refusal` |
55
- | `cancelled` | `aborted` |
56
- | `max_turn_requests` or unknown | `error` |
94
+ A start resolves the child's working directory (the configured `cwd` override, else the parent session's cwd), spawns the command through the subprocess seam, performs the ACP `initialize` and `newSession` handshake, and only then publishes the run. Fulfillment means a remote session is ready and ownership has transferred to the caller. Disposal is idempotent: it closes stdin and waits a configured grace for cooperative quiescence, then escalates through SIGTERM to SIGKILL and awaits whole-tree exit. Cleanup failures remain observable as ordered safe facts and never claim quiescence.
57
95
 
58
- ## Process boundary
96
+ ### Stop-reason mapping
59
97
 
60
- The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables and ambient `DSH_*` names are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives, and a `DSH_*` deployment fact such as `DSH_PERMISSION_MODE` reaches the child the same way — the scrub drops only its stale ambient namesake), stderr is inherited to the parent's own stream, and disposal applies this plugin's EOF window before the subprocess-owned SIGTERM→SIGKILL escalation and whole-tree join. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned.
98
+ The run outcome maps the ACP terminal into the shared stop-reason vocabulary (`completed`, `max-tokens`, `refusal`, `aborted`, or `error`) in [`src/run.ts`](src/run.ts).
61
99
 
62
- The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md).
100
+ ### Process boundary
63
101
 
102
+ The child spawns through the subprocess seam: credential-shaped ambient variables are scrubbed, then explicit `config.env` values merge after the scrub. Stderr is inherited to the parent's stream, and disposal applies this provider's EOF window before the shared termination escalation.
103
+
104
+ </details>
105
+
106
+ -----
107
+
108
+ <a id="further-exploration"></a>
109
+ ## Further Exploration
110
+
111
+ Read these pages when the package-level contract is not enough. They move from this backend to the seam it plugs into and the protocol it drives.
112
+
113
+ - [Subagent subsystem](../../../docs/subsystems/subagent.md) — the service contract, provider contract, and terminal result semantics.
114
+ - [dsh-subagent seam](../subagent/README.md) — the registry and start API this provider registers on.
115
+ - [Agent Client Protocol automation server](../../acp/acp/README.md) — the automation-only server this provider drives as a client.
116
+ - [dsh-subprocess seam](../../subprocess/subprocess/README.md) — the process-spawn and teardown machinery behind each run.
117
+ - [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-subagent-acp) — every accepted config field and its source declaration.
118
+
119
+ -----
120
+
121
+ <a id="model-experience"></a>
64
122
  ## Model Experience
65
123
 
66
124
  ### Child-agent request
67
125
 
68
126
  #### What the model sees
69
127
 
70
- The remote child receives the standalone task content through ACP plus its own process's configured system prompt, tools, and fresh session. It receives no parent conversation. This provider advertises no optional start-time capabilities, so the local service rejects requests for persona, tool filtering, depth enforcement, or structured output instead of silently omitting them.
128
+ The remote child receives the standalone task content through ACP plus its own process's configured system prompt, tools, and fresh session. It receives no parent conversation. This provider advertises no optional start-time capabilities, so the local service rejects requests for `agentOptions`, persona, tool filtering, depth enforcement, or structured output instead of silently omitting them.
71
129
 
72
130
  #### Token effect
73
131
 
@@ -81,7 +139,7 @@ Independent of the parent request cache. Each ACP child can reuse only prefixes
81
139
 
82
140
  #### What the model sees
83
141
 
84
- Through `dsh-tool-subagent`, the parent receives only the child's final streamed assistant text or that consumer's exact stop-reason error, not intermediate messages or tool traffic. A request already cancelled before publication becomes exactly `Error: subagent request was aborted before the ACP child started`; other start failures pass through as `Error: <message>`.
142
+ Through `dsh-tool-subagent`, the parent receives only the child's final streamed assistant text or that consumer's exact stop-reason error, not intermediate messages or tool traffic. Non-completed results present the safe diagnostic before separately preserved partial assistant output. A request already cancelled before publication becomes exactly `Error: subagent request was aborted before the ACP child started`; another start failure contains only the fixed `Subagent failure (...)` line.
85
143
 
86
144
  #### Token effect
87
145
 
@@ -93,8 +151,27 @@ Append-only; newly visible content follows the reusable request prefix and does
93
151
 
94
152
  ## Known Limitations and Deferred Work
95
153
 
96
- - **A fresh process per run** — persistent-process pooling is a future optimization ([the seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)).
97
- - **Local workspaces only** — the resolved cwd is a local path handed to a child on the same machine; workspace mapping for a remote ACP agent would need its own backend capability and is not designed here.
98
- - **No optional start-time capabilities** — this provider cannot apply the local harness's `outputSchema`, depth cap, tool filter, or persona inside the remote process, so it advertises none and the service rejects requests that require them.
154
+ <a id="known-limitations-and-deferred-work"></a>
155
+
156
+
157
+ These limits define when this backend is a poor fit or needs special operational care. They are current package constraints, not a general ACP comparison or a task backlog.
158
+
159
+ - **A fresh process per run** — there is no process pooling; each delegation pays the full spawn and ACP handshake cost.
160
+ - **Local workspaces only** — the resolved working directory is a local path handed to a child on the same machine; remote workspace mapping is not designed.
161
+ - **No optional start-time capabilities** — this provider cannot apply `agentOptions`, `outputSchema`, a depth cap, a tool filter, or a persona inside the remote process, so the seam rejects requests that require them.
99
162
  - **Only committed `agent_message_chunk` text is collected** — the automation server keeps reasoning, tool activity, plans, and other trace data in the child session log rather than emitting them on ACP.
100
163
  - **Permission prompts are auto-answered** (`permission: allow | reject`) — no human is surfaced a child's `session/request_permission`.
164
+
165
+ <a id="dev-note"></a>
166
+ ### Dev Note
167
+
168
+ <details>
169
+ <summary>Working context for maintainers — click to expand</summary>
170
+
171
+ This Dev Note is working context for maintainers: open questions and undecided directions. It is explicitly non-authoritative — shipped behavior and limits live in the sections above and in the package code.
172
+
173
+ - **Process pooling** — persistent-process reuse is a possible future optimization but changes the per-run isolation model.
174
+ - **Remote workspaces** — mapping a remote ACP agent's workspace would need its own backend capability.
175
+ - **Continuable ACP children** — would require persisting the remote session id and a per-child continuation advertisement.
176
+
177
+ </details>
package/README.zh.md CHANGED
@@ -1,73 +1,131 @@
1
+ ---
2
+ description: "面向用户与维护者的进程外 ACP subagent 后端,用于选择委派提供方、配置子 ACP agent 命令或排查远程子 agent 运行问题。"
3
+ kind: "package-reference"
4
+ ---
5
+
1
6
  # @deepseek-ai/dsh-subagent-acp
2
7
 
3
8
  [English](README.md) | 中文
4
9
 
5
- ACP(Agent Client Protocol)提供方会在全新的子进程中运行每个 subagent,并作为 Agent Client Protocol 客户端驱动它。这是 spawn 与 fork 的进程外替代方案:子 agent(智能体)拥有自己的运行时、会话、模型配置和工具。
10
+ ## 概述
6
11
 
7
- ## 启动与所有权
12
+ `dsh-subagent-acp` 在全新的子进程中运行每个被委派的子 agent,并作为 Agent Client Protocol 客户端驱动它:子 agent(智能体)拥有自己的运行时、会话、模型配置和工具,可以是任何兼容 ACP 的 agent,而不只是 Harness。它是进程内 spawn 与 fork 后端的进程外替代方案,只与子 agent 共享父会话的工作目录。每次运行都会 spawn 全新进程、初始化 ACP 会话、发送任务并收集流式最终答案;权限提示由配置自动应答,因此无需人工参与。父级只收到子 agent 的最终答案或安全错误——中间消息与工具流量不会跨越边界。当子 agent 必须与父 harness 完全隔离且能说 ACP 时,选择它。
8
13
 
9
- `start(request)` 先解析子 agent 的工作目录,再依次执行 `spawn` → ACP `initialize` → `newSession`,然后才兑现。因此,兑现表示远程会话已就绪,所有权也已转移给调用方。spawn 失败、初始化失败、新建会话失败或因发布前取消而失败时,只有在子进程已回收后才会拒绝;工作目录解析失败则会在尚未 spawn 任何进程时拒绝。
14
+ ## 目录
10
15
 
11
- 工作目录优先使用已配置的 `cwd` 覆盖值,否则使用执行委派的父会话 cwd,绝不使用服务器进程自身的 cwd,因为同一个服务器进程会服务来自多个工作区的会话。从父级取得的值必须是绝对路径,指向 harness 可以进入的目录(具备搜索权限,这是子进程 cwd 的要求);解析后的同一路径同时作为子进程 cwd 和 ACP `session/new` 工作区。
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
12
22
 
13
- 返回的运行 id 在父级命名空间中生成。子服务器的会话 id 只用于 ACP 协议调用,因为 ACP 只保证它在该全新子进程中唯一;若将其用作父级生命周期 id,可能与另一个远程运行或本地 agent 冲突。
23
+ -----
14
24
 
15
- 发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败会以 `stopReason: 'error'` 兑现;如果必需的请求信号或 dispose(资源释放)请求了取消,则以 `aborted` 兑现。
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
16
27
 
17
- `dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后使用该 seam 定义的操作运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作式完全停稳,再触发句柄的 `terminate()` 升级(SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),并等待子进程责任方给出整棵进程树的退出证明。每次运行都使用全新进程;尚未实现进程池。
28
+ 当组合需要一个说 Agent Client Protocol 的完全隔离进程外子 agent 时,挂载本提供方。常用路径是显式的:挂载 seam、挂载本提供方,并给出一个启动 ACP agent 的命令。
18
29
 
19
- ## 能力与上下文
30
+ ### 何时选择
20
31
 
21
- ACP 不声明任何启动时能力,因为当前进程无法强制执行远程子 agent 的深度、工具过滤、persona 或结构化输出运行时。它也报告 `inheritsParentContext: false`:远程会话从全新状态开始,唯一源自父级的输入是上述工作区 cwd;对话上下文不会跨越进程边界。
32
+ 当子 agent 必须在独立进程中运行、拥有自己的运行时、模型和工具时选择此后端——例如来自其他项目的 ACP agent——或者你希望委派完全无法触及父 harness 时。当子 agent 必须共享父级组合或遵守父级强制的能力时,请选择进程内后端:本提供方不声明任何可选启动时能力,因此 seam 会拒绝要求 `agentOptions`、结构化输出、深度上限、工具过滤或 persona 的请求,而不是静默省略。
22
33
 
23
- ## 配置
34
+ ### 配置
24
35
 
25
- | | 默认值 | 含义 |
36
+ | 字段 | 默认值 | 含义 |
26
37
  |---|---|---|
27
- | `providerName` | `acp` | `ctx.subagents` 上的注册表名称。 |
28
- | `command` | 必填 | 每次运行时 spawn 的可执行文件。 |
29
- | `args` | `[]` | 命令参数。 |
30
- | `cwd` | 父会话 cwd | 子进程及其 ACP 会话的工作目录覆盖值;不得为空。相对值会在加载时以 harness 启动目录为基准解析,结果必须指向 harness 可以进入的目录。 |
31
- | `permission` | `reject` | 自动回答权限请求:拒绝,或选择第一个 `allow_once` 或 `allow_always` 选项。 |
32
- | `env` | `{}` | 显式子进程环境,叠加到已清理凭据的父进程环境之上。 |
33
- | `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 |
34
- | `disposeGraceMs` | `3000` | POSIX SIGTERM 后、SIGKILL 前的宽限时间(Windows 直接强制终止),须为正值且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 |
38
+ | `providerName` | `acp` | `ctx.subagents` 上的注册表名称 |
39
+ | `command` | 必填 | 每次运行时 spawn 的可执行文件(子 ACP agent) |
40
+ | `args` | `[]` | 命令参数 |
41
+ | `cwd` | 父会话 cwd | 子进程及其 ACP 会话的工作目录覆盖值 |
42
+ | `permission` | `reject` | 自动应答权限请求:拒绝,或选择第一个 `allow_once` 或 `allow_always` 选项(`allow`) |
43
+ | `env` | `{}` | 叠加在已清理凭据的父环境之上的显式子环境 |
44
+ | `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限 |
45
+ | `disposeGraceMs` | `3000` | 失败后观察结构化进程事实的时限;在 POSIX 上也是 SIGTERM SIGKILL 的宽限 |
46
+
47
+ 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-subagent-acp)是每个受支持字段及其 JSDoc 的穷尽式真源。
48
+
49
+ DeepSeek Harness 子进程使用产品启动器和一个显式的绝对路径 `DSH_HOME`。隔离 home 可防止嵌套 runtime 发现启动者个人的 profile 或凭据;通用 ACP provider 不会把这一要求强加给非 DSH agent。
35
50
 
36
51
  ```yaml
37
52
  - id: subagent-acp
38
53
  name: '@deepseek-ai/dsh-subagent-acp'
39
54
  config:
40
55
  providerName: acp
41
- command: node
42
- args: ['--import', 'tsx', './packages/examples/acp-demo/src/bin.ts', '--config', './examples/acp-agent/cordis.yml']
56
+ command: dsh
57
+ args: ['--profile', 'acp', '--patch', '/absolute/path/to/acp.patch.yml']
43
58
  permission: reject
44
59
  env:
60
+ DSH_HOME: /absolute/path/to/isolated-child-home
45
61
  DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY
46
62
  ```
47
63
 
48
- ## 结束原因映射
64
+ ### 你会得到什么
65
+
66
+ 成功的运行会把子 agent 最终的流式 assistant 文本作为结果输出返回。子 agent 的会话、模型与工具来自子进程自身——父级只提供任务与工作目录。停止原因把 `end_turn` 映射为 `completed`、`max_tokens` 映射为 `max-tokens`、`refusal` 映射为 `refusal`、`cancelled` 映射为 `aborted`,其余值映射为 `error`。已发布运行失败时,部分 assistant 文本保留在 `output`,安全的结构化详情则单独放在 `diagnostic`。
67
+
68
+ ### 失败与恢复
69
+
70
+ spawn、初始化或新建会话失败会在发布前拒绝,通常先等待子进程被回收。如果清理也失败,拒绝会保留有序、安全的启动与拆卸事实,但不会声称整棵进程树已经停稳。非取消错误只暴露固定的提供方、阶段与类别事实;原始失败保留在内部 cause 链与 Host 诊断中。发布后,提示词、传输或进程提前退出会以携带安全诊断的 `error` 结算;本地取消则以不带失败详情的 `aborted` 结算。
71
+
72
+ ### 安全诊断
73
+
74
+ 通用诊断使用固定的一行:`Subagent failure (provider: ACP; stage: <stage>; category: <category>; ...)`。可选的停止原因、退出码与信号只来自封闭协议或受管进程事实。stderr、异常文本、任务内容、工具输入、路径、环境值、凭据与协议载荷绝不会进入诊断;共享结果边界把诊断限制在 4096 个 UTF-8 字节内。请求过权限且未完成的运行可以增加一行固定的策略、工具种类与决定。成功运行和本地取消不包含该行。
75
+
76
+ -----
77
+
78
+ <a id="understand-the-implementation"></a>
79
+ ## 理解实现
80
+
81
+ <details>
82
+ <summary>实现细节——点击展开</summary>
83
+
84
+ 本节解释后端如何经 ACP 驱动子 agent,以及可观察行为从何而来;完整约定见[使用本包](#use-this-package)。
85
+
86
+ ### 设计理念
87
+
88
+ - **完全进程隔离。** 每个子 agent 在全新子进程中运行,拥有自己的会话、模型与工具;只有解析后的工作目录从父级跨越。
89
+ - **每次运行一个进程。** 每次运行都 spawn 新进程;没有进程池。
90
+ - **ACP 协议格式(wire format)是序列化边界。** 同进程 subagent 值不会为防御目的克隆;协议才是校验不可信输入的地方。
91
+
92
+ ### 启动与所有权流程
49
93
 
50
- | ACP | Harness |
51
- |---|---|
52
- | `end_turn` | `completed` |
53
- | `max_tokens` | `max-tokens` |
54
- | `refusal` | `refusal` |
55
- | `cancelled` | `aborted` |
56
- | `max_turn_requests` 或未知值 | `error` |
94
+ 一次启动先解析子 agent 的工作目录(配置的 `cwd` 覆盖值,否则取父会话 cwd),经子进程 seam spawn 命令,完成 ACP `initialize` `newSession` 握手,然后才发布运行。兑现意味着远程会话已就绪、所有权已转移给调用方。dispose(资源释放)是幂等的:先关闭 stdin 并按配置的宽限等待协作式完全停稳,再经 SIGTERM 升级到 SIGKILL,并等待整棵进程树退出。清理失败会作为有序的安全事实保持可观察,且绝不声称已经完全停稳。
57
95
 
58
- ## 进程边界
96
+ ### 停止原因映射
59
97
 
60
- 子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) seam spawn:共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 会继承到父进程自身的流,dispose 则先应用本插件的 EOF 时间窗,再由子进程责任方执行 SIGTERM→SIGKILL 升级并等待整棵进程树退出。ACP 协议格式(wire format)是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。
98
+ 运行结果会把 ACP 终态映射进共享的停止原因词汇(`completed`、`max-tokens`、`refusal`、`aborted` `error`),实现见 [`src/run.ts`](src/run.ts)。
61
99
 
62
- 本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘(postmortem)0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md)。
100
+ ### 进程边界
63
101
 
102
+ 子进程经子进程 seam spawn:先清除疑似凭据的环境变量,再合并显式 `config.env` 值。stderr 继承到父级流,dispose 先应用本提供方的 EOF 窗口,再执行共享的逐级终止。
103
+
104
+ </details>
105
+
106
+ -----
107
+
108
+ <a id="further-exploration"></a>
109
+ ## 进一步探索
110
+
111
+ 当包级约定不够用时阅读以下页面。它们从本后端逐步进入它接入的 seam 与它驱动的协议。
112
+
113
+ - [Subagent 子系统](../../../docs/subsystems/subagent.zh.md)——服务约定、提供方约定与终态结果语义。
114
+ - [dsh-subagent seam](../subagent/README.zh.md)——本提供方注册于其上的注册表与启动 API。
115
+ - [Agent Client Protocol 自动化服务器](../../acp/acp/README.zh.md)——本提供方作为客户端驱动的仅自动化服务器。
116
+ - [dsh-subprocess seam](../../subprocess/subprocess/README.zh.md)——每次运行背后的进程 spawn 与清理机制。
117
+ - [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-subagent-acp)——每个受支持配置字段及其源声明。
118
+
119
+ -----
120
+
121
+ <a id="model-experience"></a>
64
122
  ## 模型体验
65
123
 
66
124
  ### 子 agent 请求
67
125
 
68
- #### 模型看到的内容
126
+ #### 模型看到什么
69
127
 
70
- 远程子 agent 通过 ACP 接收独立任务内容,并使用其自身进程配置的系统提示词、工具和全新会话。它不接收父级对话。该提供方不声明任何可选启动时能力,因此本地服务会拒绝要求 persona、工具过滤、深度强制或结构化输出的请求,而不是静默省略这些要求。
128
+ 远程子 agent 通过 ACP 接收独立任务内容,并使用其自身进程配置的系统提示词、工具和全新会话。它不接收父级对话。本提供方不声明可选启动时能力,因此本地服务会拒绝要求 `agentOptions`、persona、工具过滤、深度强制或结构化输出的请求,而不是静默省略。
71
129
 
72
130
  #### Token 影响
73
131
 
@@ -79,22 +137,41 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程
79
137
 
80
138
  ### 父级工具结果(间接)
81
139
 
82
- #### 模型看到的内容
140
+ #### 模型看到什么
83
141
 
84
- 通过 `dsh-tool-subagent`,父级只接收子 agent 最终的流式 assistant 文本,或该消费方给出的精确结束原因错误;不接收中间消息或工具流量。发布前已经取消的请求会精确变为 `Error: subagent request was aborted before the ACP child started`;其他启动失败按原样传递为 `Error: <message>`。
142
+ 通过 `dsh-tool-subagent`,父级只接收子 agent 最终的流式 assistant 文本或该消费方给出的精确停止原因错误,不接收中间消息或工具流量。未完成的结果会先呈现安全诊断,再单独保留部分 assistant 输出。发布前已经取消的请求会精确变为 `Error: subagent request was aborted before the ACP child started`;其他启动失败只包含固定的 `Subagent failure (...)` 行。
85
143
 
86
144
  #### Token 影响
87
145
 
88
- 父级输入只增加最终结果或错误,其内容依赖数据,并保留到压缩(compaction)为止。该提供方自身不会添加父级 schema。
146
+ 父级输入只增加最终结果或错误,其内容依赖数据,并保留到压缩(compaction)为止。本提供方自身不会添加父级 schema。
89
147
 
90
148
  #### KV Cache 影响
91
149
 
92
150
  仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
93
151
 
94
- ## 已知限制与暂缓事项
152
+ ## 已知限制与延期工作
153
+
154
+ <a id="known-limitations-and-deferred-work"></a>
155
+
156
+
157
+ 这些限制说明本后端何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用 ACP 对比或任务积压。
158
+
159
+ - **每次运行使用全新进程**——没有进程池;每次委派都要付出完整的 spawn 与 ACP 握手成本。
160
+ - **仅支持本地工作区**——解析后的工作目录是交给同一台机器上子进程的本地路径;远程工作区映射尚未设计。
161
+ - **不支持可选启动时能力**——本提供方无法在远程进程内应用 `agentOptions`、`outputSchema`、深度上限、工具过滤器或 persona,因此 seam 会拒绝需要它们的请求。
162
+ - **只收集已提交的 `agent_message_chunk` 文本**——自动化服务器把推理(reasoning)、工具活动、计划和其他 trace 数据保留在子 agent 会话日志中,不通过 ACP 发出。
163
+ - **权限提示自动应答**(`permission: allow | reject`)——不会把子 agent 的 `session/request_permission` 呈现给人。
164
+
165
+ <a id="dev-note"></a>
166
+ ### 开发备注
167
+
168
+ <details>
169
+ <summary>维护者的工作上下文——点击展开</summary>
170
+
171
+ 本开发备注是维护者的工作上下文:开放问题与尚未决定的探索方向。它明确不具权威性——已交付的行为与限制以上文和包代码为准。
172
+
173
+ - **进程池**——持久进程复用是可能的未来优化,但会改变每次运行的隔离模型。
174
+ - **远程工作区**——映射远程 ACP agent 的工作区需要独立的后端能力。
175
+ - **可继续 ACP 子级**——需要持久化远程会话 id 与逐子级的继续执行能力声明。
95
176
 
96
- - **每次运行使用全新进程**:持久进程池属于后续优化(见 [seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md))。
97
- - **仅支持本地工作区**:解析后的 cwd 是交给同一台机器上子进程的本地路径;远程 ACP agent 的工作区映射需要独立的后端能力,此处尚未设计这种能力。
98
- - **不支持可选启动时能力**:该提供方无法在远程进程内应用本地 harness 的 `outputSchema`、深度上限、工具过滤器或 persona,因此不会声明这些能力;服务会拒绝需要它们的请求。
99
- - **只收集已提交的 `agent_message_chunk` 文本**:自动化服务器把推理(reasoning)、工具活动、计划和其他 trace 数据保留在子 agent 会话日志中,不通过 ACP 发出。
100
- - **权限提示自动回答**(`permission: allow | reject`):不会把子 agent 的 `session/request_permission` 呈现给人。
177
+ </details>
package/lib/index.js CHANGED
@@ -4,23 +4,76 @@ import z from "@deepseek-ai/schemastery";
4
4
  import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { Readable, Writable } from "node:stream";
7
- import { ClientSideConnection, PROTOCOL_VERSION, ndJsonStream } from "@agentclientprotocol/sdk";
8
- import { SessionId } from "@deepseek-ai/dsh-session";
9
- import { AssistantOutputFold } from "@deepseek-ai/dsh-subagent";
7
+ import { PROTOCOL_VERSION, client, methods, ndJsonStream } from "@agentclientprotocol/sdk";
8
+ import { brandString } from "@deepseek-ai/dsh-brand";
9
+ import { AssistantOutputFold, settleRunResult, subprocessRunHandle } from "@deepseek-ai/dsh-subagent";
10
10
  //#region lib/types/run.js
11
11
  /**
12
12
  * Fresh-process ACP subagent client. Drives one child session and owns cancellation and
13
13
  * quiescent disposal.
14
14
  *
15
- * TODO(acp-subagent-replay): add snapshot-tier coverage with a separate replay fixture and
16
- * sessions root inside each child process. Current keyless coverage uses a scripted ACP child;
17
- * with-key coverage drives the real ACP example.
18
15
  * @module @deepseek-ai/dsh-subagent-acp/run
19
16
  */
20
17
  /** EOF grace for child flush and nested-process teardown; wider than the signal grace below. */
21
18
  const DEFAULT_DISPOSE_EOF_GRACE_MS = 6e3;
22
19
  /** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */
23
20
  const DEFAULT_DISPOSE_GRACE_MS = 3e3;
21
+ const ACP_TOOL_KINDS = new Set([
22
+ "read",
23
+ "edit",
24
+ "delete",
25
+ "move",
26
+ "search",
27
+ "execute",
28
+ "think",
29
+ "fetch",
30
+ "switch_mode",
31
+ "other"
32
+ ]);
33
+ /** Fixed safe failure text derived only from provider-owned structured facts. */
34
+ function failureDiagnostic(facts) {
35
+ const fields = [
36
+ "provider: ACP",
37
+ `stage: ${facts.stage}`,
38
+ `category: ${facts.category}`
39
+ ];
40
+ if (facts.stopReason !== void 0) fields.push(`stop reason: ${facts.stopReason}`);
41
+ if (facts.outcome?.exitCode !== null && facts.outcome?.exitCode !== void 0) fields.push(`exit code: ${facts.outcome.exitCode}`);
42
+ /* v8 ignore next -- Windows does not report POSIX child signals in SubprocessOutcome. */
43
+ if (facts.outcome?.signal !== null && facts.outcome?.signal !== void 0) fields.push(`signal: ${facts.outcome.signal}`);
44
+ return `Subagent failure (${fields.join("; ")})`;
45
+ }
46
+ /** Fixed permission fact; ACP tool titles and option text never enter it. */
47
+ function permissionDiagnostic(permission) {
48
+ return `ACP unattended decision (policy: ${permission.policy}; request: ${permission.request}; decision: ${permission.decision})`;
49
+ }
50
+ /** Put the operation failure first, followed by the latest permission decision. */
51
+ function diagnosticText(facts, permission) {
52
+ const failure = failureDiagnostic(facts);
53
+ return permission === void 0 ? failure : `${failure}\n${permissionDiagnostic(permission)}`;
54
+ }
55
+ var AcpRunFailure = class extends Error {
56
+ constructor(facts, cause) {
57
+ super(`subagent-acp: ${failureDiagnostic(facts)}`, { cause });
58
+ this.name = "AcpRunFailure";
59
+ }
60
+ };
61
+ /**
62
+ * Hide a pre-spawn workspace/configuration failure behind fixed safe facts.
63
+ * @param cause - original Host failure retained on the Error cause chain.
64
+ * @returns an Error whose message contains only the fixed ACP failure line.
65
+ */
66
+ function acpConfigurationFailure(cause) {
67
+ return new AcpRunFailure({
68
+ stage: "initialize",
69
+ category: "configuration"
70
+ }, cause);
71
+ }
72
+ /** Keep only the closed ACP tool-kind vocabulary; future values use a fixed fallback. */
73
+ function permissionRequestKind(kind) {
74
+ const candidate = kind ?? "unknown";
75
+ return ACP_TOOL_KINDS.has(candidate) ? candidate : "unknown";
76
+ }
24
77
  /** Bounded whole-tree exit wait: polls the handle's tree liveness until it exits or `ms` elapses. */
25
78
  async function treeExitsWithin(child, ms) {
26
79
  const controller = new AbortController();
@@ -94,10 +147,57 @@ function toError(value) {
94
147
  /* v8 ignore next */
95
148
  return value instanceof Error ? value : new Error(String(value));
96
149
  }
150
+ /** Report an original Host failure without letting the observation sink replace it. */
151
+ function reportFailure(spec, error) {
152
+ try {
153
+ spec.onError?.(toError(error), "error");
154
+ } catch {}
155
+ }
156
+ /** Classify an unpublished failure from the active protocol operation and observed process facts. */
157
+ function startupFailure(error, stage, child, outcome) {
158
+ if (child.pid <= 0) return new AcpRunFailure({
159
+ stage: "process",
160
+ category: "process-start"
161
+ }, error);
162
+ return new AcpRunFailure(
163
+ /* v8 ignore next -- Windows anonymous pipes cannot expose a live-child protocol close during startup. */
164
+ outcome === void 0 ? {
165
+ stage,
166
+ category: "transport"
167
+ } : {
168
+ stage,
169
+ category: "process-exit",
170
+ outcome
171
+ },
172
+ error
173
+ );
174
+ }
175
+ /** Map one remote terminal reason to the optional safe failure line it needs. */
176
+ function terminalFailure(reason, permission) {
177
+ switch (reason) {
178
+ case "end_turn": return;
179
+ case "max_turn_requests": return diagnosticText({
180
+ stage: "prompt",
181
+ category: "remote-limit",
182
+ stopReason: "max_turn_requests"
183
+ }, permission);
184
+ case "max_tokens":
185
+ case "refusal":
186
+ case "cancelled": return permission === void 0 ? void 0 : permissionDiagnostic(permission);
187
+ default: return diagnosticText({
188
+ stage: "prompt",
189
+ category: "unknown",
190
+ stopReason: "unknown"
191
+ }, permission);
192
+ }
193
+ }
97
194
  /**
98
195
  * Start and publish one ACP child after initialization and session creation.
99
- * Child failures resolve through the run result; startup failures reject after
100
- * process reap. Disposal cancels, kills, and reaps the child.
196
+ * Child failures resolve through the run result. Startup rejects with fixed
197
+ * safe facts after provider-owned cleanup; successful cleanup proves process
198
+ * reap. Cleanup failure preserves startup plus teardown facts for an ordinary
199
+ * failure, or teardown alone after cancellation, without claiming quiescence.
200
+ * Disposal cancels, kills, and reaps the child.
101
201
  * @param request - the start request; its signal is the cancellation channel.
102
202
  * @param spec - the resolved spawn spec: command/args/cwd, env, permission
103
203
  * policy, dispose graces, and the optional error sink.
@@ -105,50 +205,95 @@ function toError(value) {
105
205
  */
106
206
  async function startAcpRun(request, spec) {
107
207
  if (request.signal.aborted) throw new Error("subagent request was aborted before the ACP child started");
108
- const id = SessionId(randomUUID());
109
- const child = spec.spawn({
110
- argv: [spec.command, ...spec.args],
111
- cwd: spec.cwd,
112
- stdio: {
113
- stdin: "pipe",
114
- stdout: "pipe",
115
- stderr: "inherit"
116
- },
117
- graceMs: spec.disposeGraceMs,
118
- env: spec.env
119
- });
208
+ const id = brandString(randomUUID());
209
+ let child;
210
+ try {
211
+ child = spec.spawn({
212
+ argv: [spec.command, ...spec.args],
213
+ cwd: spec.cwd,
214
+ stdio: {
215
+ stdin: "pipe",
216
+ stdout: "pipe",
217
+ stderr: "inherit"
218
+ },
219
+ graceMs: spec.disposeGraceMs,
220
+ env: spec.env
221
+ });
222
+ } catch (error) {
223
+ reportFailure(spec, error);
224
+ throw new AcpRunFailure({
225
+ stage: "process",
226
+ category: "process-start"
227
+ }, error);
228
+ }
120
229
  /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */
121
230
  if (child.stdin === void 0 || child.stdout === void 0) throw new Error("subagent-acp: subprocess implementation dropped a piped protocol stream");
122
231
  /* v8 ignore stop */
123
- const spawnFailed = child.done.then(
232
+ let processOutcome;
233
+ const processDone = child.done.then((outcome) => {
234
+ processOutcome = outcome;
235
+ return outcome;
236
+ });
237
+ const spawnFailed = processDone.then(
124
238
  /* v8 ignore next -- the success arm's never-settling executor is intentionally empty. */
125
239
  () => new Promise(() => {}),
126
240
  (err) => Promise.reject(toError(err))
127
241
  );
128
242
  spawnFailed.catch(() => {});
243
+ const observeProcessOutcome = async (signal) => {
244
+ if (processOutcome !== void 0 || child.pid <= 0) return processOutcome;
245
+ const timeout = AbortSignal.timeout(Math.ceil(spec.disposeGraceMs));
246
+ const bound = signal === void 0 ? timeout : AbortSignal.any([signal, timeout]);
247
+ const aborted = Promise.withResolvers();
248
+ /* v8 ignore next -- Windows cannot expose the live-child protocol close needed to await this abort. */
249
+ const onObservationAbort = () => {
250
+ aborted.resolve(void 0);
251
+ };
252
+ bound.addEventListener("abort", onObservationAbort, { once: true });
253
+ /* v8 ignore next -- closes the event-loop race between listener registration and the preceding derived-signal check. */
254
+ if (bound.aborted) onObservationAbort();
255
+ try {
256
+ return await Promise.race([processDone, aborted.promise]);
257
+ } catch {
258
+ /* v8 ignore next -- a published child.done cannot reject; spawn rejection is consumed before publication. */
259
+ return processOutcome;
260
+ } finally {
261
+ bound.removeEventListener("abort", onObservationAbort);
262
+ }
263
+ };
129
264
  let processDisposal;
130
265
  const disposeProcess = () => processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs);
131
266
  const fold = new AssistantOutputFold();
132
267
  const flags = { cancelled: false };
133
- const makeClient = (_agent) => ({
134
- sessionUpdate(params) {
135
- const update = params.update;
136
- if (update.sessionUpdate === "agent_message_chunk") fold.pushText(acpContentText(update.content));
137
- return Promise.resolve();
138
- },
139
- requestPermission(params) {
140
- if (spec.permission === "allow") {
141
- const allow = params.options.find((o) => o.kind === "allow_once" || o.kind === "allow_always");
142
- if (allow !== void 0) return Promise.resolve({ outcome: {
268
+ let latestPermission;
269
+ const agent = client({ name: "deepseek-harness-subagent-acp" }).onNotification(methods.client.session.update, ({ params }) => {
270
+ const update = params.update;
271
+ if (update.sessionUpdate === "agent_message_chunk") fold.pushText(acpContentText(update.content));
272
+ return Promise.resolve();
273
+ }).onRequest(methods.client.session.requestPermission, ({ params }) => {
274
+ if (spec.permission === "allow") {
275
+ const allow = params.options.find((o) => o.kind === "allow_once" || o.kind === "allow_always");
276
+ if (allow !== void 0) {
277
+ latestPermission = {
278
+ policy: "allow",
279
+ request: permissionRequestKind(params.toolCall.kind),
280
+ decision: "allowed"
281
+ };
282
+ return Promise.resolve({ outcome: {
143
283
  outcome: "selected",
144
284
  optionId: allow.optionId
145
285
  } });
146
286
  }
147
- return Promise.resolve({ outcome: { outcome: "cancelled" } });
148
287
  }
149
- });
150
- const conn = new ClientSideConnection(makeClient, ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout)));
288
+ latestPermission = {
289
+ policy: spec.permission,
290
+ request: permissionRequestKind(params.toolCall.kind),
291
+ decision: "denied"
292
+ };
293
+ return Promise.resolve({ outcome: { outcome: "cancelled" } });
294
+ }).connect(ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout))).agent;
151
295
  let sessionId;
296
+ let startupStage = "initialize";
152
297
  let signalCancelSettled;
153
298
  const cancelSettled = new Promise((resolve) => {
154
299
  signalCancelSettled = resolve;
@@ -158,7 +303,7 @@ async function startAcpRun(request, spec) {
158
303
  flags.cancelled = true;
159
304
  signalCancelSettled();
160
305
  /* v8 ignore next */
161
- if (sessionId !== void 0) conn.cancel({ sessionId }).catch(() => {});
306
+ if (sessionId !== void 0) agent.notify(methods.agent.session.cancel, { sessionId }).catch(() => {});
162
307
  };
163
308
  const onAbort = () => {
164
309
  requestCancel();
@@ -168,17 +313,22 @@ async function startAcpRun(request, spec) {
168
313
  try {
169
314
  await Promise.race([
170
315
  (async () => {
171
- await conn.initialize({
316
+ await agent.request(methods.agent.initialize, {
172
317
  protocolVersion: PROTOCOL_VERSION,
173
318
  clientCapabilities: {}
174
319
  });
175
- const session = await conn.newSession({
320
+ startupStage = "new-session";
321
+ const session = await agent.request(methods.agent.session.new, {
176
322
  cwd: spec.cwd,
177
323
  mcpServers: []
178
324
  });
179
325
  const returnedSessionId = Reflect.get(session, "sessionId");
180
- if (typeof returnedSessionId !== "string") throw new Error("ACP child published without a session id");
326
+ if (typeof returnedSessionId !== "string") throw new AcpRunFailure({
327
+ stage: "new-session",
328
+ category: "protocol"
329
+ }, /* @__PURE__ */ new Error("ACP child published without a session id"));
181
330
  sessionId = returnedSessionId;
331
+ /* v8 ignore next -- cancelSettled wins the startup race before this post-response guard can settle it. */
182
332
  if (flags.cancelled) throw new Error("subagent cancelled before the ACP session started");
183
333
  })(),
184
334
  spawnFailed,
@@ -188,59 +338,86 @@ async function startAcpRun(request, spec) {
188
338
  ]);
189
339
  } catch (error) {
190
340
  request.signal.removeEventListener("abort", onAbort);
191
- await disposeProcess();
192
- if (flags.cancelled) throw new Error("subagent request was aborted before the ACP child started");
193
- throw toError(error);
341
+ const startup = flags.cancelled ? { kind: "cancelled" } : {
342
+ kind: "failed",
343
+ failure: error instanceof AcpRunFailure ? error : startupFailure(error, startupStage, child, await observeProcessOutcome())
344
+ };
345
+ if (startup.kind === "cancelled") {} else reportFailure(spec, error instanceof AcpRunFailure ? error.cause : error);
346
+ try {
347
+ await disposeProcess();
348
+ } catch (cleanupError) {
349
+ reportFailure(spec, cleanupError);
350
+ const cleanupFailure = new AcpRunFailure({
351
+ stage: "teardown",
352
+ category: processOutcome === void 0 ? "unknown" : "process-exit",
353
+ ...processOutcome === void 0 ? {} : { outcome: processOutcome }
354
+ }, cleanupError);
355
+ if (startup.kind === "cancelled") throw new AggregateError([cleanupFailure], cleanupFailure.message);
356
+ throw new AggregateError([startup.failure, cleanupFailure], `${startup.failure.message}; ${cleanupFailure.message}`);
357
+ }
358
+ if (startup.kind === "cancelled") throw new Error("subagent request was aborted before the ACP child started");
359
+ throw startup.failure;
194
360
  }
195
361
  /* v8 ignore next */
196
362
  if (sessionId === void 0) throw new Error("unreachable: ACP startup fulfilled without a session id");
197
363
  const remoteSessionId = sessionId;
198
- const result = (async () => {
199
- try {
200
- const prompt = async () => {
201
- const promptResult = await conn.prompt({
202
- sessionId: remoteSessionId,
203
- prompt: toAcpPrompt(request.prompt)
204
- });
205
- return {
206
- output: collectOutput(),
207
- stopReason: acpStopReason(promptResult.stopReason)
208
- };
209
- };
210
- return await Promise.race([prompt(), cancelSettled.then(() => ({
211
- output: collectOutput(),
212
- stopReason: "aborted"
213
- }))]);
214
- } catch (error) {
215
- /* v8 ignore next */
216
- if (flags.cancelled) return {
217
- output: collectOutput(),
218
- stopReason: "aborted"
219
- };
220
- try {
221
- spec.onError?.(toError(error), "error");
222
- } catch {}
223
- return {
224
- output: collectOutput(),
225
- stopReason: "error"
226
- };
227
- } finally {
228
- request.signal.removeEventListener("abort", onAbort);
229
- }
230
- })();
231
- let disposal;
232
- return {
364
+ let diagnostic;
365
+ return subprocessRunHandle({
233
366
  id,
234
- localAgent: void 0,
235
- result,
236
- dispose() {
237
- if (disposal !== void 0) return disposal;
238
- request.signal.removeEventListener("abort", onAbort);
239
- requestCancel();
240
- disposal = disposeProcess();
241
- return disposal;
367
+ result: settleRunResult({
368
+ attempt: async () => {
369
+ try {
370
+ const promptResult = await Promise.race([agent.request(methods.agent.session.prompt, {
371
+ sessionId: remoteSessionId,
372
+ prompt: toAcpPrompt(request.prompt)
373
+ }), cancelSettled.then(() => {
374
+ throw new Error("subagent cancelled while the ACP prompt was running");
375
+ })]);
376
+ const stopReason = acpStopReason(promptResult.stopReason);
377
+ diagnostic = terminalFailure(promptResult.stopReason, latestPermission);
378
+ return {
379
+ output: collectOutput(),
380
+ ...diagnostic === void 0 ? {} : { diagnostic },
381
+ stopReason
382
+ };
383
+ } catch (error) {
384
+ if (!flags.cancelled) {
385
+ const outcome = await observeProcessOutcome(request.signal);
386
+ diagnostic = diagnosticText(outcome === void 0 ? {
387
+ stage: "prompt",
388
+ category: "transport"
389
+ } : {
390
+ stage: "process",
391
+ category: "process-exit",
392
+ outcome
393
+ }, latestPermission);
394
+ }
395
+ throw error;
396
+ }
397
+ },
398
+ collectOutput,
399
+ collectDiagnostic: () => diagnostic,
400
+ cancelled: () => flags.cancelled,
401
+ onError: spec.onError,
402
+ signal: request.signal,
403
+ onAbort
404
+ }),
405
+ signal: request.signal,
406
+ onAbort,
407
+ requestCancel,
408
+ teardown: async () => {
409
+ try {
410
+ await disposeProcess();
411
+ } catch (error) {
412
+ reportFailure(spec, error);
413
+ throw new AcpRunFailure({
414
+ stage: "teardown",
415
+ category: processOutcome === void 0 ? "unknown" : "process-exit",
416
+ ...processOutcome === void 0 ? {} : { outcome: processOutcome }
417
+ }, error);
418
+ }
242
419
  }
243
- };
420
+ });
244
421
  }
245
422
  //#endregion
246
423
  //#region lib/types/index.js
@@ -264,7 +441,7 @@ const Config = z.object({
264
441
  disposeEofGraceMs: z.number().default(DEFAULT_DISPOSE_EOF_GRACE_MS),
265
442
  disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS)
266
443
  });
267
- /** A dispose grace must fit the single Node timer that owns its teardown tier. */
444
+ /** A process grace must fit every Node timer that observes or terminates the child. */
268
445
  function assertPositiveFinite(name, value) {
269
446
  if (!Number.isFinite(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) throw new Error(`subagent-acp: ${name} must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
270
447
  }
@@ -312,14 +489,15 @@ function resolveCwd(configured, request) {
312
489
  }
313
490
  /**
314
491
  * The ACP provider. Advertises NO start-time capabilities: an out-of-process
315
- * child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects
316
- * a request needing any of them before `start` runs).
492
+ * child cannot honor `agentOptions`/`outputSchema`/`maxDepth`/`toolFilter`/
493
+ * `persona` (the service rejects a request needing any before `start` runs).
317
494
  */
318
495
  var AcpProvider = class {
319
496
  name;
320
497
  ctx;
321
498
  config;
322
499
  capabilities = {
500
+ agentOptions: false,
323
501
  outputSchema: false,
324
502
  depthLimit: false,
325
503
  toolFilter: false,
@@ -332,10 +510,19 @@ var AcpProvider = class {
332
510
  this.config = config;
333
511
  }
334
512
  start(request) {
513
+ if (request.signal.aborted) throw new Error("subagent request was aborted before the ACP child started");
514
+ let cwd;
515
+ try {
516
+ cwd = resolveCwd(this.config.cwd, request);
517
+ } catch (error) {
518
+ const failure = acpConfigurationFailure(error);
519
+ this.ctx.logger.warn(`subagent-acp "${this.name}": child start failed: %o`, error);
520
+ throw failure;
521
+ }
335
522
  return startAcpRun(request, {
336
523
  command: this.config.command,
337
524
  args: this.config.args,
338
- cwd: resolveCwd(this.config.cwd, request),
525
+ cwd,
339
526
  permission: this.config.permission,
340
527
  env: this.config.env,
341
528
  disposeEofGraceMs: this.config.disposeEofGraceMs,
@@ -47,7 +47,7 @@ export interface Config {
47
47
  * `MAX_TIMER_DELAY_MS`.
48
48
  */
49
49
  disposeEofGraceMs?: number;
50
- /** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */
50
+ /** Failure-observation and termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */
51
51
  disposeGraceMs?: number;
52
52
  }
53
53
  export declare const Config: z<Config>;
@@ -2,9 +2,6 @@
2
2
  * Fresh-process ACP subagent client. Drives one child session and owns cancellation and
3
3
  * quiescent disposal.
4
4
  *
5
- * TODO(acp-subagent-replay): add snapshot-tier coverage with a separate replay fixture and
6
- * sessions root inside each child process. Current keyless coverage uses a scripted ACP child;
7
- * with-key coverage drives the real ACP example.
8
5
  * @module @deepseek-ai/dsh-subagent-acp/run
9
6
  */
10
7
  import { type ContentBlock as AcpContentBlock, type StopReason } from '@agentclientprotocol/sdk';
@@ -45,9 +42,11 @@ export interface AcpRunSpec {
45
42
  */
46
43
  disposeEofGraceMs: number;
47
44
  /**
48
- * Termination-escalation grace (ms) in {@link SubagentRun.dispose}; POSIX
49
- * waits this long after `SIGTERM` before `SIGKILL`, while Windows
50
- * force-terminates directly. The plugin fills it from `disposeGraceMs`.
45
+ * Process-observation and termination-escalation grace (ms). Failure
46
+ * classification waits at most this long for structured exit facts; POSIX
47
+ * dispose also waits this long after `SIGTERM` before `SIGKILL`, while
48
+ * Windows force-terminates directly. The plugin fills it from
49
+ * `disposeGraceMs`.
51
50
  */
52
51
  disposeGraceMs: number;
53
52
  /**
@@ -57,12 +56,9 @@ export interface AcpRunSpec {
57
56
  */
58
57
  spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle;
59
58
  /**
60
- * Sink for a child-level failure that the run flattened into a stop reason
61
- * (the seam contract forbids `result` rejecting). The driver calls this with
62
- * the original error and the chosen stop reason so the fault is preserved
63
- * rather than silently lost; the provider wires it to `ctx.logger.warn`.
64
- * A throw from the sink itself is contained — it cannot reject `result`.
65
- * Optional — omitted in a unit test that asserts the stop reason directly.
59
+ * Host sink for startup, published-run, or teardown failures. Model-visible
60
+ * text uses fixed safe facts, while this callback retains the original Error
61
+ * when one exists. A throw from the sink itself is contained.
66
62
  */
67
63
  onError?: (error: Error, stopReason: SubagentStopReason) => void;
68
64
  }
@@ -70,6 +66,12 @@ export interface AcpRunSpec {
70
66
  export declare const DEFAULT_DISPOSE_EOF_GRACE_MS = 6000;
71
67
  /** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */
72
68
  export declare const DEFAULT_DISPOSE_GRACE_MS = 3000;
69
+ /**
70
+ * Hide a pre-spawn workspace/configuration failure behind fixed safe facts.
71
+ * @param cause - original Host failure retained on the Error cause chain.
72
+ * @returns an Error whose message contains only the fixed ACP failure line.
73
+ */
74
+ export declare function acpConfigurationFailure(cause: unknown): Error;
73
75
  /**
74
76
  * Cooperative teardown ladder for an out-of-process agent, over the seam's
75
77
  * public verbs; resolves only at whole-tree quiescence: stdin EOF (the child's
@@ -101,8 +103,11 @@ export declare function acpContentText(content: AcpContentBlock): string;
101
103
  export declare function toAcpPrompt(prompt: ContentBlock[]): AcpContentBlock[];
102
104
  /**
103
105
  * Start and publish one ACP child after initialization and session creation.
104
- * Child failures resolve through the run result; startup failures reject after
105
- * process reap. Disposal cancels, kills, and reaps the child.
106
+ * Child failures resolve through the run result. Startup rejects with fixed
107
+ * safe facts after provider-owned cleanup; successful cleanup proves process
108
+ * reap. Cleanup failure preserves startup plus teardown facts for an ordinary
109
+ * failure, or teardown alone after cancellation, without claiming quiescence.
110
+ * Disposal cancels, kills, and reaps the child.
106
111
  * @param request - the start request; its signal is the cancellation channel.
107
112
  * @param spec - the resolved spawn spec: command/args/cwd, env, permission
108
113
  * policy, dispose graces, and the optional error sink.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-subagent-acp",
3
3
  "description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol",
4
- "version": "0.1.1-rc.2",
4
+ "version": "0.1.2-alpha.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -32,30 +32,37 @@
32
32
  ],
33
33
  "license": "MIT",
34
34
  "peerDependencies": {
35
- "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
36
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
37
- "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
38
- "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
39
- "@deepseek-ai/dsh-subagent": "^0.1.1-rc.2",
40
- "@deepseek-ai/dsh-subprocess": "^0.1.1-rc.2",
41
- "@deepseek-ai/dsh-timeout": "^0.1.1-rc.2",
42
- "@deepseek-ai/cordis": "^4.0.1"
35
+ "@deepseek-ai/cordis": "^4.0.2",
36
+ "@deepseek-ai/dsh-agent": "^0.1.2-alpha.2",
37
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
38
+ "@deepseek-ai/dsh-llm": "^0.1.2-alpha.2",
39
+ "@deepseek-ai/dsh-session": "^0.1.2-alpha.2",
40
+ "@deepseek-ai/dsh-subagent": "^0.1.2-alpha.2",
41
+ "@deepseek-ai/dsh-subprocess": "^0.1.2-alpha.2",
42
+ "@deepseek-ai/dsh-timeout": "^0.1.2-alpha.2"
43
43
  },
44
44
  "dependencies": {
45
- "@agentclientprotocol/sdk": "0.25.1",
46
- "@deepseek-ai/schemastery": "^3.18.1"
45
+ "@agentclientprotocol/sdk": "1.4.0",
46
+ "@deepseek-ai/dsh-brand": "^0.1.2-alpha.2",
47
+ "@deepseek-ai/schemastery": "^3.18.2"
47
48
  },
48
49
  "devDependencies": {
49
- "@deepseek-ai/cordis-plugin-loader": "^1.0.2",
50
- "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
51
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
52
- "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
53
- "@deepseek-ai/dsh-loader-smoke": "^0.1.1-rc.2",
54
- "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
55
- "@deepseek-ai/dsh-subagent": "^0.1.1-rc.2",
56
- "@deepseek-ai/dsh-subprocess": "^0.1.1-rc.2",
57
- "@deepseek-ai/dsh-subprocess-local": "^0.1.1-rc.2",
58
- "@deepseek-ai/dsh-timeout": "^0.1.1-rc.2",
59
- "@deepseek-ai/cordis": "^4.0.1"
50
+ "@deepseek-ai/cordis": "^4.0.2",
51
+ "@deepseek-ai/cordis-plugin-loader": "^1.0.3",
52
+ "@deepseek-ai/dsh-agent": "^0.1.2-alpha.2",
53
+ "@deepseek-ai/dsh-agent-spine-demo": "^0.1.2-alpha.2",
54
+ "@deepseek-ai/dsh-app-boot": "^0.1.2-alpha.2",
55
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
56
+ "@deepseek-ai/dsh-llm": "^0.1.2-alpha.2",
57
+ "@deepseek-ai/dsh-loader-smoke": "^0.1.2-alpha.2",
58
+ "@deepseek-ai/dsh-session": "^0.1.2-alpha.2",
59
+ "@deepseek-ai/dsh-session-checkpoint-policy": "^0.1.2-alpha.2",
60
+ "@deepseek-ai/dsh-session-persistence-jsonl": "^0.1.2-alpha.2",
61
+ "@deepseek-ai/dsh-session-projection": "^0.1.2-alpha.2",
62
+ "@deepseek-ai/dsh-subagent": "^0.1.2-alpha.2",
63
+ "@deepseek-ai/dsh-subprocess": "^0.1.2-alpha.2",
64
+ "@deepseek-ai/dsh-subprocess-local": "^0.1.2-alpha.2",
65
+ "@deepseek-ai/dsh-timeout": "^0.1.2-alpha.2",
66
+ "@deepseek-ai/dsh-tool-subagent": "^0.1.2-alpha.2"
60
67
  }
61
68
  }