Merge pull request #1600 from deepseek-harness/codex/product-providers-pr2-claude-code

Add the Claude Code subagent provider
This commit is contained in:
pku-xht
2026-08-06 21:48:52 +08:00
committed by GitHub
120 changed files with 6857 additions and 393 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md
2026-07-27-dispose-ladder-to-consumer.md: 97b551ff509e3b424f6bf5725939cf54acc961a7
2026-07-27-dispose-ladder-to-consumer.zh.md: 7fff744e64109549a65d4f5bb17ff2d6ddfc6888
2026-07-27-dispose-ladder-to-consumer.md: e9af88e8e7ef962213a74e96a249241cbe8d5994
2026-07-27-dispose-ladder-to-consumer.zh.md: 89f8e107c56d42787c59bc6f8fa8fc7b3ef73208
@@ -10,7 +10,7 @@ English | [中文](2026-07-27-dispose-ladder-to-consumer.zh.md)
## Decision
The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs, graceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then `terminate()` (whose SIGTERM→spec-grace→SIGKILL escalation already encodes the signal tiers), then a final bounded whole-tree wait that throws if survivors remain. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold each tier on real tree exit. `dsh-subprocess-local` drops its `dsh-timeout` dependency; the seam's handle loses one method and one exported interface.
The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then call `terminate()`, whose SIGTERM→spec-grace→SIGKILL escalation already owns the signal timer, and await an unbounded `waitForExit()` for the subprocess owner's whole-tree exit proof. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold the cooperative tier on real tree exit without deriving another timer from the termination grace. The seam's handle loses one method and one exported interface.
## Alternatives considered
@@ -20,4 +20,4 @@ The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(c
## Consequences
Bought: the seam is one method and one type smaller; implementations owe four verbs and no teardown policy; `dsh-subprocess-local` loses a dependency; the ladder's tier windows live beside the config fields that tune them. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier-tier tests moved from the seam suite to the ACP suite, and the seam suite pins the verbs the ladder composes (bounded `waitForExit` false-then-true across an escalation) instead of the composed policy.
Bought: the seam is one method and one type smaller; implementations owe four verbs and no teardown policy; the cooperative EOF window lives beside the ACP config field that tunes it, while the subprocess owner alone owns the termination window and final join. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier tests live in the ACP suite, and the seam suite pins the verbs the ladder composes (bounded `waitForExit` false before escalation and an unbounded whole-tree join after it) instead of the composed policy.
@@ -10,7 +10,7 @@ Status: implemented
## 决策
阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs, graceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已编码了信号层级),最后进行有界的整树等待,若仍有存活进程则抛出。seam 保留 `kill``terminate``waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在每一层确认进程树真正退出所需的完全停稳探针`dsh-subprocess-local` 卸下 `dsh-timeout` 依赖;seam 的句柄少了一个方法和一个导出接口。
阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后调用 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已拥有信号定时器),再无界等待 `waitForExit()`,由子进程责任方证明整棵进程树已经退出。seam 保留 `kill``terminate``waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在协作层确认进程树真正退出所需的停稳探针,无需从终止宽限期再派生一个定时器。seam 的句柄少了一个方法和一个导出接口。
## 曾考虑的替代方案
@@ -20,4 +20,4 @@ Status: implemented
## 后果
换来的是:seam 少了一个方法和一个类型;实现只需提供四个动词,无需提供拆卸策略;`dsh-subprocess-local` 少了一个依赖;阶梯的层级时间窗与调节它的配置字段住在一起。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试从 seam 套件移入 ACP 套件,seam 套件转而钉住阶梯所组合的动词(升级前有界 `waitForExit` 先假后真),而非组合后的策略。
买到的:seam 少了一个方法和一个类型;实现只四个动词,不欠拆卸策略;协作式 EOF 时间窗与调节它的 ACP 配置字段住在一起,而终止时间窗与最终的整树退出等待仅由子进程责任方拥有。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试位于 ACP 套件,seam 套件转而钉住阶梯所组合的动词(升级前有界 `waitForExit` 返回假,升级后无界等待整棵进程树退出),而非组合后的策略。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md
2026-06-21-subagent-capability-seam.md: 043092884731c403a11b71ef8b5a410e9e5af7e0
2026-06-21-subagent-capability-seam.zh.md: 6a4a5798199ca7d6d7c011668a319d65a0553208
2026-06-21-subagent-capability-seam.md: 752e639b09ea2ac0ba19841ddfe38b15b44d22e9
2026-06-21-subagent-capability-seam.zh.md: 6009aeda6773357a4217f8956fb510c2116bbe3f
@@ -4,7 +4,7 @@ Status: implemented
English | [中文](2026-06-21-subagent-capability-seam.zh.md)
> The full seam is shipped: the `dsh-subagent` interface and `dsh-tool-subagent` consumer; the two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`); the nested-agent snapshot infrastructure ([per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md)); and the out-of-process `dsh-subagent-acp` backend ([its Agent Note](2026-06-22-acp-subagent-backend.md)).
> The full seam is shipped: the `dsh-subagent` interface and `dsh-tool-subagent` consumer; the two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`); the nested-agent snapshot infrastructure ([per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md)); and the out-of-process ACP, Codex, and Claude Code backends ([ACP Agent Note](2026-06-22-acp-subagent-backend.md), [product-provider Agent Note](2026-08-04-claude-code-and-codex-subagent-backends.md)).
## Problem
@@ -14,7 +14,8 @@ The distinctive requirement — the one that shapes the whole design — is that
- **in-process** — a child concrete `Agent` on the same `Context` (the cheapest, and nearly free given the existing agent factory);
- **ACP** — act as an ACP *client* driving another agent process (which can be another instance of ourselves);
- later: **A2A**, the **Codex app-server**, and the **Claude Code Agent SDK**each the same out-of-process "start a child, prompt it, stream updates, cancel" shape as the ACP backend.
- **Codex app-server and Claude Code Agent SDK** — current one-shot siblings that apply the same named-provider seam to official product processes ([product-provider Agent Note](2026-08-04-claude-code-and-codex-subagent-backends.md));
- later: **A2A** using the same out-of-process "start a child, prompt it, settle, cancel" shape.
## Alternatives considered
@@ -34,6 +35,8 @@ A new package group `packages/subagent/`:
| `@deepseek-ai/dsh-subagent-spawn` | implementation: a fresh in-process child via `ctx.agents.create` |
| `@deepseek-ai/dsh-subagent-fork` | implementation: an in-process child seeded with a snapshot of the parent's log |
| `@deepseek-ai/dsh-subagent-acp` | implementation: an ACP client driving a configured child process |
| `@deepseek-ai/dsh-subagent-codex` | implementation: a one-shot official Codex app-server process |
| `@deepseek-ai/dsh-subagent-claude-code` | implementation: a one-shot official Claude Code process through the Agent SDK |
| `@deepseek-ai/dsh-tool-subagent` | consumer: the model-facing `subagent` tool over `ctx.subagents` |
### The primitive: async `start → SubagentRun`
@@ -51,7 +54,7 @@ Fresh and forked children are separate providers, not a request flag. `dsh-subag
### Child isolation and the parent log
Each subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. The parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output) — the child's internal steps and tool calls stay in the child's own session, never injected into the parent log. This is the only design that is identical across transports: an ACP child's internal events physically cannot be injected into our parent log, so making in-process behave the same keeps the seam transport-agnostic.
Each in-process subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. Remote ACP and one-shot product providers instead mint a parent-scoped lifecycle id and expose no local `Agent` or child `Session`; their internal state remains in the remote process. Across both forms, the parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output), while child steps and tool calls remain outside the parent log.
### Synchronous collect (first cut)
@@ -4,7 +4,7 @@ Status: implemented
[English](2026-06-21-subagent-capability-seam.md) | 中文
> 完整 seam 已交付:`dsh-subagent` 接口与 `dsh-tool-subagent` 消费方;两个进程内后端(`dsh-subagent-spawn`、`dsh-subagent-fork`);嵌套 agent 快照基础设施([逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md));以及进程外后端 `dsh-subagent-acp`[其 Agent Note](2026-06-22-acp-subagent-backend.md))。
> 完整 seam 已交付:`dsh-subagent` 接口与 `dsh-tool-subagent` 消费方;两个进程内后端(`dsh-subagent-spawn`、`dsh-subagent-fork`);嵌套 agent 快照基础设施([逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md));以及进程外的 ACP、Codex 与 Claude Code 后端([ACP Agent Note](2026-06-22-acp-subagent-backend.md)、[产品提供方 Agent Note](2026-08-04-claude-code-and-codex-subagent-backends.md))。
## 问题
@@ -14,7 +14,8 @@ harness 有一个长期搁置的 seam 用于 **subagent**:一个 agent(智
- **进程内**:在同一个 `Context` 上创建一个具体的子 `Agent`(最廉价,且鉴于现有 agent 工厂几乎零成本);
- **ACP**:作为 ACP *客户端*驱动另一个 agent 进程(可以是自身的另一个实例);
- 后续:**A2A**、**Codex app-server****Claude Code Agent SDK**——每种都与 ACP 后端相同的进程外形状:「启动子 agent、发送提示词、流式接收更新、取消」。
- **Codex app-server 与 Claude Code Agent SDK**:当前的一次性兄弟提供方,将同一个命名提供方 seam 应用于官方产品进程([产品提供方 Agent Note](2026-08-04-claude-code-and-codex-subagent-backends.md));
- 后续:**A2A**,采用同样的进程外形态:「启动子 agent、发送提示词、结算、取消」。
## 曾考虑的替代方案
@@ -34,6 +35,8 @@ bash seam[能力 seam](../architecture/2026-06-13-capability-seams.md))在
| `@deepseek-ai/dsh-subagent-spawn` | 实现:通过 `ctx.agents.create` 创建全新的进程内子 agent |
| `@deepseek-ai/dsh-subagent-fork` | 实现:用父 agent 日志快照初始化的进程内子 agent |
| `@deepseek-ai/dsh-subagent-acp` | 实现:作为 ACP 客户端驱动已配置的子进程 |
| `@deepseek-ai/dsh-subagent-codex` | 实现:一次性官方 Codex app-server 进程 |
| `@deepseek-ai/dsh-subagent-claude-code` | 实现:通过 Agent SDK 运行的一次性官方 Claude Code 进程 |
| `@deepseek-ai/dsh-tool-subagent` | 消费方:基于 `ctx.subagents` 的面向模型的 `subagent` 工具 |
### 原语:异步 `start → SubagentRun`
@@ -51,7 +54,7 @@ bash seam[能力 seam](../architecture/2026-06-13-capability-seams.md))在
### 子 agent 隔离与父日志
每个 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。父日志仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出)——子 agent 的内部步骤和工具调用留在子 agent 自己的会话中,绝不注入父日志。这是唯一在所有传输方式下行为一致的设计:ACP 子 agent 的内部事件在物理上无法注入我们的父日志,因此让进程内行为保持一致,使 seam 真正与传输方式无关
每个进程内 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。远端 ACP 和一次性产品提供方则会生成一个父级作用域的生命周期 id,且不暴露本地 `Agent` 或子 `Session`;其内部状态留在远端进程中。两种形式下,父日志仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出),而子 agent 的步骤和工具调用留在父日志之外
### 同步收集(首版)
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md
2026-06-22-acp-subagent-backend.md: 5f12aa1c08d4f4cfaa35f2f7f4b09ad341c3eae8
2026-06-22-acp-subagent-backend.zh.md: ba9d4255723367ab4afc5ab87e056f12f5c3a285
2026-06-22-acp-subagent-backend.md: c994ebfa69649bb9e79d3aa389a0e13c178131c7
2026-06-22-acp-subagent-backend.zh.md: 62d631e95b5d1f299bed1fd00353b5ac9349b9a0
@@ -57,6 +57,6 @@ Persistent-process pooling (reuse a warm child across runs) is a performance opt
Every run pays a fresh subprocess (spawn + `initialize` + `newSession`). The parent surfaces only the child's final answer: `session/update` thoughts and tool-call cards are consumed and dropped, and permission prompts never reach a human — the configured policy answers them. The child's environment is credential-scrubbed by default, so its own model key is supplied explicitly via `config.env`.
## Future providers
## Product-provider siblings
The same out-of-process spawn/prompt/stream/cancel shape generalizes to other transports named in the seam Agent Note — A2A, the Codex app-server, and the Claude Code Agent SDK — each a sibling provider registered by name. The ACP backend is the proof that the seam supports the boundary; those are mechanically similar.
The [Codex app-server and Claude Code Agent SDK providers](2026-08-04-claude-code-and-codex-subagent-backends.md) apply the same out-of-process spawn/prompt/settle/cancel boundary as siblings registered by name. A2A remains a future sibling transport; the ACP backend proves that the common seam supports this boundary without owning product-private protocols.
@@ -57,6 +57,6 @@ ACP `StopReason` → harness `SubagentStopReason``end_turn`→`completed`、`
每次运行都要付出一个全新子进程的代价(spawn + `initialize` + `newSession`)。父进程仅暴露子 agent 的最终回答:`session/update` 中的思考和工具调用卡片被消费后丢弃,权限提示从不到达人类——由配置的策略应答。子进程环境默认经过凭证清洗,因此其自身的模型密钥需通过 `config.env` 显式提供。
## 后续提供方
## 兄弟产品提供方
同样的进程外启动/提示词/流式输出/取消形态可泛化到 seam Agent Note 中列出的其他传输方式——A2A、Codex app-server Claude Code Agent SDK——每个都是按名称注册的兄弟提供方。ACP 后端证明了 seam 支持跨进程边界;其余在机制上类似
[Codex app-server Claude Code Agent SDK 提供方](2026-08-04-claude-code-and-codex-subagent-backends.md)作为按名称注册的兄弟提供方,采用同样的进程外启动/提示词/结算/取消边界。A2A 仍是未来的兄弟传输方式;ACP 后端证明了通用 seam 能够支持这项边界,而无需负责产品私有协议
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md
2026-08-04-claude-code-and-codex-subagent-backends.md: e81d1fb14f719331c503dba539d6a5ec0f1eed4f
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 3c78e8d5a4ca1a86942971721ad05c631901f231
@@ -0,0 +1,94 @@
# Agent Note: Claude Code and Codex subagent backends
Status: implemented
English | [中文](2026-08-04-claude-code-and-codex-subagent-backends.zh.md)
## Problem
The named [`ctx.subagents`](2026-06-21-subagent-capability-seam.md) registry lets a parent agent delegate work without knowing how the child runs, but the harness needs first-party routes to the real Codex and Claude Code products. Each route must hand the product one self-contained task, let it work in the parent Session's workspace, return a final answer or an explicit failure or cancellation, and leave no managed product process behind.
The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or process trees. Required evidence therefore separates three facts: a keyless real-product test proves the official integration, native authentication shape, deterministic answer, and teardown; a Loader composition test proves that the public package and documented tool configuration load without starting the product; and a credentialed e2e proves that the production provider and real product can obtain a unique answer from the real DeepSeek service. Direct model HTTP or a product double cannot replace either product-running tier, and a hand-mounted plugin cannot replace the Loader tier.
## Decision
The harness publishes two sibling one-shot providers as independently installable, opt-in packages. A user loads a provider and the existing common subagent tool in their own `cordis.yml`: `subagent_codex` binds `codex`, while `subagent_claude_code` binds `claude-code`. The shipped CLI dependency closure and base, Web, and headless configurations load neither provider. Each tool accepts only a standalone text task; product selection and background execution are not model arguments.
Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools disable background execution and use `maxDepth: 'provider-managed'`, leaving recursion policy with the out-of-process product instead of sending a limit the provider cannot enforce. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation.
```text
fixed tool → shared subagent service → product provider → official product process
← final answer / explicit error / cancellation ← terminal product fact
→ foreground disposal → shared process-tree termination → whole-tree exit
```
### Ownership and lifecycle
| Phase | Shared owner | Product-specific responsibility | Observable result |
| --- | --- | --- | --- |
| Resolve | `dsh-tool-subagent` and `ctx.subagents` | Validate the product's text-only input and derive native startup parameters | Unsupported context or malformed input fails before a run is published |
| Start | `dsh-subprocess` owns every acquired process tree | Reach the smallest native point at which the product conversation and process can both be controlled | `start()` publishes one existing `SubagentRun`, or cleans up and rejects |
| Run | The product owns its native protocol facts; the holder owns their mapping | Submit exactly one task and derive an existing shared stop reason; Codex uses `max-tokens` only for explicit context exhaustion | The parent receives only a final answer or an explicit failure |
| Dispose | The foreground consumer requests release; `dsh-subprocess` proves exit | Close the native protocol and express any best-effort native cancellation | Disposal is idempotent and returns only after the whole process tree exits |
## Codex provider
`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Installation, login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities.
Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize``initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session.
`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed required fields in a recognized app-server frame, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`.
For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply.
An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. Result failure and teardown failure stay independently observable.
Codex 0.146.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively.
## Claude Code provider
`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. The SDK's platform `optionalDependency` supplies the real Claude Code 2.1.220 CLI. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` command, arguments, cwd, environment, and forwarded signal unchanged to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires.
The public configuration contains the same two deployment-owned values as the Codex sibling: an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Each run creates its own `AbortController`, sets `persistSession: false`, and disables `AskUserQuestion`. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK rather than waiting for a user interface the provider does not own.
The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. Every SDK error subtype, an error-marked success, a missing result, iterator failure, protocol failure, or process failure becomes `error`. SDK turn, budget, and structured-output limits are not token-window facts, and the SDK exposes no native refusal terminal, so this provider produces neither `max-tokens` nor `refusal`. Local cancellation wins and becomes `aborted`.
Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. Query-close failure, process failure, and teardown failure remain independently observable.
The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract directly: the runtime-only DeepSeek key becomes `ANTHROPIC_AUTH_TOKEN`, the fixed official base gains `/anthropic`, and the main and subagent model variables select the documented DeepSeek models. It starts the production provider and real SDK/CLI, requires one random nonce as the complete answer, persists no credential in settings, and waits for every managed handle to exit.
## Distribution and evidence
Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Loader tier boots the README-shaped user configuration, verifies both fixed foreground-only tools in one context, and starts neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret.
The Codex evidence pins `@openai/codex@0.146.0` and `codex-cli 0.146.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`.
The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit.
The Claude Code evidence pins Agent SDK 0.3.220 and its platform-distributed Claude Code 2.1.220 CLI. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, and whole-tree exit. The Loader e2e resolves both product packages by name while neither product command is available and records zero child starts.
The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test.
The project owner's distribution authorization is scoped to the official `@anthropic-ai/claude-agent-sdk` identity and the official Claude Code CLI/platform payloads each SDK version declares through `optionalDependencies`. [`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) derives and discloses the current payload set without reclassifying its declared terms as permissive. Version, license-field, and payload-set changes still undergo ordinary dependency, lockfile, compatibility, terms, and notices review; unrelated non-permissive runtime packages continue to fail closed.
## Alternatives considered
**Direct model HTTP, `codex exec`, or a hand-written Claude CLI protocol.** These paths bypass the products' official extensible integration surfaces and cannot prove native configuration, tools, approvals, result semantics, or teardown. Each provider uses its official product integration instead.
**A shared product-process helper package.** The existing subagent and subprocess seams already own every shared task, result, environment, and process-tree concern. A new helper would duplicate ownership without deleting either private product adapter, so each adapter calls the existing seams directly.
**A model-visible product selector.** Product availability and authentication are deployment facts. Two fixed tools keep each schema and provider binding explicit and avoid adding dynamic selection state to the common service.
**Product doubles as required evidence.** Doubles cover exhaustive private protocol branches but do not prove package exports, official distributions, authentication, or real process behavior. Required evidence drives each official product against a loopback model fixture.
**Plugin-managed login, product home, models, settings, or permissions.** Those choices would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. The providers expose only an explicit environment overlay and teardown grace; unattended interaction fails closed.
**Continuation, progress, background collection, and shared parent context.** The delivered user result is one self-contained task and one final answer. Product sessions, resume, follow-up, intermediate messages, parent transcript transfer, structured output, and background collection need separate user contracts and are not prebuilt.
## Consequences
Users can install either or both product providers, bind stable foreground tools in their own Cordis configuration, and delegate one self-contained task through the existing subagent contract. Official product integrations preserve native settings and behavior while shared services retain the sole ownership of task settlement and process-tree quiescence.
Every delegation pays for a fresh product process and independent model context, and only final text reaches the parent. Product-native configuration makes behavior depend on the deployment's installed product, account state, and workspace settings. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout.
Compatibility is pinned by package-level unit coverage, keyless real-product loopback tests, credentialed DeepSeek nonce tests, public Loader composition, built-package and NodeNext consumer checks, generated documentation and notices, and the repository CI matrix. A supported product or DeepSeek endpoint/model baseline change must refresh those facts; production performs no separate runtime version probe.
@@ -0,0 +1,94 @@
# Agent Note: Claude Code 与 Codex subagent 后端
Status: implemented
[English](2026-08-04-claude-code-and-codex-subagent-backends.md) | 中文
## 问题
命名的 [`ctx.subagents`](2026-06-21-subagent-capability-seam.md) 注册表让父 agent(智能体)无需了解子级的运行方式即可委派工作,但 harness 需要通往真实 Codex 与 Claude Code 产品的第一方路径。每条路径都必须向产品交付一项自包含任务,让它在父会话的工作区中执行,返回最终回答或明确的失败或取消结果,并且不留下任何受管的产品进程。
产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。因此,所需证据要区分三个事实:无密钥真实产品测试证明官方集成、原生身份验证形态、确定性答案与资源清理;Loader 组合测试证明公开包(package)和文档所示的工具配置无需启动产品即可加载;带密钥 e2e 证明生产提供方与真实产品能够从真实 DeepSeek 服务取得唯一答案。直接发起模型 HTTP 请求或使用产品替身无法取代上述任一产品运行层级;手工挂载插件无法取代 Loader 层级。
## 决策
harness 将两个一次性兄弟提供方作为可独立安装、选择启用的包交付。用户在自己的 `cordis.yml` 中加载提供方与现有的通用 subagent 工具:`subagent_codex` 绑定 `codex``subagent_claude_code` 绑定 `claude-code`。随产品交付的 CLI(命令行界面)依赖闭包,以及基础、Web 与 headless 配置都不会加载任一提供方。每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。
这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具会禁用后台执行,并使用 `maxDepth: 'provider-managed'`,将递归策略留给进程外产品,而不是发送提供方无法强制执行的限制。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。
```text
fixed tool → shared subagent service → product provider → official product process
← final answer / explicit error / cancellation ← terminal product fact
→ foreground disposal → shared process-tree termination → whole-tree exit
```
### 归属与生命周期
| 阶段 | 共享责任方 | 产品特定职责 | 可观察结果 |
| --- | --- | --- | --- |
| 解析 | `dsh-tool-subagent``ctx.subagents` | 验证产品的纯文本输入并推导原生启动参数 | 不受支持的上下文或格式错误的输入会在发布运行前报错 |
| 启动 | `dsh-subprocess` 负责每棵已获取的进程树 | 到达能够同时控制产品对话与进程的最小原生控制点 | `start()` 发布一个已存在的 `SubagentRun`,否则清理后拒绝调用 |
| 运行 | 产品负责其原生协议事实;持有方负责映射这些事实 | 只提交一项任务,并推导出一种现有的共享停止原因;Codex 仅在明确发生上下文耗尽时使用 `max-tokens` | 父级只会收到最终回答或明确失败 |
| dispose(资源释放) | 前台消费方请求释放;`dsh-subprocess` 证明进程已退出 | 关闭原生协议,并发出尽力而为的原生取消请求 | 释放操作具有幂等性,且仅在整棵进程树退出后才返回 |
## Codex 提供方
`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`,且后者不得大于仓库共享的 `MAX_TIMER_DELAY_MS`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。
发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize``initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。
`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"``agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、已识别的 app-server 帧中必需字段格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`
对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。
若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树并等待其退出,然后拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,并等待整棵进程树退出。结果失败与清理失败仍可彼此独立地观察。
Codex 0.146.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSEServer-Sent Events)生命周期。该桥接层既不是生产代理,也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。
## Claude Code 提供方
`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。SDK 的平台 `optionalDependency` 提供真实的 Claude Code 2.1.220 CLI。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 命令、参数、cwd、环境和转发的信号原样传入 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。
公开配置包含与 Codex 兄弟提供方相同、由部署方负责的两个值:显式的 `env` 覆盖项,以及须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS``disposeGraceMs`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。
只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"``is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。所有 SDK 错误子类型、标记为错误的成功消息、结果缺失、迭代器失败、协议失败或进程失败都会成为 `error`。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens``refusal`。本地取消会胜出并成为 `aborted`
启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。Query 关闭失败、进程失败和清理失败仍可彼此独立地观察。
带密钥 Claude Code e2e 直接使用官方 DeepSeek Claude Code 契约:仅在运行时提供的 DeepSeek 密钥会映射为 `ANTHROPIC_AUTH_TOKEN`,固定的官方基础 URL 会追加 `/anthropic`,主模型与 subagent 模型变量会选择文档所示的 DeepSeek 模型。该测试会启动生产提供方与真实 SDK 和 CLI,要求一个随机数作为完整答案,不会把任何凭据持久化到设置中,并等待所有受管句柄退出。
## 分发与证据
每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Loader 层级会启动 README 所示形态的用户配置,在同一个上下文中验证两个固定且只支持前台执行的工具,并且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。
Codex 证据锁定 `@openai/codex@0.146.0``codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。
带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。
Claude Code 证据锁定 Agent SDK 0.3.220 及其平台分发的 Claude Code 2.1.220 CLI。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消以及整棵进程树退出。Loader e2e 会在两个产品命令均不可用时按名称解析两个产品包,并记录零次子级启动。
带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]``deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。
项目所有者的分发授权范围限定为官方 `@anthropic-ai/claude-agent-sdk` 身份,以及每个 SDK 版本通过 `optionalDependencies` 声明的官方 Claude Code CLI 与平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 会推导并披露当前载荷集合,但不会将其声明条款重新归类为宽松条款。版本、许可证字段和载荷集合发生变化时,仍须经过常规的依赖、锁文件、兼容性、条款和声明评审;无关的非宽松运行时包继续以默认拒绝方式失败。
## 曾考虑的替代方案
**直接模型 HTTP、`codex exec` 或手写的 Claude CLI 协议。** 这些路径会绕过产品的官方可扩展集成接口,无法证明原生配置、工具、审批、结果语义或资源清理。每个提供方都改用相应的官方产品集成。
**共享产品进程辅助包。** 现有 subagent 与子进程 seam 已负责围绕任务、结果、环境和进程树的全部共享职责。新辅助包无法删除任一私有产品适配器,只会造成责任重复,因此每个适配器都会直接调用现有 seam。
**面向模型的产品选择器。** 产品可用性和身份验证属于部署事实。两个固定工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。
**以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture(测试前置数据)。
**由插件管理登录、产品主目录、模型、设置或权限。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。提供方只公开显式环境覆盖项和清理宽限期;无人值守交互会以默认拒绝方式失败。
**续接、进度、后台收集和共享父级上下文。** 已交付的用户结果是一项自包含任务和一个最终回答。产品会话、恢复、后续交互、中间消息、父级 transcript(文本记录)传递、结构化输出和后台收集都需要独立的用户契约,当前实现不会预先构建这些功能。
## 后果
用户可以安装任一或两个产品提供方,在自己的 Cordis 配置中绑定稳定的前台工具,并通过现有 subagent 契约委派一项自包含任务。官方产品集成会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。
每次委派都要承担新建产品进程和独立模型上下文的开销,且只有最终文本会到达父级。产品原生配置使行为取决于部署环境中安装的产品、账户状态和工作区设置。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。
兼容性由包级单元测试覆盖率、无密钥真实产品回环测试、带密钥 DeepSeek 随机数测试、公开 Loader 组合、已构建包与 NodeNext 消费方检查、生成的文档与声明以及仓库 CI 矩阵共同锁定。更改受支持的产品基线或 DeepSeek 端点/模型基线时必须刷新这些事实;生产环境不会另行执行运行时版本探测。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md
2026-07-30-generated-third-party-notices.md: e480954d29d5dc09ef8ecd4069059a1f0c8b1043
2026-07-30-generated-third-party-notices.zh.md: 78ba7250e797c57048078d1b4f62b7a9a5d9d561
2026-07-30-generated-third-party-notices.md: cbabd1142ad292f78a3184723182a834dfb234fc
2026-07-30-generated-third-party-notices.zh.md: b135c3626b661f1a5b0317a90b700a26d679bf1e
@@ -18,7 +18,7 @@ A hand-written inventory answers none of those durably. Roughly a hundred rows o
One trigger gap is accepted rather than worked around: lefthook inspects only files present on disk, so **deleting** a manifest runs no job, and removing a package reaches the assertion in the test lane instead. Reconstructing the staged file list to include deletions was tried and does not work — lefthook filters the list against the working tree either way. The assertion is the backstop for exactly this case.
The file discloses **direct** dependencies only. The complete npm closure with pinned versions already lives in `pnpm-lock.yaml` (`pnpm licenses list` renders it) and the Python closure in `python/sdk/uv.lock`; re-materializing either as prose would be a second, worse copy.
The file discloses **direct** dependencies by default. The complete npm closure with pinned versions already lives in `pnpm-lock.yaml` (`pnpm licenses list` renders it) and the Python closure in `python/sdk/uv.lock`; re-materializing either as prose would be a second, worse copy. The one explicit transitive disclosure is the official Claude platform payload set declared by `@anthropic-ai/claude-agent-sdk` through `optionalDependencies`, because those packages carry the distributed Claude Code executable rather than ordinary library implementation detail.
**Tiering is by declaring area, not by manifest section.** A package is a runtime dependency when any manifest outside `DEV_ONLY_AREAS` — the root manifest, `packages/support/`, `packages/client/test-runtime/`, `website/`, `examples/`, `native/` — names it under `dependencies` or `optionalDependencies`. Section names alone are wrong in both directions: a test-support package declares `vitest` under `dependencies` without shipping it, and the `bin/dsh` launcher execs through `tsx`, which no manifest declares as a runtime dependency at all (the generator marks it runtime explicitly).
@@ -26,10 +26,14 @@ The runtime tier deliberately covers **every mountable plugin**, not just what t
The manifest set is derived from the `packages:` members each `pnpm-workspace.yaml` declares — the root one and the nested Landlock workspace's — so a new member area is read the day it is declared rather than the day someone remembers to extend a list. License and repository metadata come from the installed pnpm stores, both the root one and the Landlock workspace's, so the generator requires an installed tree and fails loud when a package resolves to neither, rather than emitting an empty cell. `OVERRIDES` carries the packages whose published manifest cannot answer — Rust-built npm bins that omit `license`, and the `modelcontextprotocol/servers` packages whose repository is mid MIT→Apache-2.0 relicensing, so their effective terms are per-contribution. A runtime dependency whose license is not on the permissive list is a hard error: shipping copyleft is a distribution decision, not something a regenerated table may absorb silently. Vendored packages are cross-checked against `vendor/README.md` and rejected if any is not MIT, and `pnpm-workspace.yaml`'s `patchedDependencies` are listed under the runtime table because pnpm applies those patches at install time — shipped artifacts carry modified copies of `@earendil-works/pi-tui` and `node-pty`, and the patch files are the record of what changed.
The project owner separately authorizes distribution of every official `@anthropic-ai/claude-agent-sdk` version and the official Claude Code CLI/platform payloads that version declares through `optionalDependencies`. The generator represents this as one exact direct-package identity exception, not as a permissive-license override: `SEE LICENSE IN README.md` and `SEE LICENSE IN LICENSE.md` remain non-permissive classifications, and every unrelated non-permissive runtime still fails closed. When the SDK is present, the generator reads its installed manifest, rejects optional identities outside the official SDK payload prefix, derives the current SDK, CLI, and payload versions, verifies the installed host payload's identity, version, and declared-license field, and renders the complete SDK-declared payload set in a separate notices section. Version, declared-license, and payload-set changes do not require new identity authorization, but they still require ordinary dependency, lockfile, compatibility, terms, and notices review.
## Testing
The same spec that asserts freshness pins the tiering rule against fixture manifests — including the two cases that motivate it, a `dependencies` entry of a test-support package and a plugin package no app mounts. It also pins the parsers against the shapes that would otherwise drop a package without a word: a `vendor/README.md` table that stops covering a vendored directory, a requirement array holding extras (`"httpx[http2]"`), a requirement with no version at all, an author-named `[dependency-groups]` table, and a workspace member area absent from any hardcoded list. Each of those is a silent-omission path, which is the failure mode a disclosure file cannot afford.
The Claude distribution tests prove that only the exact direct SDK identity bypasses the ordinary non-permissive-runtime rejection, that the bypass does not change license classification, and that the payload set comes from the SDK manifest rather than a version or platform allowlist. Wrong SDK identities, missing payloads, and unrelated optional package identities all fail.
## Alternatives considered
**Keep the hand-written file and review it at release time.** Reviewing a hundred derived rows by eye is exactly the work a generator does correctly, and the file's own claim — that it lists every direct dependency — would be unverified between releases.
@@ -42,6 +46,8 @@ The same spec that asserts freshness pins the tiering rule against fixture manif
**Tier by reachability from the shipped assemblies only** (`apps/*` plus `python/sdk-runtime`). This produces a tighter runtime tier, but classifies the MCP client and the OpenTelemetry exporter as development-only even though a user running the installed repository can mount them. It understates the disclosure, which is the wrong direction to err for a legal notice.
**Treat the Claude SDK terms as permissive or add a reusable non-permissive allowlist.** Either shape would misstate the upstream declaration and let an unrelated runtime inherit authorization it was never granted. The narrow exception keys only the official direct SDK identity, while its optional payload identities are accepted solely as data declared by that SDK and remain visibly non-permissive.
**Emit the notices as a bilingual pair.** Every other root document is paired, but the file is a table of upstream package names, SPDX identifiers, and URLs; the translatable surface is a handful of section blurbs. `scripts/translation-pairing.ts` scopes discovery to `README*`, `.agents/notes/**`, `docs/**`, and `python/**`, so a root non-README file is outside the bilingual corpus by construction, and the README pair carries the bilingual entry points into it.
## Consequences
@@ -51,3 +57,5 @@ A dependency edit now carries a regenerated notices file into the same commit. C
The generator needs an installed tree, which makes it heavier than a pure-source generator, and a new package with unusable published metadata needs an `OVERRIDES` entry rather than silently rendering a blank license. Both failures are loud and name the remedy.
The tiering rule is a policy encoded in one constant. Adding a workspace area that never ships — a second test-infrastructure tier, another site — requires extending `DEV_ONLY_AREAS`, or its dependencies will be disclosed as runtime.
The Claude identity exception is deliberately narrower than the payload disclosure it activates. Upgrading the SDK needs no new owner authorization, but regeneration fails unless the installed SDK exposes its version, CLI version, and at least one official platform payload, and unless the current host payload matches the SDK declaration. Maintainers still review changed terms and compatibility; the generator prevents the authorization from silently widening to another package.
@@ -18,7 +18,7 @@ Status: implemented
有一处触发缺口是接受而非绕过的:lefthook 只检视磁盘上存在的文件,因此**删除**清单文件不会触发任何任务,移除一个包会落到测试 lane 的断言上。重构暂存文件列表以纳入删除的做法试过,不成立——无论怎么给列表,lefthook 都会拿工作树过滤一遍。这个场景正由断言兜底。
文件只披露**直接**依赖。完整的 npm 闭包连同锁定版本已记录在 `pnpm-lock.yaml``pnpm licenses list` 可渲染),Python 闭包记录在 `python/sdk/uv.lock`;再用散文誊一遍只会得到一份更差的副本。
文件默认只披露**直接**依赖。完整的 npm 闭包连同锁定版本已记录在 `pnpm-lock.yaml``pnpm licenses list` 可渲染),Python 闭包记录在 `python/sdk/uv.lock`;再用散文誊一遍只会得到一份更差的副本。唯一明确披露的传递依赖,是 `@anthropic-ai/claude-agent-sdk` 通过 `optionalDependencies` 声明的官方 Claude 平台载荷集合,因为这些包承载随产品分发的 Claude Code 可执行文件,而非普通的库实现细节。
**分层依据是声明方所在区域,而非清单字段名。** 只要 `DEV_ONLY_AREAS` 之外的任一清单——即根清单、`packages/support/``packages/client/test-runtime/``website/``examples/``native/` 之外——在 `dependencies``optionalDependencies` 里点名某个包,它就是运行时依赖。单看字段名在两个方向上都会出错:测试支撑包把 `vitest` 写在 `dependencies` 里却并不交付它;而 `bin/dsh` 启动器 exec 经过的 `tsx`,根本没有任何清单把它声明为运行时依赖,只能由生成器显式标记。
@@ -26,10 +26,14 @@ Status: implemented
清单集合由两个 `pnpm-workspace.yaml`——根工作区与嵌套的 Landlock 工作区——各自声明的 `packages:` 成员派生,因此新增成员区域在声明当天就会被读取,而不必等谁想起来去补一份列表。许可证与仓库地址取自已安装的 pnpm store,根 store 与 Landlock 工作区的 store 都会查;某个包两处都解析不到时直接失败,而不是留下空单元格。`OVERRIDES` 收录已发布清单答不上来的包:用 Rust 构建、发布时省略 `license` 字段的 npm 可执行包,以及 `modelcontextprotocol/servers` 系列——该仓库正处在 MIT 向 Apache-2.0 的重新许可过程中,实际条款按贡献逐条而定。运行时依赖的许可证若不在宽松清单内即为硬失败:交付 copyleft 是一项分发决策,不该被一次重新生成悄悄吸收。被源码收编的包会与 `vendor/README.md` 交叉核对,出现非 MIT 即报错;`pnpm-workspace.yaml``patchedDependencies` 列在运行时表格之后,因为 pnpm 在安装期就会打上这些补丁——交付产物携带的是改动过的 `@earendil-works/pi-tui``node-pty`,补丁文件本身就是改动的完整记录。
项目所有者另行授权分发每个官方 `@anthropic-ai/claude-agent-sdk` 版本,以及该版本通过 `optionalDependencies` 声明的官方 Claude Code CLI 与平台载荷。生成器将其表示为一项精确匹配直接包身份的例外,而非宽松许可证覆盖项:`SEE LICENSE IN README.md``SEE LICENSE IN LICENSE.md` 仍归类为非宽松,所有无关的非宽松运行时依赖仍以默认拒绝方式失败。存在该 SDK 时,生成器会读取其已安装清单,拒绝不符合官方 SDK 载荷前缀的可选包身份,推导当前 SDK、CLI 与载荷版本,核验已安装宿主载荷的身份、版本和声明许可证字段,并在单独的声明章节中渲染 SDK 声明的完整载荷集合。版本、声明许可证和载荷集合发生变化时无需新的身份授权,但仍须经过常规的依赖、锁文件、兼容性、条款和声明评审。
## Testing
断言新鲜度的同一个 spec 也用夹具清单钉住分层规则,覆盖促成该规则的两个场景:测试支撑包的 `dependencies` 条目,以及没有任何应用挂载的插件包。它还把各解析器钉在那些原本会让某个包无声消失的形态上:不再覆盖全部收编目录的 `vendor/README.md` 表、含 extras 的依赖数组(`"httpx[http2]"`)、完全不带版本的依赖、作者自取名字的 `[dependency-groups]` 表,以及任何硬编码列表都不含的工作区成员区域。这些都是静默漏报路径——正是披露文件最担不起的失败方式。
Claude 分发测试证明:只有精确匹配的直接 SDK 身份会绕过通常的非宽松运行时拒绝;该绕过不会改变许可证分类;载荷集合来自 SDK 清单,而非版本或平台允许列表。SDK 身份错误、载荷缺失或存在无关的可选包身份时,测试都会失败。
## Alternatives considered
**保留手写文件,发版时人工过一遍。** 用肉眼审阅上百行推导数据,恰恰是生成器能做对的活;而且在两次发版之间,文件自称「列出全部直接依赖」这句话无人验证。
@@ -42,6 +46,8 @@ Status: implemented
**只按已交付装配的可达性分层**`apps/*``python/sdk-runtime`)。这样得到的运行时层更紧凑,但会把 MCP 客户端与 OpenTelemetry 导出器判为仅开发用途——而运行已安装仓库的用户完全可以挂载它们。这会低估披露,对法务通告来说错在了更危险的一侧。
**将 Claude SDK 条款视为宽松条款,或新增可复用的非宽松允许列表。** 两种方案都会误述上游声明,并让无关运行时依赖继承从未授予它的授权。这项窄例外只匹配官方直接 SDK 身份;其可选载荷身份仅作为该 SDK 声明的数据被接受,并继续明确归类为非宽松。
**把披露文件做成双语对。** 其他根文档都是成对的,但这份文件是上游包名、SPDX 标识与网址构成的表格,可翻译的只有寥寥几段章节导语。`scripts/translation-pairing.ts` 的发现范围限定在 `README*``.agents/notes/**``docs/**``python/**`,根目录下的非 README 文件在构造上就不属于双语语料;双语入口由 README 对承担。
## Consequences
@@ -51,3 +57,5 @@ Status: implemented
生成器需要已安装的工作树,因此比纯源码生成器更重;发布元数据不可用的新包需要补一条 `OVERRIDES`,而不是默默渲染出空白许可证。这两类失败都会明确报错并指出补救方式。
分层规则是编码在一个常量里的政策。若新增了不参与交付的工作区区域——第二层测试基础设施、另一个站点——就要同步扩展 `DEV_ONLY_AREAS`,否则其依赖会被当作运行时依赖披露出去。
Claude 身份例外刻意比其启用的载荷披露范围更窄。升级 SDK 无需新的所有者授权,但如果已安装的 SDK 未公开自身版本、CLI 版本和至少一个官方平台载荷,或当前宿主载荷与 SDK 声明不符,重新生成就会失败。维护者仍须评审发生变化的条款与兼容性;生成器会阻止授权悄然扩大到其他包。
@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md
2026-07-07-claude-code-and-codex-subagent-backends.md: ee8576f97a9fdef8c88dcad3a73f28b63ca3ebe1
2026-07-07-claude-code-and-codex-subagent-backends.zh.md: bd76a8f34d86b704494b56331c47ea89bfc8a0aa
@@ -1,89 +0,0 @@
# Agent Note: Claude Code and Codex subagent backends (out-of-process delegation to external coding agents)
Status: proposed
English | [中文](2026-07-07-claude-code-and-codex-subagent-backends.zh.md)
## Problem
The subagent seam ([the seam Agent Note](../../implemented/feature/2026-06-21-subagent-capability-seam.md)) hosts multiple named providers on `ctx.subagents`, and the ACP backend ([the ACP backend Agent Note](../../implemented/feature/2026-06-22-acp-subagent-backend.md)) proved the seam generalizes across a process boundary; its Future-providers section explicitly named the Codex app-server and the Claude Code Agent SDK as mechanically similar siblings. Those two are the engines actually worth delegating to today: a harness turn should be able to hand a self-contained task to a real Claude Code or a real Codex — a separate product with its own model, tools, and sandbox — and get back one final answer, without the parent deployment leaking its secrets into the child or the child's behavior silently depending on whatever `~/.claude` / `~/.codex` state exists on the host machine.
## Proposal
Two sibling provider packages, structural variants of the ACP backend, plus one extraction:
- `@deepseek-ai/dsh-subagent-claude-code` — drives a Claude Code child through `@anthropic-ai/claude-agent-sdk`'s `query()` (the SDK runs in the parent process and spawns its bundled `claude` CLI as the subprocess). Provider name `claude-code`: the child is the Claude Code *product*, not an Anthropic model adapter — "claude" stays reserved for a future `dsh-llm` adapter.
- `@deepseek-ai/dsh-subagent-codex` — spawns `codex app-server` and drives one thread/turn over its JSON-RPC-over-stdio protocol with a hand-rolled newline-JSON client (~200300 lines) in the package.
- `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`buildChildEnv`), the EOF → SIGTERM → SIGKILL dispose ladder, and new isolated-config-dir helpers (`mkdtemp` create, best-effort remove). The ACP backend migrates onto it; `bash-local`'s sibling copy is left alone to bound the change.
Both providers copy the ACP backend's seam posture verbatim: fresh child per `start`, exactly one prompt round-trip, capabilities all `false`, `inheritsParentContext: false`, `request.parent`/`request.agentOptions` ignored, `id = SessionId(randomUUID())`, `result` never rejects — child-level failure flattens to a stop reason and the original error goes to `ctx.logger` via an `onError` spec callback. Model exposure is zero new code: `dsh-tool-subagent` is loaded once per provider with a distinct `toolName` (`subagent_claude_code`, `subagent_codex`). No new session events are needed — the only model-visible artifact is the tool result, so reconstructability holds exactly as it did for ACP. To be explicit about the boundary: the session log reconstructs the model-visible transcript, not workspace mutation history — a child granted write access mutates files as an ambient side effect outside the log, exactly as the bash tools and the ACP backend already do; replay reproduces requests, not the disk.
## Verified interface facts (pinned versions)
Both integration surfaces were verified against pinned implementations before this proposal — types and bundled source read, keyless spikes run — not from vendor docs alone. The pins are the verification baseline, not a runtime contract: the backends perform no runtime version probe (no `codex --version` gate, no SDK version sniffing). Compatibility is enforced at development time — every dependency bump re-runs the keyless suites against the real load path — and at runtime by failing loudly: a protocol-level surprise settles `error` via `onError`, never a silent misbehavior.
**`@anthropic-ai/claude-agent-sdk` 0.3.202.** `options.env` REPLACES the child environment (no merge with `process.env`), which is exactly what the scrub needs. `settingSources` defaults to loading ALL filesystem settings — isolation requires explicitly passing `[]`. Result subtypes are `success` | `error_during_execution` | `error_max_turns` | `error_max_budget_usd` | `error_max_structured_output_retries`. On abort the SDK escalates the CLI child itself: stdin EOF immediately, SIGTERM ~2s later if the child ignores it (observed; no leftover processes) — no bespoke kill fallback needed. `outputFormat: {type: 'json_schema'}` and an `agents` option exist, giving future landing points for the seam's `outputSchema` capability and named subagent types; both are out of scope here.
**codex CLI 0.142.5, `codex app-server` (v2 vocabulary).** LF-delimited JSON, JSON-RPC 2.0 shapes with the `"jsonrpc"` header omitted.
- Lifecycle: `initialize{clientInfo}` + `initialized``thread/start` (accepts `cwd`, `model`, `sandbox`, `approvalPolicy`, `ephemeral`; succeeds unauthenticated) → `turn/start{threadId, input:[{type:'text',text}]}` returns an `inProgress` turn immediately; the terminal signal is the `turn/completed` notification carrying `Turn{status: completed|interrupted|failed|inProgress, error}`.
- Approvals are server-initiated requests — `item/commandExecution/requestApproval`, `item/fileChange/requestApproval`, `item/permissions/requestApproval`, `item/tool/requestUserInput`, `mcpServer/elicitation/request` — answered with `accept`/`decline`-family decisions.
- Auth: `account/login/start{type:'apiKey', apiKey}` is a first-class RPC and `account/read` reports `requiresOpenaiAuth` — and an unauthenticated `turn/start` does NOT fail fast (it hangs in retry), so the backend MUST pre-check auth and settle `error` loudly instead of waiting on the turn.
- Isolation: `CODEX_HOME` redirection is honored (the `initialize` response echoes it, so tests can assert isolation), and `ephemeral: true` threads leave no session files at all.
## Isolation and credentials
Deployments authenticate with API keys only, and the child must not see the host user's Claude Code / Codex configuration: behavior has to be a function of `cordis.yml` alone. Each run gets a fresh `mkdtemp` config dir — `CLAUDE_CONFIG_DIR` for Claude Code (paired with an explicit `settingSources: []`), `CODEX_HOME` for Codex — removed best-effort on dispose; a config field can pin a persistent dir instead. The child env reuses the ACP backend's `buildChildEnv` semantics verbatim via the extraction: the ambient env is forwarded MINUS credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `config.env` layered on top — so `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive and the CLIs run normally, while only credential-shaped ambient vars are scrubbed (`ANTHROPIC_API_KEY` enters explicitly through `config.env` for Claude Code), and the Codex key travels via the `account/login/start` RPC into the isolated `CODEX_HOME` rather than a hand-written `auth.json`.
## Permission and approval policy
Instead of collapsing to ACP's single `permission: allow|reject` knob, each backend exposes its engine's native vocabulary as config, with conservative defaults: Claude Code gets `permissionMode` (default `default`) plus `permission: allow|reject` (default `reject`) as the `canUseTool` auto-answer for whatever falls through; Codex gets `sandboxMode` (default `read-only`) and `approvalPolicy` (default `never`) plus the same `permission` fallback for approval requests that still arrive. Defaults are deliberately do-no-harm (the out-of-box child cannot write files); examples demonstrate opening up (`acceptEdits` / `workspace-write`). The mechanical rule: EVERY server-initiated request is settled programmatically and promptly — the enumerated approval/user-input/elicitation requests by the configured policy, an unknown request method with a JSON-RPC method-not-found error response (never left pending), unknown notifications consumed — so no child request can wedge a turn waiting on an answer that will never come. Prompts never reach a human in this cut, matching ACP.
## StopReason mapping
Claude Code: `success``completed`; `error_max_turns`, `error_during_execution`, `error_max_budget_usd`, `error_max_structured_output_retries``error` (aligning with the ACP call on `max_turn_requests`: an unfinished task is not success); generator abort → `aborted`; anything unknown → `error`. Codex: `Turn.status` `completed``completed`; `interrupted``aborted`; `failed` with `codexErrorInfo: 'contextWindowExceeded'``max-tokens`, any other `failed``error`; transport/spawn/auth-precheck failure → `error` (or `aborted` if cancel was requested). In both, `cancel()` is the ACP shape: flag + abort/interrupt + a cancel-settled race arm so an uncooperative child cannot stall the result.
Liveness posture, stated explicitly: teardown timing is config, turn duration is not. Both backends take the dispose ladder's grace periods as defaulted validated config fields (the ACP backend's `disposeEofGraceMs`/`disposeGraceMs` shape, carried by the extraction), but there is deliberately NO turn-duration or startup timeout — matching ACP, liveness during a turn belongs to the caller via `cancel()`/the abort signal, a subagent turn is legitimately minutes long, and the Codex auth precheck removes the one verified guaranteed-hang; a deployment wanting a wall-clock bound cancels from the parent.
## Testing
Named at every tier per the root AGENTS.md rule, and de-risked up front:
- **Keyless unit/integration**, mirroring the ACP spec list per backend (round-trip and output accumulation, every stop mapping, both cancel paths, already-aborted, permission auto-answer under both policies, unknown-message tolerance, bad-command spawn failure, HMR provider cleanup, export shape, isolation assertions on child env and temp-dir removal; Codex adds the auth-precheck failure path). Claude Code's harness is a scripted fake `claude` executable behind `pathToClaudeCodeExecutable` driven by the REAL SDK — a spike already passed end-to-end keyless in 24ms (the fake CLI answers one `control_request/initialize` and speaks plain stream-json, ~40 lines). Codex's harness is a scripted mock app-server subprocess speaking the verified wire protocol, the `mock-acp-server.ts` shape.
- **With-key e2e** per backend: the real engine does real file work verified on disk, under a pinned opened-up config so acceptance and the do-no-harm defaults don't collide — `permissionMode: 'acceptEdits'` for Claude Code, `sandboxMode: 'workspace-write'` + `approvalPolicy: 'never'` for Codex; self-skips report exactly what is missing (binary vs key). CI has no secrets, so these run locally per the with-key policy.
- **Snapshot**: deferred as `TODO(claude-code-subagent-replay)` / `TODO(codex-subagent-replay)` — the same distinct replay shape the ACP backend deferred ([the per-session replay Agent Note](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md)); the keyless suites carry deterministic coverage meanwhile.
## Alternatives considered
### Why not the official `@openai/codex-sdk` instead of a hand-rolled client?
The dispose ladder and env scrub require owning the child process (spawn args, env, signals, exit await); the SDK hides the process. The wire format is trivial to frame (LF JSON), the shapes are generatable per pinned version (`codex app-server generate-json-schema`), and the repo precedent (`hook-protocol`) is to own thin protocol cores rather than wrap someone's runtime. The SDK would save protocol-evolution maintenance but costs the exact control this backend exists to have.
### Why not a model-visible `subagent_type` parameter (one Task-style tool)?
Claude Code's own Task tool puts the subagent type in the model-facing schema, selecting a prompt-plus-toolset persona. Here the choice is between EXECUTION ENGINES, and only the deployer knows which engines have credentials configured — so selection stays deployment config, preserving `dsh-tool-subagent`'s documented one-provider-per-tool contract. A persona-style type selector would be a separate Agent Note against the tool, not the backends.
### Why not login-state credentials and the user's own config?
Inheriting `~/.claude` / `~/.codex` (subscription login, user settings, skills, MCP servers) would make child behavior depend on host-machine state and punch an implicit exception through the "credentials enter explicitly via `config.env`, never ambiently" rule the ACP backend and bash executor established. API-key-only plus forced config-dir isolation keeps runs reproducible; deployments wanting shared state can point the config-dir field at a persistent directory deliberately.
### Why not a driver-injection seam for the Claude Code keyless tests?
Injecting a fake `query()` would mock our own boundary and leave the real SDK load path untested (the real-over-mock policy in docs/testing.md). The risk that justified considering it — the SDK↔CLI stream-json control protocol being internal — was retired by the spike: the fake-CLI harness works against the real pinned SDK today. If an SDK upgrade breaks the mock, the keyless suite fails the upgrade PR, which is the gate working.
### Why not ACP adapters (e.g. `claude-code-acp`) reusing the existing backend?
Community shims wrap both engines in ACP, which would make them "just config" on `dsh-subagent-acp`. But that inserts an unofficial third-party layer between the harness and the engine, erases the native control surfaces this Agent Note exposes (permissionMode, sandboxMode/approvalPolicy, config-dir isolation, apiKey RPC), and trades first-party protocol stability for a shim's release cadence. First-party surfaces — the Agent SDK and the app-server — are the supported integration points.
## Acceptance criteria
On a machine with both engines and keys configured: a REPL-driven model completes one real file task through `subagent_claude_code` and one through `subagent_codex`, the tool result being the child's final answer, with only `tool/call` + `tool/result` in the parent session log. Keyless suites pass at 100% per-file coverage in a credential-less environment, asserting isolation (scrubbed child env, no temp config dirs left after dispose) and that child behavior is unchanged by the presence or absence of `~/.claude` / `~/.codex`. Cancelling a parent turn quiesces both backends in bounded time with no leftover child processes. E2e suites self-skip cleanly, naming the missing prerequisite.
## Risks
- `codex app-server` is CLI-flagged experimental and its v1/v2 vocabularies coexist; the client pins 0.142.5, implements v2 only, and consumes unknown methods/notifications without crashing, but a future codex bump can still force rework (regenerate schemas and re-run the keyless suite on every bump — the development-time enforcement behind the no-runtime-version-probe stance above).
- The Claude Code fake-CLI mock rides an internal protocol: any SDK upgrade must go through the keyless suite, and a breaking control-protocol change means reworking the mock (fallback: the driver-injection seam rejected above becomes the escape hatch).
- The SDK's optionalDependencies weigh ~280MB per platform — accepted, and confined to the one backend package.
- The SDK's SIGKILL branch beyond EOF→SIGTERM was not observed and is trusted; e2e keeps a no-leftover-process assertion.
- Codex is a deployment prerequisite (no npm-bundled binary); a missing or incompatible binary surfaces as a loud spawn/protocol `error`, not a version probe.
- Every run pays a fresh child process and only the final answer surfaces — thoughts, tool cards, and usage are consumed and dropped; pooling, intermediate-progress surfacing, `sendMessage`/`resume`, `outputSchema` via the SDK's `outputFormat`, and named subagent types via the SDK's `agents` option are all deliberate deferrals.
@@ -1,89 +0,0 @@
# Agent Note: Claude Code 与 Codex subagent 后端(向外部编码 agent 的进程外委派)
Status: proposed
[English](2026-07-07-claude-code-and-codex-subagent-backends.md) | 中文
## 问题
subagent seam[seam Agent Note](../../implemented/feature/2026-06-21-subagent-capability-seam.md))在 `ctx.subagents` 上托管多个命名提供方,ACPAgent Client Protocol)后端([ACP 后端 Agent Note](../../implemented/feature/2026-06-22-acp-subagent-backend.md))证明了该 seam 能跨越进程边界泛化;其「未来提供方」一节明确将 Codex app-server 与 Claude Code Agent SDK 列为实现机制相似的同类方案。如今真正值得委派的就是这两个引擎:harness 的一个轮次应能把一个自包含任务交给真实的 Claude Code 或真实的 Codex——一个拥有自身模型、工具与沙箱的独立产品——并取回一个最终答案,同时父部署不向子进程泄漏密钥,子进程行为也不静默依赖宿主机上碰巧存在的 `~/.claude` / `~/.codex` 状态。
## 提案
两个兄弟提供方包,作为 ACP 后端的结构变体,另加一次提取:
- `@deepseek-ai/dsh-subagent-claude-code`:通过 `@anthropic-ai/claude-agent-sdk``query()` 驱动一个 Claude Code 子进程(SDK 在父进程中运行,并将其内置的 `claude` CLI(命令行界面)作为子进程 spawn)。提供方名称为 `claude-code`:子进程是 Claude Code 这个*产品*,而非 Anthropic 模型适配器——「claude」保留给未来的 `dsh-llm` 适配器。
- `@deepseek-ai/dsh-subagent-codex`spawn `codex app-server`,通过其 JSON-RPC-over-stdio 协议驱动一个 thread 及其中的一个轮次,使用包内一个手写的换行 JSON 客户端(约 200–300 行)。
- `@deepseek-ai/dsh-subagent-process`:纯库(沿用 `subagent-inprocess` 的先例),提取 `dsh-subagent-acp` 已有且两个新后端都需要的内容:凭证环境清洗(`buildChildEnv`)、EOF → SIGTERM → SIGKILL 的 dispose(资源释放)阶梯,以及新的隔离配置目录辅助函数(`mkdtemp` 创建、尽力删除)。ACP 后端迁移到该库上;`bash-local` 的兄弟副本保持不动以限制变更范围。
两个提供方逐字复制 ACP 后端的 seam 姿态:每次 `start` 创建全新子进程、恰好一次提示词往返、所有能力均为 `false``inheritsParentContext: false`、忽略 `request.parent`/`request.agentOptions``id = SessionId(randomUUID())`,且 `result` 从不 reject——子进程级失败扁平化为 stop reason,原始错误则通过 `onError` spec 回调送到 `ctx.logger`。模型暴露无需新代码:每个提供方各加载一次 `dsh-tool-subagent`,使用不同的 `toolName``subagent_claude_code``subagent_codex`)。无需新的会话事件——唯一的模型可见产物是工具结果,因此可重建性与 ACP 完全相同。明确边界:会话日志重建模型可见的 transcript(文本记录),而不是工作区变更历史——获准写入的子进程将文件作为日志之外的环境副作用进行修改,与 bash 工具和 ACP 后端现有行为完全一致;回放复现请求,而非磁盘。
## 已验证的接口事实(固定版本)
两个集成面在本提案之前均已针对固定版本进行了验证——阅读类型与打包源码、运行无需密钥的 spike——而非仅依赖厂商文档。固定版本是验证基线,不是运行时契约:后端不执行运行时版本探测(无 `codex --version` 门禁、无 SDK 版本嗅探)。兼容性在开发时强制执行——每次依赖升级都会针对真实加载路径重跑无密钥套件——在运行时则通过显式失败来保障:协议层面的意外通过 `onError` 结算为 `error`,绝不静默异常。
**`@anthropic-ai/claude-agent-sdk` 0.3.202。** `options.env` 会替换子进程环境(不与 `process.env` 合并),恰好满足清洗需求。`settingSources` 默认加载所有文件系统设置——隔离要求显式传入 `[]`。结果子类型为 `success` | `error_during_execution` | `error_max_turns` | `error_max_budget_usd` | `error_max_structured_output_retries`。中止时 SDK 自行逐级加强对 CLI 子进程的终止措施:立即关闭 stdin,约 2 秒后若子进程未退出则发送 SIGTERM(已观察到;无残留进程)——无需自定义 kill 回退。`outputFormat: {type: 'json_schema'}``agents` 选项已存在,为 seam 的 `outputSchema` 能力和命名 subagent 类型提供了未来着陆点;两者均不在本 Agent Note 范围内。
**codex CLI 0.142.5`codex app-server`v2 词汇)。** LF 分隔的 JSONJSON-RPC 2.0 形状但省略 `"jsonrpc"` 头。
- 生命周期:`initialize{clientInfo}` + `initialized``thread/start`(接受 `cwd``model``sandbox``approvalPolicy``ephemeral`;未认证即可成功)→ `turn/start{threadId, input:[{type:'text',text}]}` 立即返回一个 `inProgress` 的轮次;终止信号是携带 `Turn{status: completed|interrupted|failed|inProgress, error}``turn/completed` 通知。
- 审批是服务端发起的请求——`item/commandExecution/requestApproval``item/fileChange/requestApproval``item/permissions/requestApproval``item/tool/requestUserInput``mcpServer/elicitation/request`——以 `accept`/`decline` 系列决策应答。
- 认证:`account/login/start{type:'apiKey', apiKey}` 是一等 RPC`account/read` 报告 `requiresOpenaiAuth`——且未认证的 `turn/start` 不会快速失败(它会挂在重试中),因此后端必须预检认证状态,并在失败时明确结算为 `error`,而非等待轮次。
- 隔离:`CODEX_HOME` 重定向被尊重(`initialize` 响应会回显它,测试可据此断言隔离),`ephemeral: true` 的 thread 不留任何会话文件。
## 隔离与凭证
部署只使用 API key 认证,子进程不得看到宿主用户的 Claude Code / Codex 配置:行为必须只由 `cordis.yml` 决定。每次运行获得一个全新的 `mkdtemp` 配置目录——Claude Code 使用 `CLAUDE_CONFIG_DIR`(并显式设置 `settingSources: []`),Codex 使用 `CODEX_HOME`——dispose 时尽力删除;配置字段也可以固定一个持久目录。子进程环境通过提取逐字复用 ACP 后端的 `buildChildEnv` 语义:转发环境变量,但移除凭证形态的变量(`/KEY|SECRET|TOKEN/i`),再叠加 `config.env`——因此 `PATH``HOME``TMPDIR`、locale 和代理变量保留,CLI 正常运行;只有环境中的凭证形态变量被清洗(Claude Code 的 `ANTHROPIC_API_KEY` 通过 `config.env` 显式进入),Codex key 则通过 `account/login/start` RPC 进入隔离的 `CODEX_HOME`,而非手写 `auth.json`
## 权限与审批策略
每个后端不压缩为 ACP 单一的 `permission: allow|reject` 旋钮,而把引擎原生词汇作为配置暴露,并采用保守默认值:Claude Code 获得 `permissionMode`(默认 `default`)以及 `permission: allow|reject`(默认 `reject`),后者作为所有未被前者处理的请求的 `canUseTool` 自动应答;Codex 获得 `sandboxMode`(默认 `read-only`)和 `approvalPolicy`(默认 `never`),以及同一个 `permission` 后备值,用来应答仍然到达的审批请求。默认值刻意做到不造成损害(开箱即用的子进程无法写文件);示例演示如何开放权限(`acceptEdits` / `workspace-write`)。机械规则是:每一个服务端发起的请求都由程序迅速结算——枚举出的审批/用户输入/elicitation 请求按配置策略应答,未知请求方法用 JSON-RPC method-not-found 错误响应(绝不保持 pending),未知通知被消费——因此任何子进程请求都不会因等待永远不会到来的应答而卡住轮次。这一版中提示词不会到达人类,与 ACP 一致。
## StopReason 映射
Claude Code`success``completed``error_max_turns``error_during_execution``error_max_budget_usd``error_max_structured_output_retries``error`(与 ACP 对 `max_turn_requests` 的处理对齐:未完成的任务不是成功);生成器中止 → `aborted`;未知值 → `error`。Codex`Turn.status``completed``completed``interrupted``aborted``failed``codexErrorInfo: 'contextWindowExceeded'``max-tokens`,其他 `failed``error`;传输/spawn/认证预检失败 → `error`(若已请求取消则为 `aborted`)。两者中,`cancel()` 采用 ACP 形状:标志位 + abort/interrupt + 一个 cancel-settled 竞争分支,使不合作的子进程无法阻塞结果。
活性姿态,明确声明:teardown 时序是配置项,轮次时长不是。两个后端将 dispose 阶梯的宽限期作为带默认值的已验证配置字段(ACP 后端的 `disposeEofGraceMs`/`disposeGraceMs` 形状,由提取库承载),但刻意不设轮次时长或启动超时——与 ACP 一致:轮次期间的活性由调用方通过 `cancel()`/abort signal 掌控,subagent 轮次持续数分钟也属合理,而 Codex 认证预检消除了唯一已验证的必然挂起场景;需要墙钟上限的部署从父侧取消即可。
## 测试
依照根 AGENTS.md 规则在每个层级明确命名,并预先消除风险:
- **无密钥单元/集成测试**:每个后端都镜像 ACP spec 清单(往返和输出累积、每种 stop 映射、两条取消路径、已中止、两种策略下的权限自动应答、未知消息容错、错误命令的 spawn 失败、HMR(热模块替换)提供方清理、导出形状、对子进程环境隔离和临时目录删除的断言;Codex 另加认证预检失败路径)。Claude Code harness 是通过 `pathToClaudeCodeExecutable` 接入真实 SDK 的脚本化假 `claude` 可执行文件——一个 spike 已在 24 ms 内完成端到端无密钥验证(假 CLI 应答一次 `control_request/initialize`,并使用普通 stream-json 通信,约 40 行)。Codex harness 是讲已验证协议格式的脚本化 mock app-server 子进程,沿用 `mock-acp-server.ts` 形状。
- **有密钥 e2e 测试**:每个后端的真实引擎执行真实文件操作,并通过磁盘状态进行验证,固定使用开放后的配置,以免验收与不造成损害的默认值冲突——Claude Code 使用 `permissionMode: 'acceptEdits'`Codex 使用 `sandboxMode: 'workspace-write'` + `approvalPolicy: 'never'`;自跳过会准确报告缺失的是二进制还是 key。CI 没有密钥,因此依照有密钥策略在本地运行。
- **快照测试**:以 `TODO(claude-code-subagent-replay)` / `TODO(codex-subagent-replay)` 推迟——即 ACP 后端也推迟的独立回放形状([按会话回放 Agent Note](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md));在此期间由无密钥套件提供确定性覆盖。
## 曾考虑的替代方案
### 为什么不用官方 `@openai/codex-sdk` 而手写客户端?
dispose 阶梯和环境清洗要求拥有子进程(spawn 参数、env、信号、exit 等待);SDK 隐藏了进程。协议格式(wire format)极其简单(LF JSON),形状可按固定版本生成(`codex app-server generate-json-schema`),仓库先例(`hook-protocol`)是拥有薄协议核心而非包装他人的运行时。SDK 能节省协议演进的维护成本,但代价是失去本后端存在的意义所在的精确控制。
### 为什么不用模型可见的 `subagent_type` 参数(单一 Task 风格工具)?
Claude Code 自身的 Task 工具将 subagent 类型放在模型可见的 schema 中,选择一个提示词 + 工具集人格。这里的选择是在执行引擎之间做出的,而只有部署者知道哪些引擎配置了凭证——因此选择留在部署配置层,保持 `dsh-tool-subagent` 文档中的「一个提供方对应一个工具」契约。人格风格的类型选择器应是针对工具的另一个 Agent Note,而非针对后端。
### 为什么不用登录态凭证和用户自身的配置?
继承 `~/.claude` / `~/.codex`(订阅登录、用户设置、skill(技能)、MCP 服务器)会使子进程行为依赖宿主机状态,并在 ACP 后端和 bash 执行器确立的「凭证通过 `config.env` 显式进入,绝不隐式继承」规则上打开一个隐式例外。仅 API key 加强制配置目录隔离使运行可复现;需要共享状态的部署可以有意将配置目录字段指向一个持久目录。
### 为什么不为 Claude Code 无密钥测试注入驱动层 seam?
注入假的 `query()` 会 mock 我们自己的边界,使真实 SDK 加载路径未被测试(docs/testing.md 中的 real-over-mock 策略)。曾考虑此方案的风险——SDK↔CLI 的 stream-json 控制协议是内部实现——已被 spike 消除:假 CLI harness 今天能对真实固定版本的 SDK 正常工作。如果 SDK 升级破坏了 mock,无密钥套件会让升级 PR(Pull Request)失败,这正是门禁在发挥作用。
### 为什么不用 ACP 适配器(如 `claude-code-acp`)复用既有后端?
社区 shim 将两个引擎包装为 ACP,这会使它们在 `dsh-subagent-acp` 上变成「仅配置」。但这在 harness 与引擎之间插入了一个非官方的第三方层,抹去了本 Agent Note 暴露的原生控制面(permissionMode、sandboxMode/approvalPolicy、配置目录隔离、apiKey RPC),并以 shim 的发布节奏替换了第一方协议的稳定性。第一方接口——Agent SDK 和 app-server——才是受支持的集成点。
## 验收标准
在两个引擎和密钥均已配置的机器上:一个 REPL 驱动的模型通过 `subagent_claude_code` 完成一个真实文件任务,通过 `subagent_codex` 完成另一个,工具结果为子进程的最终答案,父会话日志中仅有 `tool/call` + `tool/result`。无密钥套件在无凭证环境下以逐文件 100% 覆盖率通过,断言隔离(清洗后的子进程环境、dispose 后无残留临时配置目录),并断言 `~/.claude` / `~/.codex` 的存在与否不影响子进程行为。取消父轮次后,两个后端在有界时间内完全停稳,无残留子进程。e2e 套件干净地自跳过,命名缺失的前置条件。
## 风险
- `codex app-server` 被 CLI 标记为实验性,其 v1/v2 词汇共存;客户端固定 0.142.5、仅实现 v2、对未知方法/通知消费而不崩溃,但未来 codex 升级仍可能迫使返工(每次升级重新生成 schema 并重跑无密钥套件——这是上述「不做运行时版本探测」立场背后的开发时强制执行)。
- Claude Code 假 CLI mock 依赖一个内部协议:任何 SDK 升级都必须通过无密钥套件,控制协议的破坏性变更意味着返工 mock(回退方案:上面否决的驱动注入 seam 成为逃生舱口)。
- SDK 的 optionalDependencies 每平台约 280 MB——已接受,限制在单个后端包内。
- SDK 的 SIGKILL 分支(EOF→SIGTERM 之后)未被观察到,信任其实现;e2e 保留无残留进程断言。
- Codex 是部署前置条件(无 npm 内置二进制);缺失或不兼容的二进制会明确报出 spawn/协议 `error`,而非版本探测。
- 每次运行付出一个全新子进程的代价,且仅最终答案浮出——思考、工具卡片和用量被消费后丢弃;池化、中间进度浮出、`sendMessage`/`resume`、通过 SDK 的 `outputFormat` 实现 `outputSchema`、以及通过 SDK 的 `agents` 选项实现命名 subagent 类型,均为刻意推迟。
+23 -2
View File
@@ -3,9 +3,9 @@
# Third-Party Notices
DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms.
DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms.
This file lists **direct** dependencies declared by the workspace. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check.
This file lists **direct** dependencies declared by the workspace and the explicitly disclosed official Claude platform payload closure. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check.
The complete npm transitive closure, with exact pinned versions, is recorded in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python closure is recorded in [`python/sdk/uv.lock`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [`native/landlock-run/pnpm-lock.yaml`](native/landlock-run/pnpm-lock.yaml).
@@ -32,6 +32,8 @@ External packages that a workspace package resolves at runtime. `scripts/install
| Package | License |
| --- | --- |
| [`@agentclientprotocol/sdk`](https://github.com/agentclientprotocol/typescript-sdk) | Apache-2.0 |
| [`@anthropic-ai/claude-agent-sdk`](https://github.com/anthropics/claude-agent-sdk-typescript) | SEE LICENSE IN README.md |
| [`@anthropic-ai/sdk`](https://github.com/anthropics/anthropic-sdk-typescript) | MIT |
| [`@babel/code-frame`](https://github.com/babel/babel) | MIT |
| [`@clack/core`](https://github.com/bombshell-dev/clack) | MIT |
| [`@clack/prompts`](https://github.com/bombshell-dev/clack) | MIT |
@@ -95,6 +97,24 @@ pnpm applies local patches to the following packages at install time, so shipped
- `node-pty@1.1.0` — [`patches/node-pty@1.1.0.patch`](patches/node-pty@1.1.0.patch)
## Official Claude Code platform payloads
The project owner authorizes distribution of every version of the official `@anthropic-ai/claude-agent-sdk` package and the official Claude Code CLI/platform payloads that each version declares through `optionalDependencies`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review.
The installed SDK 0.3.220 declares the following optional platform packages. Each carries the official Claude Code 2.1.220 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host.
| Optional platform package | Version | Declared license |
| --- | --- | --- |
| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md |
| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md |
| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md |
| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md |
| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md |
| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md |
| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md |
| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md |
## Development-only npm dependencies
External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles.
@@ -104,6 +124,7 @@ External packages **directly declared** only by repository tooling, test infrast
| [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT |
| [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 |
| [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 |
| [`@openai/codex`](https://github.com/openai/codex) | Apache-2.0 |
| [`@stylistic/eslint-plugin`](https://github.com/eslint-stylistic/eslint-stylistic) | MIT |
| [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT |
| [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/architecture.md
architecture.md: 40c20a1c9eeabe5ecbbc6edacde81c20071b8a04
architecture.zh.md: 6fddaa883775cf8345aba01af52575c0f0e1aaa0
architecture.md: d0d59b83dac0de471e852feec2f8ca3cd04b290e
architecture.zh.md: b76b9fec360863b80ccd39e8deaeedae33709f97
+1 -1
View File
@@ -156,7 +156,7 @@ Streaming uses raw chunks and `BlockAssembler`. Each `LlmAdapter.stream()` is on
A swappable capability usually has **interface / implementation / consumer** layers: service/events, backend, and model-facing tools/prompts. Bash is the reference; the [capability graph](capability-seams.md) maps each family.
Exceptions combine LLM interface/consumer, filesystem policy, web registries, and named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)).
Exceptions combine LLM interface/consumer, filesystem policy, web registries, and named skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, use ACP children, or delegate one self-contained turn to a real product provider such as Codex ([subagent.md](core-data-structures/subagent.md)).
`dsh-workspace-context` composes its baseline on the first `agent/pre-step` and folds it into the final entering batch right after the claimed prompt, so it reaches the first request with the direct prompt; rejection keeps it in the next-step inbox. Filesystem changes projected after tools are likewise folded into the next entering pre-step instead of creating a later context-only step ([decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)). `dsh-paths` owns shared paths.
+1 -1
View File
@@ -156,7 +156,7 @@ idle inject:
可替换功能通常具有**接口/实现/消费方**三层:服务和事件、后端、面向模型的工具和提示词。Bash 是参考实现;[功能图](capability-seams.md)映射了每个包族。
例外情况包括 LLM(大语言模型)合并接口和消费方、文件系统整合策略、web 使用注册表、skill 和 subagent 使用具名提供方。subagent 可以通过 spawn 创建全新实例、fork 一个已完成轮次的前缀,或使用 ACPAgent Client Protocol)子 agent[subagent.md](core-data-structures/subagent.md))。
例外情况包括 LLM(大语言模型)合并接口和消费方、文件系统整合策略、web 使用注册表、skill 和 subagent 使用具名提供方。subagent 可以通过 spawn 创建全新实例、fork 一个已完成轮次的前缀使用 ACPAgent Client Protocol)子 agent,或将一个独立完整的轮次委派给 Codex 等真实产品提供方[subagent.md](core-data-structures/subagent.md))。
`dsh-workspace-context` 在第一次 `agent/pre-step` 组合基线并将它折入最终进入的批次、紧随已领取的直接提示词之后,使其与直接提示词一同抵达第一次请求;reject 则将它留在 next-step inbox。工具执行后投影的文件系统变更也会折入下一次进入步骤的 pre-step,而不会另外创建稍后的纯上下文步骤([决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md))。`dsh-paths` 负责共享路径。
+10 -2
View File
@@ -103,6 +103,8 @@ flowchart LR
pkg_bash_sandbox["bash-sandbox"]
pkg_lsp_local["lsp-local"]
pkg_subagent_acp["subagent-acp"]
pkg_subagent_codex["subagent-codex"]
pkg_subagent_claude_code["subagent-claude-code"]
pkg_bash["bash"]
svc_bash["ctx.bash<br/>Bash executor seam"]
pkg_pwsh_local["pwsh-local"]
@@ -135,6 +137,7 @@ flowchart LR
svc_subagents["ctx.subagents<br/>Subagent provider and continuation service"]
pkg_subagent_spawn["subagent-spawn"]
pkg_subagent_fork["subagent-fork"]
pkg_subagent_dsh_sdk["subagent-dsh-sdk"]
pkg_tool_subagent_control["tool-subagent-control"]
pkg_tool_ralph["tool-ralph"]
pkg_tasks["tasks"]
@@ -228,6 +231,9 @@ flowchart LR
pkg_storage_sqlite --> svc_storage
pkg_subagent --> svc_subagents
pkg_subagent_acp --> svc_subagents
pkg_subagent_claude_code --> svc_subagents
pkg_subagent_codex --> svc_subagents
pkg_subagent_dsh_sdk --> svc_subagents
pkg_subagent_fork --> svc_subagents
pkg_subagent_spawn --> svc_subagents
pkg_subprocess --> svc_subprocess
@@ -318,6 +324,8 @@ flowchart LR
svc_subprocess --> pkg_bash_sandbox
svc_subprocess --> pkg_lsp_local
svc_subprocess --> pkg_subagent_acp
svc_subprocess --> pkg_subagent_claude_code
svc_subprocess --> pkg_subagent_codex
svc_systemPrompt --> pkg_agent_loop
svc_systemPrompt --> pkg_tool_fs
svc_systemPrompt --> pkg_tool_pty
@@ -377,7 +385,7 @@ flowchart LR
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
| `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. |
| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp) | - | The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. |
| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | The bash executors, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. |
| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`pwsh-local`](../packages/bash/pwsh-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pwsh`](../packages/bash/tool-pwsh), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing shell tools and hook bridges consume this seam; sandboxed, remote, or PowerShell executors replace bash-local without touching them. |
| `ctx.bashEnv` | `core` | [`bash-env`](../packages/bash/bash-env) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-pwsh`](../packages/bash/tool-pwsh) | - | Plugins declare effect-scoped DSH_* facts; each shell tool collects one trusted snapshot per execution and its executor rebuilds the namespace. |
| `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. |
@@ -388,7 +396,7 @@ flowchart LR
| `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). |
| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-policy contributes observed-state checks through the fs/* event gate. |
| `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. |
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. |
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. |
| `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. |
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. |
+48 -9
View File
@@ -223,7 +223,7 @@ export interface Config {
maxOutputBytes?: number
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
maxSpillBytes?: number
/** Grace period for kill escalation and for inherited pipes after shell exit. */
/** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */
graceMs?: number
}
```
@@ -1065,7 +1065,7 @@ export interface Config {
maxOutputBytes?: number
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
maxSpillBytes?: number
/** Grace period for kill escalation and for inherited pipes after shell exit. */
/** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */
graceMs?: number
/**
* Explicit pwsh executable. When omitted, well-known Windows install
@@ -1628,10 +1628,11 @@ export interface Config {
/**
* Grace period (ms) for the child's EOF-driven quiesce on dispose — its
* window to flush persistence and tear down its own nested subprocesses
* before the parent escalates to a signal.
* before the parent escalates to a signal. Must not exceed
* `MAX_TIMER_DELAY_MS`.
*/
disposeEofGraceMs?: number
/** Termination confirmation window (ms), including forced exit on every platform. */
/** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */
disposeGraceMs?: number
}
@@ -1639,7 +1640,45 @@ export interface Config {
export type PermissionPolicy = 'allow' | 'reject'
```
Source: [`packages/subagent/subagent-acp/src/index.ts:26`](../packages/subagent/subagent-acp/src/index.ts)
Source: [`packages/subagent/subagent-acp/src/index.ts:27`](../packages/subagent/subagent-acp/src/index.ts)
## `@deepseek-ai/dsh-subagent-claude-code`
Requires: `subagents` · `subprocess`
```ts config-catalog
/** Deployment-owned environment and process-release bound. */
export interface Config {
/**
* Explicit environment entries layered over the subprocess seam's
* credential-scrubbed parent environment.
*/
env?: Record<string, string>
/** Grace in milliseconds for Claude Code process-tree termination. */
disposeGraceMs?: number
}
```
Source: [`packages/subagent/subagent-claude-code/src/index.ts:32`](../packages/subagent/subagent-claude-code/src/index.ts)
## `@deepseek-ai/dsh-subagent-codex`
Requires: `subagents` · `subprocess`
```ts config-catalog
/** Deployment-owned environment and process-release bound. */
export interface Config {
/**
* Explicit environment entries layered over the subprocess seam's
* credential-scrubbed parent environment.
*/
env?: Record<string, string>
/** Grace in milliseconds for app-server process-tree termination. */
disposeGraceMs?: number
}
```
Source: [`packages/subagent/subagent-codex/src/index.ts:30`](../packages/subagent/subagent-codex/src/index.ts)
## `@deepseek-ai/dsh-subagent-dsh-sdk`
@@ -1873,7 +1912,7 @@ export interface Config {
searchMetaMaxBytes?: number
/** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */
rawOutputMaxBytes?: number
/** Terminate-escalation grace period (ms) for one search process, handed to the subprocess seam. */
/** Terminate-escalation grace (ms), handed to the subprocess seam and bounded by `MAX_TIMER_DELAY_MS`. */
graceMs?: number
/** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */
stderrMaxBytes?: number
@@ -1882,7 +1921,7 @@ export interface Config {
}
```
Source: [`packages/fs/tool-fs-search/src/index.ts:72`](../packages/fs/tool-fs-search/src/index.ts)
Source: [`packages/fs/tool-fs-search/src/index.ts:73`](../packages/fs/tool-fs-search/src/index.ts)
## `@deepseek-ai/dsh-tool-goal`
@@ -2063,8 +2102,8 @@ export interface Config {
* requires the provider's `depthLimit` capability (mount fails loud
* otherwise). The provider checks the calling agent's current depth at every
* start; the tool remains model-visible so runtime policy owns rejection.
* `'provider-managed'` is for an out-of-process provider (ACP) whose
* recursion budget belongs to the child harness's own deployment.
* `'provider-managed'` is for an out-of-process provider whose recursion
* budget belongs to the child runtime or its own deployment.
*/
maxDepth?: number | 'provider-managed'
}
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md
extension-cookbook.md: 1d5705672396d66d5625567aefec5006ae126e67
extension-cookbook.zh.md: 2f8e0ccb745baefb857f1065040f3225142d264f
extension-cookbook.md: 26f20b8f6cb57103b8e7340fcd21089ff1b0e5f6
extension-cookbook.zh.md: b8bafdb73e91b56dd411cc5e96c21ccf2c5db123
+1 -1
View File
@@ -117,7 +117,7 @@ Every product feature maps to a listener on a documented extension seam — the
| Subprocess sandbox (landlock / sandbox-exec) | use a `ctx.sandbox` backend through `dsh-bash-sandbox`; use `tools/pre-execute` for capability-level denial |
| Permission system / AskUserQuestion | return `ask` from `tools/pre-execute` and answer through `ctx.approval`; register a separate model-facing ask tool for ordinary user questions |
| Plan mode | Shipped: [`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — logged `plan/mode` state, the `plan:policy` guidance section, `/plan [message]` entry, `/plan off` direct exit, and the user-reviewed `exit_plan_mode` exit; enforcement stays on the independent sandbox/approval axes |
| Sub-agent delegation | the `ctx.subagents` provider registry (`dsh-subagent-spawn`/`-fork`/`-acp`) + `dsh-tool-subagent` exposing one configured provider to the model |
| Sub-agent delegation | the `ctx.subagents` provider registry (`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`/`-claude-code`/`-dsh-sdk`) + `dsh-tool-subagent` exposing one configured provider to the model |
| MCP | one plugin per server: discover tools → `ctx.tools.register()` |
| Skills | section + tool registration; `inject()` skill content on invocation |
| Memory | section provider + tool |
+1 -1
View File
@@ -117,7 +117,7 @@ export function apply(ctx: Context) {
| 子进程沙箱(landlock / sandbox-exec | 通过 `dsh-bash-sandbox` 使用 `ctx.sandbox` 后端;能力级别的拒绝使用 `tools/pre-execute` |
| 权限系统 / AskUserQuestion | 从 `tools/pre-execute` 返回 `ask` 并通过 `ctx.approval` 应答;为普通用户提问注册一个独立的面向模型的 ask 工具 |
| Plan mode | 已交付:[`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — 落日志的 `plan/mode` 状态、`plan:policy` 引导段、`/plan [message]` 入口、`/plan off` 直接退出,以及经用户评审的 `exit_plan_mode` 出口;强制约束留在独立的沙箱/审批轴上 |
| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 |
| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`/`-codex`/`-claude-code`/`-dsh-sdk`+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 |
| MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` |
| Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 |
| 记忆 | section provider + 工具 |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/core-data-structures/subagent.md
subagent.md: 956b47cfa85efe7826fbde47d4405d20a6abed6c
subagent.zh.md: 467fcd35bcde5fd01a2e18efbad862c72d7a4253
subagent.md: 315051fafaa0bb291a0f7525d2de142d8570961b
subagent.zh.md: 5e147b85b1b9a57fb604145bef66e560c443c1de
+1 -1
View File
@@ -4,7 +4,7 @@ English | [中文](subagent.zh.md)
The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor.
Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message` and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only direct-child discovery through optional session query. The rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).
Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`, `-codex`, `-claude-code`, `-dsh-sdk`); the model-facing consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message` and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only direct-child discovery through optional session query. Product-provider rationale lives in [the Codex and Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md); common-seam rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).
Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts)
+1 -1
View File
@@ -4,7 +4,7 @@
subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。
接口:[dsh-subagent](../../packages/subagent/subagent)`ctx.subagents` + 下文词汇)。实现为个兄弟包(package):`dsh-subagent-spawn``-fork``-acp`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message``list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并通过可选的会话查询负责只读的直接 child 发现。设计理由见 [subagent Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
接口:[dsh-subagent](../../packages/subagent/subagent)`ctx.subagents` + 下文词汇)。实现为个兄弟包(package):`dsh-subagent-spawn``-fork``-acp``-codex``-claude-code``-dsh-sdk`;面向模型的消费方包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message``list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并通过可选的会话查询负责只读的直接 child 发现。产品提供方设计理由见 [Codex 与 Claude Code Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md);通用 seam 的设计理由见 [subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts)
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/core-data-structures/subprocess.md
subprocess.md: 922e7ad0ee8b5c0dbcd0a6a4553c9d2a580f3ee2
subprocess.zh.md: ec9f41cfa9e3121f630bab2b6f1e921c81ad55b7
subprocess.md: 8189795c5acf2fc1882d9e5bc1c006f49c327e2f
subprocess.zh.md: 370a1a05807eefbe09a896ea8bcca1e4ab0a6b83
+11 -11
View File
@@ -8,7 +8,7 @@ Source: [`packages/subprocess/subprocess/src/types.ts`](../../packages/subproces
## Managed environment namespace and captured output
`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before the caller's explicit `env` merges, so a current fact arrives only as a deliberate entry, and each collected stream reports its truncation and spill-recovery state through `CollectedOutput`.
`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before the caller's explicit `env` merges, so a current fact arrives only as a deliberate string entry, while an explicit `undefined` tombstone removes an ordinary ambient value. Each collected stream reports its truncation and spill-recovery state through `CollectedOutput`.
```ts type-equiv
/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */
@@ -101,10 +101,11 @@ interface SubprocessSpawnSpec {
/** Per-stream stdio dispositions. */
stdio: SubprocessStdio
/**
* Grace period in milliseconds for the {@link SubprocessHandle.terminate}
* escalation and for draining still-open collected pipes after the process
* exits (an inherited descriptor held by a surviving descendant cannot hold
* the outcome open indefinitely).
* Positive finite grace period in milliseconds, no greater than
* `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation
* and for draining still-open collected pipes after the process exits (an
* inherited descriptor held by a surviving descendant cannot hold the
* outcome open indefinitely).
*/
graceMs: number
/**
@@ -115,13 +116,12 @@ interface SubprocessSpawnSpec {
signal?: AbortSignal | undefined
/**
* Explicit environment entries merged onto the implementation's scrubbed
* parent base (see `scrubbedParentEnv`), with no namespace validation:
* every entry is a deliberate caller opt-in, so a forwarded
* credential-shaped entry or a current `DSH_*` fact survives precisely
* because this layer merges after the scrub that drops its ambient
* namesake.
* parent base (see `scrubbedParentEnv`), with no namespace validation. A
* string is a deliberate caller opt-in, so a forwarded credential-shaped
* entry or current `DSH_*` fact survives the scrub; `undefined` is a
* tombstone that removes an ordinary ambient entry from the child.
*/
env?: Record<string, string> | undefined
env?: NodeJS.ProcessEnv | undefined
}
```
+11 -11
View File
@@ -8,7 +8,7 @@
## 受管环境命名空间与捕获的输出
`DSH_*` 变量是归 Harness 所有的子进程事实;实现会在合并调用方显式 `env` 之前丢弃环境中已有的 `DSH_*` 名称,因此当前事实只会以有意提供的条目形式到达,每条被收集的流都通过 `CollectedOutput` 报告自身的截断与 spill 恢复状态。
`DSH_*` 变量是归 Harness 所有的子进程事实;实现会在合并调用方显式 `env` 之前丢弃环境中已有的 `DSH_*` 名称,因此当前事实只会以有意提供的字符串条目形式到达,而显式的 `undefined` tombstone 会删除普通环境中已有的值。每条被收集的流都通过 `CollectedOutput` 报告自身的截断与 spill 恢复状态。
```ts type-equiv
/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */
@@ -101,10 +101,11 @@ interface SubprocessSpawnSpec {
/** Per-stream stdio dispositions. */
stdio: SubprocessStdio
/**
* Grace period in milliseconds for the {@link SubprocessHandle.terminate}
* escalation and for draining still-open collected pipes after the process
* exits (an inherited descriptor held by a surviving descendant cannot hold
* the outcome open indefinitely).
* Positive finite grace period in milliseconds, no greater than
* `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation
* and for draining still-open collected pipes after the process exits (an
* inherited descriptor held by a surviving descendant cannot hold the
* outcome open indefinitely).
*/
graceMs: number
/**
@@ -115,13 +116,12 @@ interface SubprocessSpawnSpec {
signal?: AbortSignal | undefined
/**
* Explicit environment entries merged onto the implementation's scrubbed
* parent base (see `scrubbedParentEnv`), with no namespace validation:
* every entry is a deliberate caller opt-in, so a forwarded
* credential-shaped entry or a current `DSH_*` fact survives precisely
* because this layer merges after the scrub that drops its ambient
* namesake.
* parent base (see `scrubbedParentEnv`), with no namespace validation. A
* string is a deliberate caller opt-in, so a forwarded credential-shaped
* entry or current `DSH_*` fact survives the scrub; `undefined` is a
* tombstone that removes an ordinary ambient entry from the child.
*/
env?: Record<string, string> | undefined
env?: NodeJS.ProcessEnv | undefined
}
```
+23 -3
View File
@@ -68,6 +68,8 @@ flowchart TD
subgraph group_subagent["packages/subagent"]
pkg_subagent["subagent"]
pkg_subagent_acp["subagent-acp"]
pkg_subagent_claude_code["subagent-claude-code"]
pkg_subagent_codex["subagent-codex"]
pkg_subagent_dsh_sdk["subagent-dsh-sdk"]
pkg_subagent_fork["subagent-fork"]
pkg_subagent_inprocess["subagent-inprocess"]
@@ -351,6 +353,7 @@ flowchart TD
pkg_storage_sqlite --> pkg_storage
pkg_subprocess_local --> pkg_invariants
pkg_subprocess_local --> pkg_subprocess
pkg_subprocess_local --> pkg_timeout
pkg_typert_loader --> pkg_invariants
pkg_typert_loader --> pkg_typert_registry
pkg_llm_deepseek --> pkg_credentials
@@ -727,6 +730,7 @@ flowchart TD
pkg_tool_fs_search --> pkg_spill
pkg_tool_fs_search --> pkg_subprocess
pkg_tool_fs_search --> pkg_system_prompt
pkg_tool_fs_search --> pkg_timeout
pkg_tool_fs_search --> pkg_tools
pkg_tool_str_replace_editor --> pkg_fs
pkg_tool_str_replace_editor --> pkg_invariants
@@ -926,6 +930,13 @@ flowchart TD
pkg_subagent_acp --> pkg_session
pkg_subagent_acp --> pkg_subagent
pkg_subagent_acp --> pkg_subprocess
pkg_subagent_acp --> pkg_timeout
pkg_subagent_claude_code --> pkg_invariants
pkg_subagent_claude_code --> pkg_llm
pkg_subagent_claude_code --> pkg_session
pkg_subagent_claude_code --> pkg_subagent
pkg_subagent_claude_code --> pkg_subprocess
pkg_subagent_claude_code --> pkg_timeout
pkg_subagent_inprocess --> pkg_agent
pkg_subagent_inprocess --> pkg_invariants
pkg_subagent_inprocess --> pkg_llm
@@ -1021,6 +1032,13 @@ flowchart TD
pkg_workflow_workerthread --> pkg_subagent
pkg_workflow_workerthread --> pkg_tools
pkg_workflow_workerthread --> pkg_workflow
pkg_subagent_codex --> pkg_invariants
pkg_subagent_codex --> pkg_llm
pkg_subagent_codex --> pkg_sdk_protocol
pkg_subagent_codex --> pkg_session
pkg_subagent_codex --> pkg_subagent
pkg_subagent_codex --> pkg_subprocess
pkg_subagent_codex --> pkg_timeout
pkg_subagent_fork --> pkg_agent
pkg_subagent_fork --> pkg_invariants
pkg_subagent_fork --> pkg_session
@@ -1138,7 +1156,7 @@ flowchart TD
| [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
| [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
| [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) |
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
@@ -1223,7 +1241,7 @@ flowchart TD
| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) |
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
@@ -1255,7 +1273,8 @@ flowchart TD
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
@@ -1270,6 +1289,7 @@ flowchart TD
| [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
@@ -0,0 +1,40 @@
# Test-only composition of both public opt-in providers and foreground tools.
# The owning e2e boots this tree but never invokes a model or product process.
- id: fixture
name: './fixture.ts'
- id: subagent
name: '@deepseek-ai/dsh-subagent'
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
- id: subagent-codex
name: '@deepseek-ai/dsh-subagent-codex'
- id: subagent-claude-code
name: '@deepseek-ai/dsh-subagent-claude-code'
- id: tool-subagent-codex
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex
toolName: subagent_codex
enableRunInBackground: false
maxDepth: 'provider-managed'
- id: tool-subagent-claude-code
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: claude-code
toolName: subagent_claude_code
enableRunInBackground: false
maxDepth: 'provider-managed'
- id: cli-agent
name: '@deepseek-ai/dsh-cli-demo'
config:
provider: mock
model: mock-delegate
persona: 'This composition test must not start a model turn.'
workspaceContext: false
@@ -0,0 +1,65 @@
#!/usr/bin/env node
/** Inspect both public product-provider compositions without invoking them. */
import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-subagent'
import type {} from '@deepseek-ai/dsh-tools'
const configPath = process.argv[2]
if (configPath === undefined) {
throw new Error('product-provider Loader composition driver requires a config path')
}
let starts = 0
const ctx = await boot(
'product-provider-loader-composition',
resolveConfigPath(configPath, undefined),
undefined,
(hostCtx) => {
hostCtx.on('subagent/start', () => {
starts += 1
})
},
)
try {
const providerNames = ['codex', 'claude-code'] as const
const toolNames = ['subagent_codex', 'subagent_claude_code'] as const
const providers = providerNames.map((providerName) => {
const provider = ctx.subagents.getProvider(providerName)
if (provider === undefined) {
throw new Error(`${providerName} provider was not registered`)
}
return {
name: provider.name,
capabilities: provider.capabilities,
inheritsParentContext: provider.inheritsParentContext,
}
})
const tools = toolNames.map((toolName) => {
const tool = ctx.tools.schemas().find(schema => schema.name === toolName)
if (tool === undefined) throw new Error(`${toolName} tool was not registered`)
const properties = tool.parameters.properties
if (
typeof properties !== 'object'
|| properties === null
|| Array.isArray(properties)
) {
throw new Error(`${toolName} has invalid parameter properties`)
}
return {
name: tool.name,
parameterNames: Object.keys(properties).sort(),
required: tool.parameters.required,
}
})
process.stdout.write(`${JSON.stringify({
registeredProviders: ctx.subagents.list(),
providers,
tools,
starts,
})}\n`)
} finally {
await ctx.fiber.dispose()
}
@@ -0,0 +1,7 @@
/** Reuse the composition-only parent adapter shared by the product providers. */
export {
apply,
inject,
name,
} from '../subagent-codex/fixture.ts'
@@ -0,0 +1,29 @@
# Test-only composition of the public opt-in provider and foreground tool.
# The owning e2e boots this tree but never invokes the model or Codex.
- id: fixture
name: './fixture.ts'
- id: subagent
name: '@deepseek-ai/dsh-subagent'
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
- id: subagent-codex
name: '@deepseek-ai/dsh-subagent-codex'
- id: tool-subagent-codex
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex
toolName: subagent_codex
enableRunInBackground: false
maxDepth: 'provider-managed'
- id: cli-agent
name: '@deepseek-ai/dsh-cli-demo'
config:
provider: mock
model: mock-delegate
persona: 'This composition test must not start a model turn.'
workspaceContext: false
@@ -0,0 +1,51 @@
#!/usr/bin/env node
/** Inspect the public Codex provider composition without invoking the product. */
import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-subagent'
import type {} from '@deepseek-ai/dsh-tools'
const configPath = process.argv[2]
if (configPath === undefined) {
throw new Error('subagent-codex Loader composition driver requires a config path')
}
let starts = 0
const ctx = await boot(
'subagent-codex-loader-composition',
resolveConfigPath(configPath, undefined),
undefined,
(hostCtx) => {
hostCtx.on('subagent/start', () => {
starts += 1
})
},
)
try {
const provider = ctx.subagents.getProvider('codex')
if (provider === undefined) throw new Error('Codex provider was not registered')
const tool = ctx.tools.schemas().find(schema => schema.name === 'subagent_codex')
if (tool === undefined) throw new Error('subagent_codex tool was not registered')
const properties = tool.parameters.properties
if (typeof properties !== 'object' || properties === null || Array.isArray(properties)) {
throw new Error('subagent_codex tool has invalid parameter properties')
}
process.stdout.write(`${JSON.stringify({
providers: ctx.subagents.list(),
provider: {
name: provider.name,
capabilities: provider.capabilities,
inheritsParentContext: provider.inheritsParentContext,
},
tool: {
name: tool.name,
parameterNames: Object.keys(properties).sort(),
required: tool.parameters.required,
},
starts,
})}\n`)
} finally {
await ctx.fiber.dispose()
}
@@ -0,0 +1,22 @@
/** Parent adapter that fails if the composition-only Loader test starts a turn. */
import type { Context } from 'cordis'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
class CompositionOnlyAdapter extends LlmAdapter {
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
throw new Error('subagent-codex Loader composition must not invoke a model')
}
}
export const name = 'codex-loader-composition-fixture'
export const inject = ['llm']
/**
* Register a parent adapter solely so the host composition is complete.
* @param ctx - Loader context supplying the LLM seam.
*/
export function apply(ctx: Context): void {
ctx.llm.registerAdapter(['mock'], new CompositionOnlyAdapter())
}
+2
View File
@@ -66,6 +66,8 @@
"@deepseek-ai/dsh-spill-policy": "workspace:*",
"@deepseek-ai/dsh-subagent": "workspace:*",
"@deepseek-ai/dsh-subagent-acp": "workspace:*",
"@deepseek-ai/dsh-subagent-claude-code": "workspace:*",
"@deepseek-ai/dsh-subagent-codex": "workspace:*",
"@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*",
"@deepseek-ai/dsh-subagent-fork": "workspace:*",
"@deepseek-ai/dsh-subagent-spawn": "workspace:*",
+27
View File
@@ -48,6 +48,10 @@
"acp-agent/tests/fixtures/subagent-settlement-marker.ts",
"acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts",
"acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts",
"acp-agent/tests/fixtures/subagent/subagent-claude-code/fixture.ts",
"acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts",
"acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts",
"acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts",
"jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts",
"jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts",
"jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts",
@@ -553,6 +557,29 @@
"tests/**/*.ts"
]
},
"packages/subagent/subagent-codex": {
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
],
"ignoreDependencies": [
"@openai/codex"
]
},
"packages/subagent/subagent-claude-code": {
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/fs/tool-fs": {
"entry": [
"tests/**/*.spec.ts",
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/bash-local/README.md
README.md: bb87ad6fe021e3144cef4adced3d798bf3d94d67
README.zh.md: d2f8c9091072bbf3d75909f6826432601001ab88
README.md: cb40cb8fa40d95d5b4589b7c804f450a2bf38c8e
README.zh.md: bd4f73babdb47ff92e87e20eb7d60657ed515ec4
+1 -1
View File
@@ -23,7 +23,7 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
## Behavior
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` with no rc files.
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Background processes** — `start()` returns a live `BashProcess` handle immediately with no timeout, and `readOutput()` merges offset-based stdout/stderr reads into one consuming delta, placing stderr under a `[stderr]` marker when present. A running process belongs to the subprocess service, survives executor reloads, and is killed and joined on service disposal. Task ids, ownership, polling, and notices belong to the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with.
+1 -1
View File
@@ -23,7 +23,7 @@
## 行为
- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`,且不读取 rc 文件。
- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir``timeoutMs``stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`
- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir``timeoutMs``stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`
- **超时与取消分类**`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
- **适合模型的终端环境**`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
- **后台进程**`start()` 会立即返回活动的 `BashProcess` 句柄且不应用超时;`readOutput()` 把基于偏移量的 stdout/stderr 读取合并为一条消费式增量,并在存在 stderr 时将其置于 `[stderr]` 标记下。运行中的进程属于 subprocess 服务,可在执行器重载后存活,并在服务 dispose 时被终止且等待退出。task id、所有权、轮询和通知属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄。
+5 -2
View File
@@ -14,7 +14,7 @@ import z from 'schemastery'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
/**
* Model-friendly environment overrides: disable colors, pagers, and
@@ -48,7 +48,7 @@ export interface Config {
maxOutputBytes?: number
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
maxSpillBytes?: number
/** Grace period for kill escalation and for inherited pipes after shell exit. */
/** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */
graceMs?: number
}
@@ -102,6 +102,9 @@ export class LocalBashExecutor extends BashExecutor {
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
assertPositiveFinite('graceMs', this.config.graceMs)
if (this.config.graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
}
}
/**
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { BashProcess } from '@deepseek-ai/dsh-bash'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
@@ -70,6 +71,8 @@ describe('LocalBashExecutor.run', () => {
await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/)
await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/)
await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/)
await expect(setup({ graceMs: MAX_TIMER_DELAY_MS + 1 }))
.rejects.toThrow(`graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
const { bash } = await setup()
expect(() => bash.resolve({ command: 'true', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/)
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/pwsh-local/README.md
README.md: 2914c46ab91dd9555dab04551e52321f6eac05bf
README.zh.md: ce9696b276a2e60acf116d7124cd5cd256d7ebde
README.md: 3e38ea3830cb651a80eaee744a42f68891767358
README.zh.md: 8d32ce865d299bac37704e3e8730a7faa63ee108
+1 -1
View File
@@ -30,7 +30,7 @@ The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantic
- **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output.
- **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected.
- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction.
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent.
- **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins.
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies, and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.
+1 -1
View File
@@ -30,7 +30,7 @@
- **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。
- **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding``$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。
- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数,在构造时执行一次。
- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。进程树终止(Windows 用 taskkillPOSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`
- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkillPOSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`
- **超时与取消分类**——`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此基于信号的实情(`signal``killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。
- **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。
- **后台进程**——`start()` 立即返回存活的 `BashProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为带标记分段的增量与消费游标。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务销毁(被终止并 join)。一切任务形状的职责(id、所有权、轮询、通知)都在通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。
+5 -2
View File
@@ -18,7 +18,7 @@ import z from 'schemastery'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { resolvePwshPath } from './resolve.ts'
/* jscpd:ignore-start -- deliberate call-for-call mirror of dsh-bash-local (Agent Note: pwsh-tool-and-executor). */
@@ -62,7 +62,7 @@ export interface Config {
maxOutputBytes?: number
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
maxSpillBytes?: number
/** Grace period for kill escalation and for inherited pipes after shell exit. */
/** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */
graceMs?: number
/**
* Explicit pwsh executable. When omitted, well-known Windows install
@@ -129,6 +129,9 @@ export class PwshLocalExecutor extends BashExecutor {
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
assertPositiveFinite('graceMs', this.config.graceMs)
if (this.config.graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
}
this.pwshPath = resolvePwshPath(this.config.pwshPath)
}
@@ -19,6 +19,7 @@ import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPa
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import SubprocessService from '@deepseek-ai/dsh-subprocess'
import type { SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { BashProcess } from '@deepseek-ai/dsh-bash'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-'))
@@ -187,6 +188,8 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => {
await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/)
await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/)
await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/)
await expect(setup({ graceMs: MAX_TIMER_DELAY_MS + 1 }))
.rejects.toThrow(`graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
const { bash } = await setup()
expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/)
@@ -2819,7 +2819,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SubprocessSpawnSpec',
declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdio: SubprocessStdio;\n graceMs: number;\n signal?: AbortSignal | undefined;\n env?: Record<string, string> | undefined;\n}',
declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdio: SubprocessStdio;\n graceMs: number;\n signal?: AbortSignal | undefined;\n env?: NodeJS.ProcessEnv | undefined;\n}',
},
{
name: 'SubprocessStdinMode',
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/tool-fs-search/README.md
README.md: 78ffa069e56da5fc987913acf761eb5c6ae15b1a
README.zh.md: b7bb08f94682b0c85baf33ad9045320e3306bbb2
README.md: 32fa61e3bb09b2166499003953a5631a93baf73b
README.zh.md: bf2eac320bf3dc0811ec8e3747487b4f3ce634b7
+1 -1
View File
@@ -30,7 +30,7 @@ The binary ships with the package on every supported platform (macOS/Linux/Windo
| `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. |
| `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. |
| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`; the subprocess seam's terminate escalation is the hard kill. |
| `graceMs` | `3000` | Terminate-escalation grace period the subprocess seam grants past `timeoutMs` before the search fails as `SEARCH_ABORTED`. |
| `graceMs` | `3000` | Positive terminate-escalation grace the subprocess seam grants past `timeoutMs` before the search fails as `SEARCH_ABORTED`; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). |
| `stderrMaxBytes` | `65536` | Diagnostic-tail budget for `rg` stderr, captured through the subprocess seam's collect disposition; a lossy read keeps only the tail (marked `[stderr truncated]`). |
## Tools
+1 -1
View File
@@ -30,7 +30,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
| `grepMaxLineBytes` | `2000` | 每条匹配行预览的字节上限;截断会保留 UTF-8 边界,并标记为 `(line truncated)`。 |
| `rawOutputMaxBytes` | `20000000` | 搜索将解析的完整原始 `rg` stdout 上限(与 Claude Code 的 ripgrep 原始 buffer 相同);更大的原始输出以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败。 |
| `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-timeout-policy` 通过 `exec.signal` 强制执行;subprocess seam 的终止升级提供硬终止。 |
| `graceMs` | `3000` | subprocess seam 在 `timeoutMs` 之外授予的终止升级宽限期;超过后搜索以 `SEARCH_ABORTED` 失败。 |
| `graceMs` | `3000` | subprocess seam 在 `timeoutMs` 之外授予的终止升级宽限期须为正值;超过后搜索以 `SEARCH_ABORTED` 失败;该宽限期不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 |
| `stderrMaxBytes` | `65536` | `rg` stderr 的诊断尾部预算,经 subprocess seam 的 collect 形态捕获;lossy 读取只保留尾部(标记 `[stderr truncated]`)。 |
## 工具
+2
View File
@@ -36,6 +36,7 @@
"@deepseek-ai/dsh-spill": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
@@ -49,6 +50,7 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-spill": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
+5 -1
View File
@@ -28,6 +28,7 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts'
import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts'
import { RAW_OUTPUT_MAX_BYTES, SEARCH_GRACE_MS, SEARCH_META_MAX_BYTES, SEARCH_STDERR_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
@@ -82,7 +83,7 @@ export interface Config {
searchMetaMaxBytes?: number
/** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */
rawOutputMaxBytes?: number
/** Terminate-escalation grace period (ms) for one search process, handed to the subprocess seam. */
/** Terminate-escalation grace (ms), handed to the subprocess seam and bounded by `MAX_TIMER_DELAY_MS`. */
graceMs?: number
/** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */
stderrMaxBytes?: number
@@ -130,6 +131,9 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
assertPositiveInteger('searchMetaMaxBytes', resolved.searchMetaMaxBytes)
assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes)
assertPositiveInteger('graceMs', resolved.graceMs)
if (resolved.graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`tool-fs-search: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
}
assertPositiveInteger('stderrMaxBytes', resolved.stderrMaxBytes)
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
applyGlobTool(ctx, {
@@ -18,6 +18,7 @@ import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecution, type ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import { SubprocessService } from '@deepseek-ai/dsh-subprocess'
import type { SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputRead, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { rgPath } from '@vscode/ripgrep'
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
@@ -309,6 +310,17 @@ describe('config validation', () => {
await ctx.plugin(FakeSubprocess)
await expect(ctx.plugin(ToolFsSearch, { ...DEFAULT_CONFIG, ...config })).rejects.toThrow(new RegExp(`tool-fs-search: ${name} must be a positive integer`))
})
it('rejects a grace beyond the Node timer range at load', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FakeSubprocess)
await expect(ctx.plugin(ToolFsSearch, {
...DEFAULT_CONFIG,
graceMs: MAX_TIMER_DELAY_MS + 1,
})).rejects.toThrow(`tool-fs-search: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
})
})
describe('command construction (plain argv)', () => {
+3
View File
@@ -36,6 +36,9 @@
{
"path": "../../spill/spill"
},
{
"path": "../../util/timeout"
},
{
"path": "../../support/invariants"
}
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/README.md
README.md: 6aeb7fb1eaa9341dd72df614ca11d114f321fb83
README.zh.md: a78cb365a8e96ad44c0c930c072372f88930906c
README.md: 0a342569e66539e4987710b2e56f2946c97b1ac1
README.zh.md: 5d2f7beef478b8bfd27b4772c7a951ea62cb10ef
+2
View File
@@ -11,6 +11,8 @@ This family lets an agent delegate work to child agents. Multiple named provider
| [`subagent-spawn/`](subagent-spawn/README.md) | Starts a fresh in-process child | registers on `ctx.subagents` |
| [`subagent-fork/`](subagent-fork/README.md) | Starts an in-process child from the parent's completed history | registers on `ctx.subagents` |
| [`subagent-acp/`](subagent-acp/README.md) | Starts an out-of-process child over ACP | registers on `ctx.subagents` |
| [`subagent-codex/`](subagent-codex/README.md) | Starts a real Codex app-server child | registers on `ctx.subagents` |
| [`subagent-claude-code/`](subagent-claude-code/README.md) | Starts a real Claude Code child through the official Claude Agent SDK | registers on `ctx.subagents` |
| [`subagent-dsh-sdk/`](subagent-dsh-sdk/README.md) | Starts an out-of-process Harness child through the TypeScript SDK | registers on `ctx.subagents` |
| [`tool-subagent/`](tool-subagent/README.md) | Exposes delegation to the model | registers on `ctx.tools` |
| [`tool-subagent-control/`](tool-subagent-control/README.md) | Exposes child messaging and listing to the model | registers on `ctx.tools` |
+2
View File
@@ -11,6 +11,8 @@
| [`subagent-spawn/`](subagent-spawn/README.md) | 启动全新的进程内子 agent | 注册到 `ctx.subagents` |
| [`subagent-fork/`](subagent-fork/README.md) | 从父 agent 已完成的历史记录启动进程内子 agent | 注册到 `ctx.subagents` |
| [`subagent-acp/`](subagent-acp/README.md) | 通过 ACPAgent Client Protocol)启动进程外子 agent | 注册到 `ctx.subagents` |
| [`subagent-codex/`](subagent-codex/README.md) | 启动真实的 Codex app-server 子 agent | 注册到 `ctx.subagents` |
| [`subagent-claude-code/`](subagent-claude-code/README.md) | 通过官方 Claude Agent SDK 启动真实的 Claude Code 子 agent | 注册到 `ctx.subagents` |
| [`subagent-dsh-sdk/`](subagent-dsh-sdk/README.md) | 通过 TypeScript SDK 启动进程外 Harness 子 agent | 注册到 `ctx.subagents` |
| [`tool-subagent/`](tool-subagent/README.md) | 向模型公开委派操作 | 注册到 `ctx.tools` |
| [`tool-subagent-control/`](tool-subagent-control/README.md) | 向模型公开子级消息发送和列举操作 | 注册到 `ctx.tools` |
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent-acp/README.md
README.md: 83a5f60414528bdb768ffccd29f3091793f44b6b
README.zh.md: 4ea8daef9341897463f3dbedca86fd2c83b45514
README.md: 4fdd3a09e128d4dc7ec7395d9578803c64a33bc6
README.zh.md: 7cb1e3d18602ef839e4af316962fc2d10bc67640
+4 -4
View File
@@ -14,7 +14,7 @@ The returned run id is minted in the parent namespace. The child server's sessio
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.
`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 the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL — Windows force-terminates directly), then a bounded whole-tree exit wait that rejects if survivors remain. Every run uses a fresh process; process pooling is not implemented.
`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.
## Capabilities and context
@@ -30,8 +30,8 @@ ACP advertises no start-time capabilities because this process cannot enforce th
| `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. |
| `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first allow-shaped option. |
| `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. |
| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before platform termination. |
| `disposeGraceMs` | `3000` | Exit-confirmation grace after termination; POSIX also waits this long after SIGTERM before SIGKILL. |
| `disposeEofGraceMs` | `6000` | Positive grace after stdin EOF before platform termination; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). |
| `disposeGraceMs` | `3000` | Positive POSIX grace after SIGTERM before SIGKILL (Windows force-terminates directly); it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). |
```yaml
- id: subagent-acp
@@ -57,7 +57,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th
## Process boundary
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 runs the seam's cooperative stdin-EOF→SIGTERM→SIGKILL ladder with this plugin's configured graces. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned.
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.
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).
+4 -4
View File
@@ -14,7 +14,7 @@ ACPAgent Client Protocol)提供方会在全新的子进程中运行每个 s
发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败会以 `stopReason: 'error'` 兑现;如果必需的请求信号或 dispose(资源释放)请求了取消,则以 `aborted` 兑现。
`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后经由该 seam 的动词运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作式完全停稳,再触发句柄的 `terminate()` 升级(SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),最后进行有界的整树退出等待;若仍有存活进程,则拒绝。每次运行都使用全新进程;尚未实现进程池。
`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后经由该 seam 的动词运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作式完全停稳,再触发句柄的 `terminate()` 升级(SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),并等待子进程责任方给出整棵进程树的退出证明。每次运行都使用全新进程;尚未实现进程池。
## 能力与上下文
@@ -30,8 +30,8 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程
| `cwd` | 父会话 cwd | 子进程及其 ACP 会话的工作目录覆盖值;不得为空。相对值会在加载时以 harness 启动目录为基准解析,结果必须指向 harness 可以进入的目录。 |
| `permission` | `reject` | 自动回答权限请求:拒绝,或选择第一个允许形态的选项。 |
| `env` | `{}` | 显式子进程环境,叠加到已清理凭据的父进程环境之上。 |
| `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间。 |
| `disposeGraceMs` | `3000` | 终止后的退出确认宽限时间;POSIX 在 SIGTERM 后、SIGKILL 前也会等待同样时长。 |
| `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 |
| `disposeGraceMs` | `3000` | POSIX 在 SIGTERM 后、SIGKILL 前的宽限时间(Windows 直接强制终止),须为正值且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 |
```yaml
- id: subagent-acp
@@ -57,7 +57,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程
## 进程边界
子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 会继承到父进程自身的流,dispose 则以本插件配置的宽限期运行该 seam 的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。ACP 协议格式(wire format)是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。
子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 会继承到父进程自身的流,dispose 则先应用本插件的 EOF 时间窗,再由子进程责任方执行 SIGTERM→SIGKILL 升级并等待整棵进程树退出。ACP 协议格式(wire format)是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。
本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘(postmortem0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。
@@ -31,6 +31,7 @@
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
@@ -47,6 +48,7 @@
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
+7 -5
View File
@@ -17,6 +17,7 @@ import type {
SubagentProvider,
SubagentStartRequest,
} from '@deepseek-ai/dsh-subagent'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts'
export const name = 'subagent-acp'
@@ -54,10 +55,11 @@ export interface Config {
/**
* Grace period (ms) for the child's EOF-driven quiesce on dispose — its
* window to flush persistence and tear down its own nested subprocesses
* before the parent escalates to a signal.
* before the parent escalates to a signal. Must not exceed
* `MAX_TIMER_DELAY_MS`.
*/
disposeEofGraceMs?: number
/** Termination confirmation window (ms), including forced exit on every platform. */
/** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */
disposeGraceMs?: number
}
@@ -72,10 +74,10 @@ export const Config: z<Config> = z.object({
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
})
/** A dispose grace must be a positive finite number (it bounds the teardown wait). */
/** A dispose grace must fit the single Node timer that owns its teardown tier. */
function assertPositiveFinite(name: string, value: number): void {
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`subagent-acp: ${name} must be a positive finite number`)
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}`)
}
}
+10 -15
View File
@@ -62,9 +62,9 @@ export interface AcpRunSpec {
*/
disposeEofGraceMs: number
/**
* Termination confirmation window (ms) in {@link SubagentRun.dispose}; POSIX applies it after
* `SIGTERM` and `SIGKILL`, while Windows applies it after direct forced termination. The plugin
* fills this from its `disposeGraceMs` config.
* Termination-escalation grace (ms) in {@link SubagentRun.dispose}; POSIX
* waits this long after `SIGTERM` before `SIGKILL`, while Windows
* force-terminates directly. The plugin fills it from `disposeGraceMs`.
*/
disposeGraceMs: number
/**
@@ -105,14 +105,12 @@ async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise<boo
* Cooperative teardown ladder for an out-of-process agent, over the seam's
* public verbs; resolves only at whole-tree quiescence: stdin EOF (the child's
* window to flush persistence and reap its own descendants), then the
* terminate() escalation (SIGTERM → spec grace → SIGKILL), then a bounded
* confirmation wait.
* terminate() escalation (SIGTERM → spec grace → SIGKILL) and its
* whole-tree exit proof.
* @param child - the spawned ACP child's handle.
* @param eofGraceMs - tier-1 window after stdin EOF.
* @param graceMs - confirmation window after the escalation's SIGKILL.
* @throws when the tree still has not exited `graceMs` after forced termination.
*/
export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: number, graceMs: number): Promise<void> {
export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: number): Promise<void> {
// A spawn failure has no process to tear down; observe the rejection so
// disposal in a finally block cannot surface it as unhandled.
if (child.pid <= 0) {
@@ -121,13 +119,10 @@ export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: numbe
}
child.stdin?.end()
if (await treeExitsWithin(child, eofGraceMs)) return
// terminate() sends SIGTERM now and SIGKILL after the spawn spec's grace
// (this plugin passes disposeGraceMs there), so the bound covers both the
// escalation window and an equal confirmation window after the SIGKILL.
// terminate() owns the bounded SIGTERM→SIGKILL timer. Its unbounded wait is
// the process owner's exit proof, not a second derived grace that can overflow.
child.terminate()
if (!(await treeExitsWithin(child, graceMs * 2))) {
throw new Error('ACP child process tree did not exit within its dispose windows')
}
await child.waitForExit()
}
/**
@@ -235,7 +230,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
// Startup rollback and the published handle share one process teardown.
let processDisposal: Promise<void> | undefined
const disposeProcess = (): Promise<void> => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs, spec.disposeGraceMs))
const disposeProcess = (): Promise<void> => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs))
// Accumulate the child's streamed assistant text — the SubagentResult output.
const output: string[] = []
@@ -7,6 +7,7 @@ import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import * as acp from '../src/index.ts'
import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
@@ -147,7 +148,7 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)',
it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => {
const child = bash('read -r line; exit 0')
await disposeAcpChild(child, 5_000, 200)
await disposeAcpChild(child, 5_000)
const outcome = await child.done
expect(outcome.exitCode).toBe(0)
expect(outcome.signal).toBeNull()
@@ -155,7 +156,7 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)',
it('tier 2: an EOF-deaf child dies by the terminate escalation (SIGTERM)', async () => {
const child = bash('sleep 60')
await disposeAcpChild(child, 100, 5_000)
await disposeAcpChild(child, 100)
const outcome = await child.done
expect(outcome.signal).toBe('SIGTERM')
})
@@ -166,30 +167,11 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)',
while (!child.collected.stdout!.readFrom(0).text.includes('armed')) {
await new Promise(resolve => setTimeout(resolve, 10))
}
await disposeAcpChild(child, 50, 2_000)
await disposeAcpChild(child, 50)
const outcome = await child.done
expect(outcome.signal).toBe('SIGKILL')
})
it('throws when the tree survives even the escalation window', async () => {
// A handle whose tree never exits (waitForExit only ever aborts): the
// ladder must fail loud instead of resolving over survivors. Built as a
// stub because the ladder composes only public verbs.
const never: Parameters<typeof disposeAcpChild>[0] = {
pid: 1,
stdin: undefined,
stdout: undefined,
stderr: undefined,
collected: {},
done: new Promise(() => {}),
terminate: () => {},
waitForExit: (signal?: AbortSignal) => new Promise((resolve) => {
signal?.addEventListener('abort', () => { resolve(false) }, { once: true })
}),
}
await expect(disposeAcpChild(never, 20, 20)).rejects.toThrow(/did not exit within its dispose windows/)
})
it('observes a spawn-level rejection and returns without a process to reap', async () => {
const child = spawnSubprocess({
argv: ['bash', '-c', 'true'],
@@ -197,7 +179,7 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)',
stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
graceMs: 200,
})
await expect(disposeAcpChild(child, 1_000, 1_000)).resolves.toBeUndefined()
await expect(disposeAcpChild(child, 1_000)).resolves.toBeUndefined()
await expect(child.done).rejects.toThrow()
})
})
@@ -721,13 +703,20 @@ describe('dsh-subagent-acp', () => {
}
})
it('rejects a non-positive dispose grace at load', async () => {
for (const bad of [{ disposeEofGraceMs: 0 }, { disposeGraceMs: -1 }, { disposeEofGraceMs: Number.NaN }]) {
it('rejects a dispose grace outside the Node timer range at load', async () => {
for (const bad of [
{ disposeEofGraceMs: 0 },
{ disposeGraceMs: -1 },
{ disposeEofGraceMs: Number.NaN },
{ disposeGraceMs: Number.POSITIVE_INFINITY },
{ disposeEofGraceMs: MAX_TIMER_DELAY_MS + 1 },
{ disposeGraceMs: MAX_TIMER_DELAY_MS + 1 },
]) {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', args: [], permission: 'reject', env: {}, ...bad }))
.rejects.toThrow(/subagent-acp: dispose(?:Eof)?GraceMs must be a positive finite number/)
.rejects.toThrow(new RegExp(`subagent-acp: dispose(?:Eof)?GraceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`))
await ctx.fiber.dispose()
}
})
@@ -29,6 +29,9 @@
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../util/timeout"
},
{
"path": "../../support/loader-smoke"
},
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md
README.md: e62f60fceea16749296a91377785b81d94d751ca
README.zh.md: e171524157b2b1696df31753816210d41637a911
@@ -0,0 +1,96 @@
# @deepseek-ai/dsh-subagent-claude-code
English | [中文](README.zh.md)
This package registers the fixed `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, starts the SDK-distributed Claude Code CLI through the shared subprocess service, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract.
## Start and ownership
`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It creates one private `AbortController`, calls the official SDK `query()`, and publishes the run only after the SDK's `spawnClaudeCodeProcess` hook has supplied a live CLI handle owned by [`dsh-subprocess`](../../subprocess/subprocess/README.md). A failure or cancellation before publication closes the query, terminates any acquired process tree, waits for it to exit, and rejects `start()`.
The SDK receives the exact concatenated text task. The provider iterates the complete SDK message stream and accepts only a `result` message with `subtype: "success"`, `is_error: false`, and a nonblank `result`, followed by normal iterator completion. Every SDK error subtype, an error-marked success, a missing answer, iterator failure, protocol failure, or process failure maps to `error`; this version produces neither `max-tokens` nor `refusal`.
Local cancellation wins the result race and maps to `aborted`. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the shared process-tree termination escalation, and waits for whole-tree exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Result failure and independent teardown failure remain separate.
## Native settings and interaction
The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state.
Each query sets `persistSession: false` and disables `AskUserQuestion`. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK instead of waiting for a user interface this provider does not own.
## Capabilities and context
The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Claude Code receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. Every run has an independent SDK query, cancellation controller, CLI process, and non-persisted product session.
## Configuration
| Key | Default | Meaning |
|---|---|---|
| `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. |
| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. |
Production uses the Claude Code CLI supplied by `@anthropic-ai/claude-agent-sdk` and the host's native settings and authentication. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden.
Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_claude_code` by default.
```yaml
- id: subagent-claude-code
name: '@deepseek-ai/dsh-subagent-claude-code'
config:
env:
ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY
- id: tool-subagent-claude-code
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: claude-code
toolName: subagent_claude_code
enableRunInBackground: false
maxDepth: provider-managed
```
## Product compatibility and evidence
The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`, whose platform optional dependency supplies Claude Code 2.1.220. Required evidence exercises that official distribution through a keyless loopback product path and a credentialed DeepSeek path, while Loader composition proves that both opt-in product packages coexist without starting either product.
The project owner's identity-scoped distribution authorization covers the official SDK and the official CLI/platform payloads declared by each SDK version. [`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) discloses the current optional payload closure without classifying its declared terms as permissive; unrelated non-permissive runtime dependencies continue to fail the notices gate.
## Model Experience
### Child request
#### What the model sees
The Claude Code child receives the standalone text task as one fresh SDK query. Its workspace is the parent Session cwd, while its model, system instructions, tools, permissions, and authentication come from the host's native Claude settings and product installation.
#### Token effect
The child pays for an independent Claude Code context and query. Child tokens do not enter the parent's context.
#### KV Cache effect
Independent of the parent request cache. Reuse depends only on Claude Code's own model, instructions, tools, native settings, and fresh query.
### Parent tool result, indirectly
#### What the model sees
Through `dsh-tool-subagent`, the parent sees only the strict final Claude Code answer or the consumer's exact error for a non-completed result. Claude Code reasoning, tool activity, intermediate messages, stderr, workspace diffs, usage, and product ids are not copied into the parent Session.
#### Token effect
Parent input grows only by the final answer or error retained in the tool result. This provider adds no parent tool schema by itself.
#### KV Cache effect
Append-only: the new tool result follows the reusable parent request prefix.
## Known Limitations and Deferred Work
- **One fresh query and process per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence.
- **Host settings are intentionally authoritative** — project and user settings can change model, tools, and behavior; the provider does not provide a filtered or hermetic production mode.
- **Product installation and account state remain native** — an incompatible SDK payload, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow.
- **No human interaction path** — `AskUserQuestion` is disabled and other interactive callbacks are absent, so tasks requiring new approval or input fail instead of suspending.
- **Final text only** — reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local.
- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider.
- **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored.
@@ -0,0 +1,96 @@
# @deepseek-ai/dsh-subagent-claude-code
[English](README.md) | 中文
本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务启动 SDK 分发的 Claude Code CLI,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果契约仅返回最终答案。
## 启动与所有权
`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。它会创建一个私有 `AbortController`,调用官方 SDK 的 `query()`,并仅在 SDK 的 `spawnClaudeCodeProcess` 钩子已经提供由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 管理的活动 CLI 句柄后发布此次运行。若在发布前发生失败或取消,它会关闭 query、终止所有已取得的进程树并等待其退出,然后拒绝 `start()` 调用。
SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 消息流,而且只接受满足以下条件的 `result` 消息:其 `subtype: "success"``is_error: false``result` 非空白,之后迭代器还须正常结束。所有 SDK 错误子类型、标记为错误的成功消息、缺失答案、迭代器失败、协议失败或进程失败都映射为 `error`;本版本不会产生 `max-tokens``refusal`
本地取消会在结果竞态中胜出并映射为 `aborted``dispose()` 具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用共享的进程树逐级终止机制,并等待整棵进程树退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。结果失败与独立的清理失败仍彼此分离。
## 原生设置与交互
提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。
每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。
## 能力与上下文
本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Claude Code 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出契约。每次运行都拥有独立的 SDK query、取消控制器、CLI 进程和不持久化的产品会话。
## 配置
| 配置键 | 默认值 | 含义 |
|---|---|---|
| `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 |
| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 |
生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH``HOME` 等普通环境变量仍会被继承。
请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_claude_code`
```yaml
- id: subagent-claude-code
name: '@deepseek-ai/dsh-subagent-claude-code'
config:
env:
ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY
- id: tool-subagent-claude-code
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: claude-code
toolName: subagent_claude_code
enableRunInBackground: false
maxDepth: provider-managed
```
## 产品兼容性与证据
运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其平台可选依赖提供 Claude Code 2.1.220。强制证据会通过无密钥回环产品路径与带密钥 DeepSeek 路径运行该官方发行版,而 Loader 组合则证明两个选择启用的产品包能够共存,且不会启动任一产品。
项目所有者按身份范围授权分发官方 SDK 及每个 SDK 版本声明的官方 CLI/平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) 会披露当前可选载荷闭包,但不会把其声明条款归类为宽松许可证;其他无关的非宽松运行时依赖仍会使第三方声明门禁失败。
## 模型体验
### 子任务请求
#### 模型看到的内容
Claude Code 子任务会在一个全新的 SDK query 中接收独立文本任务。它的工作区是父会话 cwd;其模型、系统指令、工具、权限和身份验证来自宿主机原生 Claude 设置与产品安装。
#### 对 token 的影响
子任务需为独立的 Claude Code 上下文和 query 承担 token 开销。子任务 token 不会进入父级上下文。
#### 对 KV Cache 的影响
这与父请求缓存相互独立。能否复用只取决于 Claude Code 自身的模型、指令、工具、原生设置和全新 query。
### 父级工具结果(间接)
#### 模型看到的内容
通过 `dsh-tool-subagent`,父级模型只会看到符合严格成功条件的 Claude Code 最终答案,或者在结果未完成时看到消费方给出的原样错误。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息和产品标识符均不会复制到父会话。
#### 对 token 的影响
父级输入只会增加工具结果中保留的最终答案或错误内容。本提供方自身不添加父级工具 schema。
#### 对 KV Cache 的影响
仅追加:新的工具结果接在可复用的父请求前缀之后。
## 已知限制与后续工作
- **每次运行均新建一个 query 和一个进程**:不支持续接、恢复、池化、进度流或产品会话持久化。
- **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。
- **产品安装与账户状态仍由原生机制管理**:不兼容的 SDK 载荷、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。
- **没有人工交互路径**`AskUserQuestion` 被禁用,其他交互回调也不存在,因此需要新审批或输入的任务会失败而不会挂起。
- **仅返回最终文本**:推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。
- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。
- **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。
@@ -0,0 +1,53 @@
{
"name": "@deepseek-ai/dsh-subagent-claude-code",
"description": "One-shot Claude Code subagent provider over the official Agent SDK",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"@anthropic-ai/sdk": "0.93.0",
"@anthropic-ai/claude-agent-sdk": "0.3.220",
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
@@ -0,0 +1,107 @@
/**
* Fixed Claude Code one-shot subagent provider. Every accepted run invokes
* the official Agent SDK in the delegating Session's workspace and places
* the SDK-spawned real CLI under the shared subprocess owner.
*
* @module @deepseek-ai/dsh-subagent-claude-code
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import {
assertPositiveFinite,
NO_START_CAPABILITIES,
resolveChildCwd,
type ResolvedSubagentStartRequest,
type SubagentCapabilities,
type SubagentProvider,
} from '@deepseek-ai/dsh-subagent'
import {
DEFAULT_DISPOSE_GRACE_MS,
startClaudeCodeRun,
type ClaudeCodeRunSpec,
} from './run.ts'
export const name = 'subagent-claude-code'
export const inject = ['subagents', 'subprocess']
/* jscpd:ignore-start -- sibling product providers intentionally expose the
* same two deployment-owned fields without adding a shared config owner. */
/** Deployment-owned environment and process-release bound. */
export interface Config {
/**
* Explicit environment entries layered over the subprocess seam's
* credential-scrubbed parent environment.
*/
env?: Record<string, string>
/** Grace in milliseconds for Claude Code process-tree termination. */
disposeGraceMs?: number
}
export const Config: z<Config> = z.object({
env: z.dict(z.string()).default({}),
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
})
type ResolvedConfig = Required<Config>
/* jscpd:ignore-end */
/* jscpd:ignore-start -- Cordis registration and shared-seam plumbing mirror
* the Codex sibling; each product's lifecycle remains package-private. */
class ClaudeCodeProvider implements SubagentProvider {
readonly name = 'claude-code'
readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES
readonly inheritsParentContext = false
constructor(
private readonly ctx: Context,
private readonly config: ResolvedConfig,
) {}
start(request: ResolvedSubagentStartRequest) {
const parentCwd = request.parent.session.header.cwd
if (parentCwd === undefined) {
throw new Error(
'subagent-claude-code: no working directory for the child — delegate from a parent session that has one',
)
}
const spec: ClaudeCodeRunSpec = {
cwd: resolveChildCwd(
'subagent-claude-code',
undefined,
parentCwd,
),
env: this.config.env,
disposeGraceMs: this.config.disposeGraceMs,
spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec),
onError: (error, stopReason) => {
this.ctx.logger.warn(
`subagent-claude-code: child run failed (${stopReason}): ${error.message}`,
)
},
}
return startClaudeCodeRun(request, spec)
}
}
/**
* Register the fixed `claude-code` provider.
* @param ctx - context carrying shared subagent and subprocess services.
* @param config - explicit child environment and disposal grace.
*/
export function apply(ctx: Context, config: Config): void {
const resolved = config as ResolvedConfig
assertPositiveFinite(
'subagent-claude-code',
'disposeGraceMs',
resolved.disposeGraceMs,
)
if (resolved.disposeGraceMs > MAX_TIMER_DELAY_MS) {
throw new Error(
`subagent-claude-code: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
ctx.subagents.registerProvider(new ClaudeCodeProvider(ctx, resolved))
}
/* jscpd:ignore-end */
@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for
* `@deepseek-ai/dsh-subagent-claude-code`.
* @module @deepseek-ai/dsh-subagent-claude-code/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-claude-code'
/** Cordis companion plugin name. */
export const name = 'subagent-claude-code-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: lifecycle pairing belongs to the shared subagent
* service and process-tree ownership belongs to the subprocess service.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - plugin context carrying the invariant registry.
* @returns the installed registration's disposer.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,156 @@
/**
* Projection from the shared managed-process handle to the official Claude
* Agent SDK's custom-spawn process interface.
*
* @module @deepseek-ai/dsh-subagent-claude-code/process
*/
import { EventEmitter } from 'node:events'
import type {
SpawnedProcess,
SpawnOptions,
} from '@anthropic-ai/claude-agent-sdk'
import {
scrubbedParentEnv,
type SubprocessHandle,
type SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
function thrown(value: unknown): Error {
/* v8 ignore next -- the subprocess seam rejects with Error. */
return value instanceof Error ? value : new Error(String(value))
}
/**
* Encode the SDK's complete child environment as a subprocess overlay.
* @param env - SDK-composed child environment after its removals and replacements.
* @returns explicit values plus tombstones for surviving ambient names the SDK removed.
*/
export function sdkEnvironmentOverlay(
env: SpawnOptions['env'],
): NodeJS.ProcessEnv {
const overlay: NodeJS.ProcessEnv = { ...env }
for (const name of Object.keys(scrubbedParentEnv())) {
if (!(name in env)) overlay[name] = undefined
}
return overlay
}
/**
* Translate one official SDK spawn request to the shared process owner.
* @param options - command, arguments, workspace, environment, and forwarded signal from the SDK.
* @param graceMs - process-tree termination grace.
* @returns the fully explicit shared subprocess request.
*/
export function claudeSpawnSpec(
options: SpawnOptions,
graceMs: number,
): SubprocessSpawnSpec {
if (options.cwd === undefined || options.cwd.length === 0) {
throw new Error('subagent-claude-code: SDK spawn request omitted its workspace')
}
return {
argv: [options.command, ...options.args],
cwd: options.cwd,
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
graceMs,
signal: options.signal,
env: sdkEnvironmentOverlay(options.env),
}
}
/**
* SDK-facing view of one shared managed process. Protocol transport remains
* in the official SDK; this adapter only projects streams and exit events.
*/
export class ManagedClaudeCodeProcess implements SpawnedProcess {
readonly stdin
readonly stdout
private readonly events = new EventEmitter()
private exitCodeValue: number | null = null
private signalCodeValue: NodeJS.Signals | null = null
private killRequested = false
/**
* Project a managed process with piped stdin and stdout.
* @param child - shared handle that remains the process-tree authority.
*/
constructor(private readonly child: SubprocessHandle) {
this.stdin = child.stdin as NonNullable<SubprocessHandle['stdin']>
this.stdout = child.stdout as NonNullable<SubprocessHandle['stdout']>
// EventEmitter gives `error` special throw semantics without a listener.
// The SDK attaches its listener synchronously after custom spawn returns,
// while this no-op also contains an already-rejected spawn handle.
this.events.on('error', () => {})
void child.done.then(
(outcome) => {
this.exitCodeValue = outcome.exitCode
this.signalCodeValue = outcome.signal
this.events.emit('exit', outcome.exitCode, outcome.signal)
},
(error: unknown) => {
this.events.emit('error', thrown(error))
},
)
}
/** Whether the SDK has requested managed tree termination. */
get killed(): boolean {
return this.killRequested
}
/** Direct-child exit code, or null while running or after signal exit. */
get exitCode(): number | null {
return this.exitCodeValue
}
/** Direct-child terminating signal, if any. */
get signalCode(): NodeJS.Signals | null {
return this.signalCodeValue
}
/**
* Route the SDK's termination request to the tree-scoped process owner.
* @param _signal - SDK-selected signal; the shared seam owns its escalation ladder.
* @returns false only after exit or a previous termination request.
*/
kill(_signal: NodeJS.Signals): boolean {
if (
this.killRequested
|| this.exitCodeValue !== null
|| this.signalCodeValue !== null
) {
return false
}
this.killRequested = true
this.child.terminate()
return true
}
/** Register a persistent process lifecycle listener. */
on(
event: 'exit' | 'error',
listener: ((code: number | null, signal: NodeJS.Signals | null) => void)
| ((error: Error) => void),
): void {
this.events.on(event, listener)
}
/** Register a one-shot process lifecycle listener. */
once(
event: 'exit' | 'error',
listener: ((code: number | null, signal: NodeJS.Signals | null) => void)
| ((error: Error) => void),
): void {
this.events.once(event, listener)
}
/** Remove a process lifecycle listener. */
off(
event: 'exit' | 'error',
listener: ((code: number | null, signal: NodeJS.Signals | null) => void)
| ((error: Error) => void),
): void {
this.events.off(event, listener)
}
}
@@ -0,0 +1,287 @@
/**
* One-shot Claude Code lifecycle: invoke the official Agent SDK, place its
* real CLI process under the shared subprocess owner, map only strict SDK
* success to completion, and dispose to whole-tree quiescence.
*
* @module @deepseek-ai/dsh-subagent-claude-code/run
*/
import { randomUUID } from 'node:crypto'
import {
query as officialQuery,
type Options,
type Query,
type SDKMessage,
type SDKResultMessage,
type SpawnOptions,
} from '@anthropic-ai/claude-agent-sdk'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
settleRunResult,
subprocessRunHandle,
type SubagentResult,
type SubagentRun,
type SubagentStartRequest,
type SubagentStopReason,
} from '@deepseek-ai/dsh-subagent'
import {
scrubbedParentEnv,
type SubprocessHandle,
type SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import {
claudeSpawnSpec,
ManagedClaudeCodeProcess,
} from './process.ts'
/** Default POSIX grace between subprocess termination tiers. */
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
/* jscpd:ignore-start -- sibling providers intentionally keep product-private
* run inputs and error normalization instead of adding a shared lifecycle owner. */
/** Fully resolved inputs for one official Claude Agent SDK query. */
export interface ClaudeCodeRunSpec {
/** Parent Session workspace supplied to the SDK and real CLI. */
readonly cwd: string
/** Explicit deployment/test environment layered after shared scrubbing. */
readonly env: Record<string, string>
/** Subprocess termination grace passed to the shared process-tree owner. */
readonly disposeGraceMs: number
/** Shared subprocess service spawn operation. */
readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
/** Diagnostic sink for a post-publication error flattened into a result. */
readonly onError?: (error: Error, stopReason: SubagentStopReason) => void
}
function thrown(value: unknown): Error {
/* v8 ignore next -- typed SDK and subprocess failures reject with Error. */
return value instanceof Error ? value : new Error(String(value))
}
/* jscpd:ignore-end */
/**
* Validate and preserve the one-shot task before crossing the SDK boundary.
* @param prompt - task content accepted from the shared subagent service.
* @returns the exact text sequence as one SDK prompt.
*/
export function textTask(prompt: readonly ContentBlock[]): string {
if (prompt.length === 0) {
throw new Error('subagent-claude-code: the one-shot task must contain only text blocks')
}
const texts: string[] = []
for (const block of prompt) {
if (block.type !== 'text') {
throw new Error('subagent-claude-code: the one-shot task must contain only text blocks')
}
texts.push(block.text)
}
if (texts.every(text => text.trim().length === 0)) {
throw new Error('subagent-claude-code: the one-shot task must not be empty')
}
return texts.join('')
}
/**
* Strictly derive the only SDK result that can complete a shared run.
* @param message - an official discriminated result union.
* @returns exact final text for a successful, non-error result.
*/
export function successfulResult(message: SDKResultMessage): string {
if (
message.subtype !== 'success'
|| message.is_error
|| message.result.trim().length === 0
) {
const detail = message.subtype === 'success'
? 'success result was marked as an error or contained no answer'
: message.errors.join('; ') || message.subtype
throw new Error(`subagent-claude-code: Claude Code failed: ${detail}`)
}
return message.result
}
/**
* Consume the complete SDK stream and require one strict success plus normal
* iterator completion.
* @param query - published official SDK query.
* @returns the completed shared result.
*/
export async function consumeClaudeQuery(
query: AsyncIterable<SDKMessage>,
): Promise<SubagentResult> {
let answer: string | undefined
for await (const message of query) {
if (message.type !== 'result') continue
answer = successfulResult(message)
}
if (answer === undefined) {
throw new Error('subagent-claude-code: Claude Code ended without a result')
}
return {
output: [{ type: 'text', text: answer }],
stopReason: 'completed',
}
}
/**
* Close the official query, terminate the managed process tree, and wait for
* the subprocess owner to prove it is gone.
* @param query - official SDK query, when creation reached that point.
* @param child - shared-service handle that owns the CLI process tree.
*/
export async function disposeClaudeCodeChild(
query: Pick<Query, 'close'> | undefined,
child: SubprocessHandle,
): Promise<void> {
const failures: Error[] = []
try {
query?.close()
} catch (error: unknown) {
failures.push(thrown(error))
}
if (child.pid > 0) {
child.terminate()
try {
await child.waitForExit()
} catch (error: unknown) {
failures.push(thrown(error))
}
}
try {
await child.done
} catch (error: unknown) {
failures.push(thrown(error))
}
const firstFailure = failures[0]
if (failures.length === 1 && firstFailure !== undefined) throw firstFailure
if (failures.length > 1) {
throw new AggregateError(
failures,
'subagent-claude-code: query and process cleanup failed',
)
}
}
/**
* Build the fixed official SDK options for one one-shot provider run.
* @param spec - workspace, environment, process seam, and disposal policy.
* @param controller - per-run cancellation owner.
* @param capture - receives the real managed child synchronously from the SDK hook.
* @returns options that inherit native settings while disabling persistence and user questions.
*/
export function claudeQueryOptions(
spec: ClaudeCodeRunSpec,
controller: AbortController,
capture: (child: SubprocessHandle) => void,
): Options {
return {
abortController: controller,
cwd: spec.cwd,
env: { ...scrubbedParentEnv(), ...spec.env },
persistSession: false,
disallowedTools: ['AskUserQuestion'],
spawnClaudeCodeProcess: (options: SpawnOptions) => {
const child = spec.spawn(claudeSpawnSpec(options, spec.disposeGraceMs))
capture(child)
return new ManagedClaudeCodeProcess(child)
},
}
}
/**
* Start one official Claude Agent SDK query and publish its one-shot run.
* @param request - resolved shared subagent request.
* @param spec - workspace, environment, process seam, and diagnostic policy.
* @returns the published run after both Query and real CLI handle exist.
*/
export async function startClaudeCodeRun(
request: SubagentStartRequest,
spec: ClaudeCodeRunSpec,
): Promise<SubagentRun> {
const prompt = textTask(request.prompt)
if (request.signal.aborted) {
throw new Error('subagent-claude-code: request was aborted before SDK startup')
}
const controller = new AbortController()
const requestCancel = (): void => {
if (!controller.signal.aborted) {
controller.abort(new Error('subagent-claude-code: run cancelled locally'))
}
}
const onAbort = (): void => { requestCancel() }
request.signal.addEventListener('abort', onAbort, { once: true })
let child: SubprocessHandle | undefined
let query: Query | undefined
try {
query = officialQuery({
prompt,
options: claudeQueryOptions(spec, controller, (captured) => {
child = captured
}),
})
if (child === undefined || child.pid <= 0) {
throw new Error(
'subagent-claude-code: official SDK did not publish a controllable Claude Code process',
)
}
if (controller.signal.aborted) {
throw new Error('subagent-claude-code: request was aborted before SDK startup')
}
} catch (error: unknown) {
request.signal.removeEventListener('abort', onAbort)
const cancelledBeforeCleanup = controller.signal.aborted
requestCancel()
if (child !== undefined) {
try {
await disposeClaudeCodeChild(query, child)
} catch (disposeError: unknown) {
throw new AggregateError(
[thrown(error), thrown(disposeError)],
'subagent-claude-code: startup failed and CLI cleanup also failed',
)
}
} else if (query !== undefined) {
try {
query.close()
} catch (disposeError: unknown) {
throw new AggregateError(
[thrown(error), thrown(disposeError)],
'subagent-claude-code: startup failed and query cleanup also failed',
)
}
}
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the request can abort while process cleanup is awaited.
if (cancelledBeforeCleanup || request.signal.aborted) {
throw new Error('subagent-claude-code: request was aborted before SDK startup')
}
throw thrown(error)
}
const publishedQuery = query
const publishedChild = child
const result = settleRunResult({
attempt: () => consumeClaudeQuery(publishedQuery),
collectOutput: () => [],
cancelled: () => controller.signal.aborted,
onError: spec.onError,
signal: request.signal,
onAbort,
})
return subprocessRunHandle({
id: SessionId(randomUUID()),
result,
signal: request.signal,
onAbort,
requestCancel,
teardown: () => disposeClaudeCodeChild(
publishedQuery,
publishedChild,
),
})
}
@@ -0,0 +1,72 @@
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import {
LOADER_SMOKE_TEST_TIMEOUT_MS,
runLoaderSmoke,
} from '@deepseek-ai/dsh-loader-smoke'
const fixtureDir = fileURLToPath(new URL(
'../../../../examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/',
import.meta.url,
))
const driver = join(fixtureDir, 'driver.ts')
const configPath = join(fixtureDir, 'cordis.yml')
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
describe('product-provider public Loader composition', () => {
it('loads both opt-in packages and foreground tools without starting either product', async () => {
const { stdout, stderr } = await runLoaderSmoke({
label: 'product-provider Loader composition',
tempDirPrefix: 'dsh-product-provider-loader-',
binScript: driver,
libBinScript: driver,
configPath,
tsconfigPath: repoTsconfig,
env: {
// Loading either optional package must not probe or start its binary.
PATH: '',
},
})
expect(stderr).toBe('')
expect(JSON.parse(stdout)).toEqual({
registeredProviders: ['codex', 'claude-code'],
providers: [
{
name: 'codex',
capabilities: {
outputSchema: false,
depthLimit: false,
toolFilter: false,
persona: false,
},
inheritsParentContext: false,
},
{
name: 'claude-code',
capabilities: {
outputSchema: false,
depthLimit: false,
toolFilter: false,
persona: false,
},
inheritsParentContext: false,
},
],
tools: [
{
name: 'subagent_codex',
parameterNames: ['description', 'prompt'],
required: ['description', 'prompt'],
},
{
name: 'subagent_claude_code',
parameterNames: ['description', 'prompt'],
required: ['description', 'prompt'],
},
],
starts: 0,
})
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})
@@ -0,0 +1,150 @@
import { createServer, type IncomingHttpHeaders, type ServerResponse } from 'node:http'
/** One deterministic response emitted by the package-private Messages server. */
export type MessagesBehavior =
| { readonly kind: 'complete'; readonly text: string }
| { readonly kind: 'hold' }
/** One recorded Anthropic Messages request. */
interface RecordedMessagesRequest {
readonly method: string
readonly path: string
readonly headers: IncomingHttpHeaders
readonly body: Record<string, unknown>
}
/** Running package-private Anthropic Messages fixture. */
export interface MessagesFixture {
readonly baseUrl: string
readonly requests: RecordedMessagesRequest[]
readonly requestStarted: Promise<void>
close(): Promise<void>
}
function event(
response: ServerResponse,
type: string,
payload: Record<string, unknown>,
): void {
response.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`)
}
function complete(
response: ServerResponse,
body: Record<string, unknown>,
text: string,
): void {
const model = typeof body.model === 'string' ? body.model : 'fixture-model'
response.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
})
event(response, 'message_start', {
type: 'message_start',
message: {
id: 'msg_dsh_fixture',
type: 'message',
role: 'assistant',
model,
content: [],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 7,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
})
event(response, 'content_block_start', {
type: 'content_block_start',
index: 0,
content_block: { type: 'text', text: '' },
})
event(response, 'content_block_delta', {
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text },
})
event(response, 'content_block_stop', {
type: 'content_block_stop',
index: 0,
})
event(response, 'message_delta', {
type: 'message_delta',
delta: { stop_reason: 'end_turn', stop_sequence: null },
usage: { output_tokens: 1 },
})
event(response, 'message_stop', { type: 'message_stop' })
response.end()
}
/**
* Start a loopback-only Anthropic Messages SSE fixture.
* @param behavior - the single response behavior for this fixture.
* @returns the bound server and its recorded requests.
*/
export async function startMessagesFixture(
behavior: MessagesBehavior,
): Promise<MessagesFixture> {
const requests: RecordedMessagesRequest[] = []
let requestStartedResolve!: () => void
const requestStarted = new Promise<void>((resolve) => {
requestStartedResolve = resolve
})
const server = createServer((request, response) => {
const chunks: Buffer[] = []
request.on('data', (chunk: Buffer) => { chunks.push(chunk) })
request.on('end', () => {
const path = request.url ?? ''
if (path !== '/v1/messages' && !path.startsWith('/v1/messages?')) {
response.writeHead(404, { 'content-type': 'application/json' })
response.end(JSON.stringify({
type: 'error',
error: { type: 'not_found_error', message: `unexpected path ${path}` },
}))
return
}
const text = Buffer.concat(chunks).toString('utf8')
const body = JSON.parse(text) as Record<string, unknown>
requests.push({
method: request.method ?? '',
path,
headers: request.headers,
body,
})
requestStartedResolve()
if (behavior.kind === 'complete') {
complete(response, body, behavior.text)
}
// A hold deliberately leaves the response pending until client abort.
})
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
server.off('error', reject)
resolve()
})
})
const address = server.address()
if (address === null || typeof address === 'string') {
throw new Error('Messages fixture did not bind a TCP port')
}
return {
baseUrl: `http://127.0.0.1:${address.port}`,
requests,
requestStarted,
async close(): Promise<void> {
server.closeAllConnections()
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error !== undefined) reject(error)
else resolve()
})
})
},
}
}
@@ -0,0 +1,160 @@
import { execFile } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as claudeCode from '../src/index.ts'
const execFileAsync = promisify(execFile)
const OFFICIAL_DEEPSEEK_BASE_URL = 'https://api.deepseek.com'
const sdkRoot = dirname(fileURLToPath(
import.meta.resolve('@anthropic-ai/claude-agent-sdk'),
))
const sdkPackage = JSON.parse(readFileSync(
join(sdkRoot, 'package.json'),
'utf8',
)) as {
version: string
claudeCodeVersion: string
optionalDependencies: Record<string, string>
}
const platformPackage = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`
const platformRoot = resolve(sdkRoot, '..', platformPackage.split('/')[1]!)
const claudeBin = join(
platformRoot,
process.platform === 'win32' ? 'claude.exe' : 'claude',
)
const roots: string[] = []
const contexts: Context[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function deepSeekBaseUrl(): string {
const configured = (process.env.DEEPSEEK_BASE_URL ?? OFFICIAL_DEEPSEEK_BASE_URL)
.replace(/\/+$/, '')
if (configured !== OFFICIAL_DEEPSEEK_BASE_URL) {
throw new Error('Claude Code DeepSeek e2e requires the official DeepSeek base URL')
}
return configured
}
async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise<void> {
expect(handles.length).toBeGreaterThan(0)
for (const handle of handles) {
await expect(handle.waitForExit()).resolves.toBe(true)
await expect(handle.done).resolves.toHaveProperty('exitCode')
}
}
describe.skipIf(!process.env.DEEPSEEK_API_KEY)(
'Claude Code provider with real DeepSeek API',
() => {
it('returns one unique nonce through the production provider and real SDK/CLI', async () => {
const apiKey = process.env.DEEPSEEK_API_KEY
if (apiKey === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY')
const root = mkdtempSync(join(tmpdir(), 'dsh-claude-deepseek-e2e-'))
roots.push(root)
const workspace = join(root, 'workspace')
const claudeConfig = join(root, 'claude-config')
const xdgConfig = join(root, 'xdg-config')
const xdgCache = join(root, 'xdg-cache')
const xdgData = join(root, 'xdg-data')
const xdgState = join(root, 'xdg-state')
for (const directory of [
workspace,
claudeConfig,
xdgConfig,
xdgCache,
xdgData,
xdgState,
]) mkdirSync(directory)
const env = {
ANTHROPIC_AUTH_TOKEN: apiKey,
ANTHROPIC_BASE_URL: `${deepSeekBaseUrl()}/anthropic`,
ANTHROPIC_MODEL: 'deepseek-v4-pro[1m]',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'deepseek-v4-pro[1m]',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'deepseek-v4-pro[1m]',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'deepseek-v4-flash',
CLAUDE_CODE_SUBAGENT_MODEL: 'deepseek-v4-flash',
CLAUDE_CODE_EFFORT_LEVEL: 'max',
CLAUDE_CONFIG_DIR: claudeConfig,
HOME: root,
XDG_CONFIG_HOME: xdgConfig,
XDG_CACHE_HOME: xdgCache,
XDG_DATA_HOME: xdgData,
XDG_STATE_HOME: xdgState,
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: '1',
DISABLE_TELEMETRY: '1',
DISABLE_ERROR_REPORTING: '1',
HTTP_PROXY: '',
HTTPS_PROXY: '',
ALL_PROXY: '',
NO_PROXY: '127.0.0.1,localhost',
}
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
const handles: SubprocessHandle[] = []
const spawn = ctx.subprocess.spawn.bind(ctx.subprocess)
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
const handle = spawn(spec)
handles.push(handle)
return handle
})
await ctx.plugin(claudeCode, { env, disposeGraceMs: 3_000 })
expect(sdkPackage.version).toBe('0.3.220')
expect(sdkPackage.claudeCodeVersion).toBe('2.1.220')
expect(sdkPackage.optionalDependencies[platformPackage]).toBe('0.3.220')
const version = await execFileAsync(claudeBin, ['--version'], {
env: { ...process.env, ...env },
})
expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)')
const nonce = `DSH_CLAUDE_DEEPSEEK_${randomUUID()}`
const parent = {
id: 'deepseek-e2e-parent',
session: { header: { cwd: workspace } },
} as unknown as Agent
const run = await ctx.subagents.start('claude-code', {
prompt: [{
type: 'text',
text: `Reply with exactly ${nonce} and nothing else. Do not use tools.`,
}],
parent,
signal: new AbortController().signal,
})
const result = await run.result
await run.dispose()
expect(result.stopReason).toBe('completed')
const text = result.output
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
.trim()
expect(text).toBe(nonce)
await expectQuiescent(handles)
}, 180_000)
},
)
@@ -0,0 +1,272 @@
import { execFile } from 'node:child_process'
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import type {
Query,
SDKMessage,
SDKSystemMessage,
} from '@anthropic-ai/claude-agent-sdk'
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as claudeCode from '../src/index.ts'
import {
startMessagesFixture,
type MessagesBehavior,
type MessagesFixture,
} from './messages-fixture.ts'
const observedSdkMessages = vi.hoisted((): SDKMessage[] => [])
vi.mock('@anthropic-ai/claude-agent-sdk', async (importOriginal) => {
const actual = await importOriginal<
typeof import('@anthropic-ai/claude-agent-sdk')
>()
return {
...actual,
query(options: Parameters<typeof actual.query>[0]): Query {
const query = actual.query(options)
// Observe the real SDK stream without replacing its protocol or CLI.
return new Proxy(query, {
get(target, property) {
if (property === Symbol.asyncIterator) {
return async function* (): AsyncGenerator<SDKMessage, void> {
for await (const message of target) {
observedSdkMessages.push(message)
yield message
}
}
}
const value: unknown = Reflect.get(target, property, target)
if (typeof value === 'function') {
const method = value as (...args: unknown[]) => unknown
return method.bind(target)
}
return value
},
})
},
}
})
const execFileAsync = promisify(execFile)
const sdkRoot = dirname(fileURLToPath(
import.meta.resolve('@anthropic-ai/claude-agent-sdk'),
))
const sdkPackage = JSON.parse(readFileSync(
join(sdkRoot, 'package.json'),
'utf8',
)) as {
version: string
claudeCodeVersion: string
optionalDependencies: Record<string, string>
}
const platformPackage = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`
const platformRoot = resolve(sdkRoot, '..', platformPackage.split('/')[1]!)
const claudeBin = join(
platformRoot,
process.platform === 'win32' ? 'claude.exe' : 'claude',
)
const settingsModel = 'dsh-settings-inheritance-marker'
const fakeKey = 'dsh-fake-anthropic-key'
const roots: string[] = []
const fixtures: MessagesFixture[] = []
const contexts: Context[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
await Promise.all(fixtures.splice(0).map(fixture => fixture.close()))
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
observedSdkMessages.length = 0
})
interface RealHarness {
readonly ctx: Context
readonly handles: SubprocessHandle[]
readonly parent: Agent
readonly workspace: string
readonly env: Record<string, string>
}
async function realHarness(behavior: MessagesBehavior): Promise<{
readonly harness: RealHarness
readonly fixture: MessagesFixture
}> {
const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-real-'))
roots.push(root)
const workspace = join(root, 'workspace')
const claudeConfig = join(root, 'claude-config')
const xdgConfig = join(root, 'xdg')
mkdirSync(workspace)
mkdirSync(claudeConfig)
mkdirSync(xdgConfig)
writeFileSync(
join(claudeConfig, 'settings.json'),
`${JSON.stringify({ model: settingsModel }, null, 2)}\n`,
)
const fixture = await startMessagesFixture(behavior)
fixtures.push(fixture)
const env = {
ANTHROPIC_API_KEY: fakeKey,
ANTHROPIC_BASE_URL: fixture.baseUrl,
CLAUDE_CONFIG_DIR: claudeConfig,
HOME: root,
XDG_CONFIG_HOME: xdgConfig,
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: '1',
DISABLE_TELEMETRY: '1',
DISABLE_ERROR_REPORTING: '1',
HTTP_PROXY: '',
HTTPS_PROXY: '',
ALL_PROXY: '',
NO_PROXY: '127.0.0.1,localhost',
}
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
const handles: SubprocessHandle[] = []
const spawn = ctx.subprocess.spawn.bind(ctx.subprocess)
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
const handle = spawn(spec)
handles.push(handle)
return handle
})
await ctx.plugin(claudeCode, { env, disposeGraceMs: 3_000 })
const parent = {
id: 'real-parent',
session: { header: { cwd: workspace } },
} as unknown as Agent
return {
harness: { ctx, handles, parent, workspace, env },
fixture,
}
}
async function expectQuiescent(
handles: readonly SubprocessHandle[],
): Promise<void> {
expect(handles.length).toBeGreaterThan(0)
for (const handle of handles) {
await expect(handle.waitForExit()).resolves.toBe(true)
const outcome = await handle.done
expect(outcome).toHaveProperty('exitCode')
expect(outcome).toHaveProperty('signal')
}
}
function startRequest(
harness: RealHarness,
prompt: string,
signal = new AbortController().signal,
) {
return harness.ctx.subagents.start('claude-code', {
prompt: [{ type: 'text', text: prompt }],
parent: harness.parent,
signal,
})
}
describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', {
timeout: 60_000,
}, () => {
it('inherits host settings and sends the exact task and fake key to local Messages', async () => {
const sentinel = 'REAL_CLAUDE_CODE_SENTINEL_2_1_220'
const task = 'Return the fixture sentinel exactly.'
const { harness, fixture } = await realHarness({
kind: 'complete',
text: sentinel,
})
expect(sdkPackage.version).toBe('0.3.220')
expect(sdkPackage.claudeCodeVersion).toBe('2.1.220')
expect(sdkPackage.optionalDependencies[platformPackage]).toBe('0.3.220')
const version = await execFileAsync(claudeBin, ['--version'], {
env: { ...process.env, ...harness.env },
})
expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)')
const run = await startRequest(harness, task)
await expect(run.result).resolves.toEqual({
output: [{ type: 'text', text: sentinel }],
stopReason: 'completed',
})
await run.dispose()
const initMessage = observedSdkMessages.find(
(message): message is SDKSystemMessage =>
message.type === 'system' && message.subtype === 'init',
)
expect(initMessage?.claude_code_version).toBe('2.1.220')
expect(fixture.requests).toHaveLength(1)
const recorded = fixture.requests[0]!
expect(recorded.method).toBe('POST')
expect(recorded.path).toMatch(/^\/v1\/messages(?:\?.*)?$/)
expect(recorded.headers['x-api-key']).toBe(fakeKey)
expect(recorded.body.model).toBe(settingsModel)
expect(Array.isArray(recorded.body.messages)).toBe(true)
const messageTexts = (
recorded.body.messages as Array<{ content?: unknown }>
).flatMap((message): unknown[] =>
Array.isArray(message.content) ? message.content as unknown[] : [])
.filter((block): block is { type: string; text: string } =>
typeof block === 'object'
&& block !== null
&& 'type' in block
&& block.type === 'text'
&& 'text' in block
&& typeof block.text === 'string')
.map(block => block.text)
expect(messageTexts.filter(text => text.includes(task))).toEqual([task])
await expectQuiescent(harness.handles)
})
it('maps a real CLI process failure to error', async () => {
const { harness, fixture } = await realHarness({ kind: 'hold' })
const run = await startRequest(harness, 'Exercise the failure path.')
await fixture.requestStarted
expect(harness.handles).toHaveLength(1)
harness.handles[0]!.terminate()
await expect(run.result).resolves.toEqual({
output: [],
stopReason: 'error',
})
await run.dispose()
expect(fixture.requests).toHaveLength(1)
expect(fixture.requests[0]!.headers['x-api-key']).toBe(fakeKey)
await expectQuiescent(harness.handles)
})
it('settles cancellation and leaves the real SDK-spawned CLI tree quiescent', async () => {
const { harness, fixture } = await realHarness({ kind: 'hold' })
const controller = new AbortController()
const run = await startRequest(
harness,
'Wait for cancellation.',
controller.signal,
)
await fixture.requestStarted
controller.abort(new Error('real product cancellation'))
await expect(run.result).resolves.toEqual({
output: [],
stopReason: 'aborted',
})
await run.dispose()
await expectQuiescent(harness.handles)
})
})
@@ -0,0 +1,881 @@
import { PassThrough } from 'node:stream'
import type {
Options,
Query,
SDKMessage,
SDKResultMessage,
SpawnOptions,
} from '@anthropic-ai/claude-agent-sdk'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import {
afterEach,
beforeEach,
describe,
expect,
it,
type Mock,
vi,
} from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type {
SubprocessHandle,
SubprocessOutcome,
SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import * as claudeCode from '../src/index.ts'
import * as invariant from '../src/invariant.ts'
import {
claudeSpawnSpec,
ManagedClaudeCodeProcess,
sdkEnvironmentOverlay,
} from '../src/process.ts'
import {
claudeQueryOptions,
consumeClaudeQuery,
disposeClaudeCodeChild,
startClaudeCodeRun,
successfulResult,
textTask,
type ClaudeCodeRunSpec,
} from '../src/run.ts'
type QueryFactory = (params: {
prompt: string
options: Options
}) => Query
const queryMock = vi.hoisted(() => vi.fn<QueryFactory>())
vi.mock('@anthropic-ai/claude-agent-sdk', async importOriginal => ({
...await importOriginal<typeof import('@anthropic-ai/claude-agent-sdk')>(),
query: queryMock,
}))
const fakeParent = {
id: 'parent',
session: { header: { cwd: process.cwd() } },
} as unknown as Agent
function request(
prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }],
signal = new AbortController().signal,
) {
return { prompt, parent: fakeParent, signal }
}
async function nextTask(): Promise<void> {
await new Promise<void>((resolve) => { setImmediate(resolve) })
}
interface FakeChildOptions {
readonly pid?: number
readonly exitOnTerminate?: boolean
readonly waitForExitError?: Error
readonly doneError?: Error
}
interface FakeChild {
readonly handle: SubprocessHandle
readonly stdin: PassThrough
readonly stdout: PassThrough
readonly settle: (outcome?: SubprocessOutcome) => void
readonly fail: (error: Error) => void
readonly terminate: Mock<SubprocessHandle['terminate']>
readonly waitForExit: Mock<SubprocessHandle['waitForExit']>
}
function fakeChild(options: FakeChildOptions = {}): FakeChild {
const stdin = new PassThrough()
const stdout = new PassThrough()
let exited = false
let resolveDone!: (outcome: SubprocessOutcome) => void
let rejectDone!: (error: Error) => void
const done = new Promise<SubprocessOutcome>((resolve, reject) => {
resolveDone = resolve
rejectDone = reject
})
// Individual tests deliberately exercise rejected and still-pending handles.
void done.catch(() => {})
const settle = (
outcome: SubprocessOutcome = { exitCode: 0, signal: null },
): void => {
if (exited) return
exited = true
resolveDone(outcome)
}
const fail = (error: Error): void => {
if (exited) return
exited = true
rejectDone(error)
}
if (options.doneError !== undefined) fail(options.doneError)
const terminate = vi.fn<SubprocessHandle['terminate']>(() => {
if (options.exitOnTerminate !== false) settle()
})
const waitForExit = vi.fn<SubprocessHandle['waitForExit']>(async (signal?: AbortSignal): Promise<boolean> => {
if (options.waitForExitError !== undefined) {
throw options.waitForExitError
}
if (exited) return true
if (signal === undefined) {
await done.catch(() => {})
return true
}
return await new Promise<boolean>((resolve) => {
const onAbort = (): void => { resolve(false) }
signal.addEventListener('abort', onAbort, { once: true })
void done.then(
() => {
signal.removeEventListener('abort', onAbort)
resolve(true)
},
() => {
signal.removeEventListener('abort', onAbort)
resolve(true)
},
)
})
})
const handle: SubprocessHandle = {
pid: options.pid ?? 1234,
stdin,
stdout,
stderr: undefined,
collected: {},
done,
terminate,
waitForExit,
}
return {
handle,
stdin,
stdout,
settle,
fail,
terminate,
waitForExit,
}
}
function success(
result = 'answer',
isError = false,
): SDKResultMessage {
return {
type: 'result',
subtype: 'success',
is_error: isError,
result,
} as SDKResultMessage
}
type ErrorSubtype = Exclude<SDKResultMessage['subtype'], 'success'>
function failure(
subtype: ErrorSubtype,
errors: string[] = ['fixture failure'],
): SDKResultMessage {
return {
type: 'result',
subtype,
is_error: true,
errors,
} as SDKResultMessage
}
function queryFrom(
messages: readonly SDKMessage[],
after?: Error,
close = vi.fn(),
): Query {
async function* stream(): AsyncGenerator<SDKMessage, void> {
for (const message of messages) yield message
if (after !== undefined) throw after
}
return Object.assign(stream(), { close }) as unknown as Query
}
function waitingQuery(signal: AbortSignal, close = vi.fn()): Query {
async function* stream(): AsyncGenerator<SDKMessage, void> {
await new Promise<never>((_resolve, reject) => {
const fail = (): void => {
reject(signal.reason instanceof Error
? signal.reason
: new Error(String(signal.reason)))
}
if (signal.aborted) fail()
else signal.addEventListener('abort', fail, { once: true })
})
}
return Object.assign(stream(), { close }) as unknown as Query
}
function sdkSpawnOptions(
overrides: Partial<SpawnOptions> = {},
): SpawnOptions {
return {
command: '/sdk/claude',
args: ['--output-format', 'stream-json'],
cwd: '/workspace',
env: { PATH: '/bin', OMITTED: undefined },
signal: new AbortController().signal,
...overrides,
}
}
interface FakeRun {
readonly child: FakeChild
readonly close: ReturnType<typeof vi.fn>
readonly spawnSpecs: SubprocessSpawnSpec[]
readonly options: Options[]
readonly spec: ClaudeCodeRunSpec
}
function fakeRun(
messages: readonly SDKMessage[] = [success()],
after?: Error,
child = fakeChild(),
): FakeRun {
const close = vi.fn()
const query = queryFrom(messages, after, close)
const spawnSpecs: SubprocessSpawnSpec[] = []
const options: FakeRun['options'] = []
const spec: ClaudeCodeRunSpec = {
cwd: '/workspace',
env: { ANTHROPIC_API_KEY: 'fake-key' },
disposeGraceMs: 5,
spawn: (spawnSpec) => {
spawnSpecs.push(spawnSpec)
return child.handle
},
}
queryMock.mockImplementation((params) => {
options.push(params.options)
params.options.spawnClaudeCodeProcess!(sdkSpawnOptions())
return query
})
return { child, close, spawnSpecs, options, spec }
}
beforeEach(() => {
queryMock.mockImplementation(({ options }) => {
options.spawnClaudeCodeProcess!(sdkSpawnOptions({
cwd: options.cwd!,
env: options.env!,
signal: options.abortController!.signal,
}))
return queryFrom([])
})
})
afterEach(() => {
queryMock.mockReset()
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
describe('task admission and package contracts', () => {
it('preserves text sequences and rejects empty, blank, and non-text tasks', () => {
expect(textTask([
{ type: 'text', text: 'one' },
{ type: 'text', text: 'two' },
])).toBe('onetwo')
expect(() => textTask([])).toThrow('only text blocks')
expect(() => textTask([{ type: 'reasoning', text: 'hidden' }]))
.toThrow('only text blocks')
expect(() => textTask([{ type: 'text', text: ' \n ' }]))
.toThrow('must not be empty')
})
it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
const fiber = await ctx.plugin(claudeCode, {})
expect(ctx.subagents.getProvider('claude-code')).toMatchObject({
name: 'claude-code',
capabilities: {
outputSchema: false,
depthLimit: false,
toolFilter: false,
persona: false,
},
inheritsParentContext: false,
})
expect(ctx.subagents.list()).toEqual(['claude-code'])
await fiber.dispose()
expect(ctx.subagents.list()).toEqual([])
for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
await expect(ctx.plugin(claudeCode, { disposeGraceMs }))
.rejects.toThrow('disposeGraceMs must be a positive finite number')
}
await expect(ctx.plugin(claudeCode, {
disposeGraceMs: MAX_TIMER_DELAY_MS + 1,
})).rejects.toThrow(
`disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`,
)
await ctx.fiber.dispose()
})
it('starts through the registered provider with its resolved config and diagnostics', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
const child = fakeChild()
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
.mockImplementation(() => child.handle)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
await ctx.plugin(claudeCode, {
env: {
ANTHROPIC_API_KEY: 'provider-fake-key',
CLAUDE_CONFIG_DIR: '/private/tmp/dsh-claude-code-unit-config',
HOME: '/private/tmp/dsh-claude-code-unit-home',
},
disposeGraceMs: 29,
})
await expect(ctx.subagents.start('claude-code', {
...request(),
parent: {
id: 'parent-without-cwd',
session: { header: {} },
} as unknown as Agent,
})).rejects.toThrow(
'subagent-claude-code: no working directory for the child — delegate from a parent session that has one',
)
expect(queryMock).not.toHaveBeenCalled()
const run = await ctx.subagents.start('claude-code', request())
child.settle({ exitCode: 9, signal: null })
child.stdout.end()
await expect(run.result).resolves.toEqual({
output: [],
stopReason: 'error',
})
expect(warn).toHaveBeenCalledWith(expect.stringContaining(
'subagent-claude-code: child run failed (error):',
))
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
cwd: process.cwd(),
graceMs: 29,
}))
expect(spawn.mock.calls[0]?.[0].env).toMatchObject({
ANTHROPIC_API_KEY: 'provider-fake-key',
})
await run.dispose()
await ctx.fiber.dispose()
})
it('keeps the Loader namespace shape and package-owned empty invariant', async () => {
expect('default' in claudeCode).toBe(false)
expect(claudeCode.name).toBe('subagent-claude-code')
expect(claudeCode.inject).toEqual(['subagents', 'subprocess'])
const loader = Object.create(Loader.prototype) as Loader
expect(loader.unwrapExports(claudeCode)).toBe(claudeCode)
const dispose = vi.fn()
const register = vi.fn((
_packageName: string,
_installer: InvariantInstaller,
) => dispose)
const ctx = { invariants: { register } } as unknown as Context
await expect(invariant.apply(ctx)).resolves.toBe(dispose)
expect(register).toHaveBeenCalledWith(
'@deepseek-ai/dsh-subagent-claude-code',
expect.any(Function),
)
const install = register.mock.calls[0]![1]
await install(new Context(), (message) => { throw new Error(message) })
expect(invariant.name).toBe('subagent-claude-code-invariant')
expect(invariant.inject).toEqual(['invariants'])
})
})
describe('official spawn projection', () => {
it('forwards command, arguments, cwd, environment, and signal exactly', () => {
vi.stubEnv('SDK_REMOVED_AMBIENT', 'ambient-value')
const signal = new AbortController().signal
const options = sdkSpawnOptions({
command: '/official/claude',
args: ['--one', 'two'],
cwd: '/parent/workspace',
env: { A: 'one', B: undefined, C: 'three' },
signal,
})
expect(sdkEnvironmentOverlay(options.env)).toEqual(expect.objectContaining({
A: 'one',
B: undefined,
C: 'three',
SDK_REMOVED_AMBIENT: undefined,
}))
const spawnSpec = claudeSpawnSpec(options, 321)
expect(spawnSpec).toMatchObject({
argv: ['/official/claude', '--one', 'two'],
cwd: '/parent/workspace',
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
graceMs: 321,
signal,
})
expect(spawnSpec.env).toEqual(expect.objectContaining({
A: 'one',
B: undefined,
C: 'three',
SDK_REMOVED_AMBIENT: undefined,
}))
const missingCwd = sdkSpawnOptions()
delete missingCwd.cwd
expect(() => claudeSpawnSpec(
missingCwd,
321,
)).toThrow('SDK spawn request omitted its workspace')
expect(() => claudeSpawnSpec(
sdkSpawnOptions({ cwd: '' }),
321,
)).toThrow('SDK spawn request omitted its workspace')
})
it('projects streams, exit facts, listeners, and idempotent tree termination', async () => {
const child = fakeChild({ exitOnTerminate: false })
const process = new ManagedClaudeCodeProcess(child.handle)
expect(process.stdin).toBe(child.stdin)
expect(process.stdout).toBe(child.stdout)
expect(process.killed).toBe(false)
expect(process.exitCode).toBeNull()
expect(process.signalCode).toBeNull()
const exit = vi.fn()
const once = vi.fn()
const removed = vi.fn()
process.on('exit', exit)
process.once('exit', once)
process.on('exit', removed)
process.off('exit', removed)
expect(process.kill('SIGTERM')).toBe(true)
expect(process.killed).toBe(true)
expect(process.kill('SIGKILL')).toBe(false)
expect(child.terminate).toHaveBeenCalledOnce()
child.settle({ exitCode: null, signal: 'SIGTERM' })
await nextTask()
expect(exit).toHaveBeenCalledWith(null, 'SIGTERM')
expect(once).toHaveBeenCalledOnce()
expect(removed).not.toHaveBeenCalled()
expect(process.signalCode).toBe('SIGTERM')
expect(process.kill('SIGTERM')).toBe(false)
})
it('emits spawn errors', async () => {
const child = fakeChild()
const process = new ManagedClaudeCodeProcess(child.handle)
const errorListener = vi.fn()
const removed = vi.fn()
process.once('error', errorListener)
process.on('error', removed)
process.off('error', removed)
child.fail(new Error('spawn boom'))
await nextTask()
expect(errorListener).toHaveBeenCalledWith(expect.objectContaining({
message: 'spawn boom',
}))
expect(removed).not.toHaveBeenCalled()
})
it('exposes a settled direct-child exit code', async () => {
const child = fakeChild()
const process = new ManagedClaudeCodeProcess(child.handle)
child.settle({ exitCode: 7, signal: null })
await nextTask()
expect(process.exitCode).toBe(7)
expect(process.signalCode).toBeNull()
expect(process.kill('SIGTERM')).toBe(false)
})
})
describe('query options and result mapping', () => {
it('builds the fixed unattended options over the scrubbed environment', () => {
vi.stubEnv('HOST_VISIBLE', 'visible')
vi.stubEnv('HOST_SECRET_TOKEN', 'must-not-leak')
vi.stubEnv('DSH_INTERNAL', 'must-not-leak')
const child = fakeChild()
const spawn = vi.fn(() => child.handle)
const captured: SubprocessHandle[] = []
const spec: ClaudeCodeRunSpec = {
cwd: '/workspace',
env: {
HOST_VISIBLE: 'overridden',
ANTHROPIC_API_KEY: 'explicit-fake-key',
},
disposeGraceMs: 17,
spawn,
}
const controller = new AbortController()
const options = claudeQueryOptions(spec, controller, (value) => {
captured.push(value)
})
expect(options).toMatchObject({
abortController: controller,
cwd: '/workspace',
persistSession: false,
disallowedTools: ['AskUserQuestion'],
})
expect(options.env).toMatchObject({
HOST_VISIBLE: 'overridden',
ANTHROPIC_API_KEY: 'explicit-fake-key',
})
expect(options.env).not.toHaveProperty('HOST_SECRET_TOKEN')
expect(options.env).not.toHaveProperty('DSH_INTERNAL')
for (const omitted of [
'settingSources',
'canUseTool',
'onElicitation',
'onUserDialog',
'supportedDialogKinds',
]) {
expect(options).not.toHaveProperty(omitted)
}
const spawned = options.spawnClaudeCodeProcess!(sdkSpawnOptions())
expect(spawned).toBeInstanceOf(ManagedClaudeCodeProcess)
expect(captured).toEqual([child.handle])
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
argv: ['/sdk/claude', '--output-format', 'stream-json'],
cwd: '/workspace',
graceMs: 17,
}))
})
it('accepts only a non-error success with a non-blank final result', () => {
expect(successfulResult(success('exact final'))).toBe('exact final')
expect(() => successfulResult(success('answer', true)))
.toThrow('marked as an error')
expect(() => successfulResult(success(' \n ')))
.toThrow('contained no answer')
expect(() => successfulResult(failure(
'error_during_execution',
['first', 'second'],
))).toThrow('first; second')
expect(() => successfulResult(failure(
'error_max_turns',
[],
))).toThrow('error_max_turns')
})
it('consumes the complete stream and keeps the latest strict success', async () => {
const query = queryFrom([
{ type: 'system', subtype: 'init' } as SDKMessage,
success('first'),
success('last'),
])
await expect(consumeClaudeQuery(query)).resolves.toEqual({
output: [{ type: 'text', text: 'last' }],
stopReason: 'completed',
})
await expect(consumeClaudeQuery(
queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]),
)).rejects.toThrow('ended without a result')
})
})
describe('run publication, cancellation, and settlement', () => {
it('publishes only after Query and managed child exist, then disposes once', async () => {
const fixture = fakeRun([success('exact answer')])
const run = await startClaudeCodeRun(
request([
{ type: 'text', text: 'first' },
{ type: 'text', text: 'second' },
]),
fixture.spec,
)
expect(fixture.options).toHaveLength(1)
expect(fixture.spawnSpecs).toHaveLength(1)
await expect(run.result).resolves.toEqual({
output: [{ type: 'text', text: 'exact answer' }],
stopReason: 'completed',
})
const first = run.dispose()
const second = run.dispose()
expect(second).toBe(first)
await first
expect(fixture.close).toHaveBeenCalledOnce()
expect(fixture.child.terminate).toHaveBeenCalledOnce()
})
it('flattens every SDK error result without inventing shared stop reasons', async () => {
const subtypes: ErrorSubtype[] = [
'error_during_execution',
'error_max_turns',
'error_max_budget_usd',
'error_max_structured_output_retries',
]
for (const subtype of subtypes) {
const fixture = fakeRun([failure(subtype)])
const onError = vi.fn()
const run = await startClaudeCodeRun(
request(),
{ ...fixture.spec, onError },
)
await expect(run.result).resolves.toEqual({
output: [],
stopReason: 'error',
})
expect(onError).toHaveBeenCalledWith(
expect.any(Error),
'error',
)
await run.dispose()
}
})
it('fails closed when iteration rejects after a result', async () => {
const fixture = fakeRun(
[success('partial final')],
new Error('iterator boom'),
)
const run = await startClaudeCodeRun(request(), fixture.spec)
await expect(run.result).resolves.toEqual({
output: [],
stopReason: 'error',
})
await run.dispose()
})
it('maps invalid success and missing result to error', async () => {
for (const messages of [
[success('answer', true)],
[success('')],
[{ type: 'system', subtype: 'init' } as SDKMessage],
]) {
const fixture = fakeRun(messages)
const run = await startClaudeCodeRun(request(), fixture.spec)
await expect(run.result).resolves.toMatchObject({
stopReason: 'error',
})
await run.dispose()
}
})
it('gives local cancellation precedence and isolates overlapping controllers', async () => {
const firstChild = fakeChild()
const secondChild = fakeChild()
const children = [firstChild, secondChild]
const controllers: AbortController[] = []
let index = 0
const spec: ClaudeCodeRunSpec = {
cwd: '/workspace',
env: {},
disposeGraceMs: 5,
spawn: () => children[index++]!.handle,
}
queryMock.mockImplementation(({ prompt, options }) => {
controllers.push(options.abortController!)
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
return prompt === 'wait'
? waitingQuery(options.abortController!.signal)
: queryFrom([success('second answer')])
})
const firstAbort = new AbortController()
const first = await startClaudeCodeRun(
request([{ type: 'text', text: 'wait' }], firstAbort.signal),
spec,
)
const second = await startClaudeCodeRun(
request([{ type: 'text', text: 'finish' }]),
spec,
)
expect(controllers).toHaveLength(2)
expect(controllers[0]).not.toBe(controllers[1])
firstAbort.abort(new Error('parent cancelled'))
await expect(first.result).resolves.toEqual({
output: [],
stopReason: 'aborted',
})
await expect(second.result).resolves.toEqual({
output: [{ type: 'text', text: 'second answer' }],
stopReason: 'completed',
})
expect(controllers[1]!.signal.aborted).toBe(false)
await Promise.all([first.dispose(), second.dispose()])
})
it('keeps local cancellation authoritative when the SDK iterator ends normally', async () => {
const parentAbort = new AbortController()
const child = fakeChild()
async function* stream(): AsyncGenerator<SDKMessage, void> {
yield success('candidate answer')
parentAbort.abort(new Error('parent cancelled at iterator completion'))
}
queryMock.mockImplementation(({ options }) => {
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
return Object.assign(stream(), { close: vi.fn() }) as unknown as Query
})
const run = await startClaudeCodeRun(
request(undefined, parentAbort.signal),
{
cwd: '/workspace',
env: {},
disposeGraceMs: 5,
spawn: () => child.handle,
},
)
await expect(run.result).resolves.toEqual({
output: [],
stopReason: 'aborted',
})
await run.dispose()
})
it('rejects pre-abort and every incomplete startup transaction', async () => {
const preAborted = new AbortController()
preAborted.abort()
const unused = fakeRun()
await expect(startClaudeCodeRun(
request(undefined, preAborted.signal),
unused.spec,
)).rejects.toThrow('aborted before SDK startup')
expect(unused.options).toEqual([])
const noChildClose = vi.fn()
queryMock.mockImplementationOnce(
() => queryFrom([], undefined, noChildClose),
)
await expect(startClaudeCodeRun(request(), {
...unused.spec,
})).rejects.toThrow('did not publish a controllable')
expect(noChildClose).toHaveBeenCalledOnce()
const closeFailure = vi.fn(() => { throw new Error('close boom') })
queryMock.mockImplementationOnce(
() => queryFrom([], undefined, closeFailure),
)
const noChild = startClaudeCodeRun(request(), {
...unused.spec,
})
await expect(noChild).rejects.toBeInstanceOf(AggregateError)
const startupAbort = new AbortController()
const abortedChild = fakeChild()
const abortedClose = vi.fn()
queryMock.mockImplementationOnce(({ options }) => {
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
startupAbort.abort(new Error('startup cancelled'))
return queryFrom([], undefined, abortedClose)
})
const abortedDuringStartup = startClaudeCodeRun(
request(undefined, startupAbort.signal),
{
...unused.spec,
spawn: () => abortedChild.handle,
},
)
await expect(abortedDuringStartup)
.rejects.toThrow('aborted before SDK startup')
expect(abortedClose).toHaveBeenCalledOnce()
expect(abortedChild.terminate).toHaveBeenCalledOnce()
queryMock.mockImplementationOnce(() => {
throw new Error('query failed before resource creation')
})
await expect(startClaudeCodeRun(request(), {
...unused.spec,
})).rejects.toThrow('query failed before resource creation')
const spawned = fakeChild()
const spawnSpecs: SubprocessSpawnSpec[] = []
let factoryController: AbortController | undefined
queryMock.mockImplementationOnce(({ options }) => {
factoryController = options.abortController
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
throw new Error('query construction failed')
})
const factoryFailure = startClaudeCodeRun(request(), {
...unused.spec,
spawn: (spawnSpec) => {
spawnSpecs.push(spawnSpec)
return spawned.handle
},
})
await expect(factoryFailure).rejects.toThrow('query construction failed')
expect(spawnSpecs).toHaveLength(1)
expect(factoryController?.signal.aborted).toBe(true)
expect(spawned.terminate).toHaveBeenCalledOnce()
const failedSpawn = fakeChild({
pid: -1,
doneError: new Error('spawn failed'),
})
const failed = fakeRun([], undefined, failedSpawn)
await expect(startClaudeCodeRun(request(), failed.spec))
.rejects.toBeInstanceOf(AggregateError)
expect(failed.close).toHaveBeenCalledOnce()
})
})
describe('query and process disposal', () => {
it('closes the query, terminates the tree, and waits for direct-child outcome', async () => {
const child = fakeChild()
const close = vi.fn()
await disposeClaudeCodeChild({ close }, child.handle)
expect(close).toHaveBeenCalledOnce()
expect(child.terminate).toHaveBeenCalledOnce()
expect(child.waitForExit).toHaveBeenCalledOnce()
expect(child.waitForExit).toHaveBeenCalledWith()
await expect(child.handle.done).resolves.toEqual({
exitCode: 0,
signal: null,
})
})
it('does not finish disposal before the managed tree exits', async () => {
const child = fakeChild({ exitOnTerminate: false })
let disposed = false
const disposal = disposeClaudeCodeChild(
{ close: vi.fn() },
child.handle,
).then(() => {
disposed = true
})
await nextTask()
expect(disposed).toBe(false)
child.settle()
await disposal
expect(disposed).toBe(true)
})
it('reports wait, close, and direct-child failures without skipping cleanup', async () => {
const waitFailure = fakeChild({
waitForExitError: new Error('wait boom'),
})
const closeFailure = vi.fn(() => { throw new Error('close boom') })
await expect(disposeClaudeCodeChild(
{ close: closeFailure },
waitFailure.handle,
)).rejects.toBeInstanceOf(AggregateError)
expect(waitFailure.terminate).toHaveBeenCalledOnce()
const doneFailure = fakeChild({
pid: -1,
doneError: new Error('spawn boom'),
})
await expect(disposeClaudeCodeChild(
{ close: vi.fn() },
doneFailure.handle,
)).rejects.toThrow('spawn boom')
const both = fakeChild({
pid: -1,
doneError: new Error('spawn boom'),
})
await expect(disposeClaudeCodeChild(
{ close: () => { throw new Error('close boom') } },
both.handle,
)).rejects.toBeInstanceOf(AggregateError)
})
})
@@ -0,0 +1,37 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/types/.tsbuildinfo"
},
"include": [
"src/**/*.ts"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../subagent"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../util/timeout"
},
{
"path": "../../support/invariants"
}
]
}
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent-codex/README.md
README.md: c25ee90edf8972da66448fe84cb659b0aec79e6f
README.zh.md: 10c8fcc47a9ab04bca983857bd44ede265c23435
@@ -0,0 +1,90 @@
# @deepseek-ai/dsh-subagent-codex
English | [中文](README.zh.md)
This package registers the fixed `codex` subagent provider. Each accepted run starts the official `codex app-server --stdio` command in the delegating Session's workspace, creates one ephemeral Codex thread, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract.
## Start and ownership
`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize``initialized``thread/start { cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`.
The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error.
For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run.
Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and this version produces no `refusal`. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate.
## Capabilities and context
The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Codex receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. The ephemeral Codex thread id and turn id stay private to this run and are never persisted in the parent Session.
## Configuration
| Key | Default | Meaning |
|---|---|---|
| `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. |
| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. |
Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden.
Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_codex` by default.
```yaml
- id: subagent-codex
name: '@deepseek-ai/dsh-subagent-codex'
config:
env:
OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY
- id: tool-subagent-codex
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex
toolName: subagent_codex
enableRunInBackground: false
maxDepth: provider-managed
```
## Product compatibility and evidence
The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`: the keyless real-product spec drives the official binary against a loopback Responses service with a non-empty fake key and proves the task, authentication, exact answer, cancellation, approvals, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration with no `codex` command available, verifies the fixed provider and foreground-only tool schema, and records zero child starts. A credentialed e2e starts the production provider and real Codex, then obtains a unique answer from the fixed official DeepSeek service through a loopback-only test bridge from Responses to Chat Completions; that bridge is not production functionality or native Codex support for DeepSeek's Chat Completions API. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`.
## Model Experience
### Child request
#### What the model sees
The Codex child receives the standalone text blocks as one turn in a fresh ephemeral thread. Its workspace is the parent Session cwd, and its model, system instructions, tools, sandbox, and authentication come from the native Codex installation and configuration.
#### Token effect
The child pays for an independent Codex context and turn. Child tokens do not enter the parent's context.
#### KV Cache effect
Independent of the parent request cache. Reuse depends only on Codex's own provider, model, instructions, tools, and ephemeral-thread request.
### Parent tool result, indirectly
#### What the model sees
Through `dsh-tool-subagent`, the parent sees only the selected final Codex answer or the consumer's exact error for a non-completed result. Codex commentary, reasoning, tool activity, stderr, workspace diffs, and product ids are not copied into the parent Session.
#### Token effect
Parent input grows only by the final answer or error retained in the tool result. This provider adds no parent tool schema by itself.
#### KV Cache effect
Append-only: the new tool result follows the reusable parent request prefix.
## Known Limitations and Deferred Work
- **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence.
- **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate.
- **Compatibility is pinned by development evidence** — upgrading from the verified 0.146.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests.
- **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; deployments cannot configure an allow policy through this package.
- **Final text only** — reasoning, commentary, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local.
- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider.
- **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored.
@@ -0,0 +1,90 @@
# @deepseek-ai/dsh-subagent-codex
[English](README.md) | 中文
本包(package)注册固定的 `codex` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中启动官方 `codex app-server --stdio` 命令,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果契约仅返回最终答案。
## 启动与所有权
`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize``initialized``thread/start { cwd, ephemeral: true }`,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。
已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"``agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。
对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。
本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次的 `codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且本版本不会产生 `refusal``dispose()` 具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。
## 能力与上下文
本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Codex 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出契约。临时 Codex 线程 ID 与轮次 ID 仅在此次运行内部可见,绝不会持久化到父会话。
## 配置
| 配置键 | 默认值 | 含义 |
|---|---|---|
| `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 |
| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 |
生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH``HOME` 等普通环境变量值仍然可用。
请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_codex`
```yaml
- id: subagent-codex
name: '@deepseek-ai/dsh-subagent-codex'
config:
env:
OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY
- id: tool-subagent-codex
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex
toolName: subagent_codex
enableRunInBackground: false
maxDepth: provider-managed
```
## 产品兼容性与证据
生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:无密钥真实产品测试使用非空的伪密钥,驱动官方二进制程序连接回环 Responses 服务,并证明任务、身份验证、精确回答、取消、审批与进程树退出。独立的 Loader 装配 e2e 会在没有可用 `codex` 命令时启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的工具 schema,并记录零次子级启动。带密钥 e2e 会启动生产提供方和真实 Codex,再通过一个仅限回环、将 Responses 转为 Chat Completions 的测试桥接层,从固定的 DeepSeek 官方服务获得唯一答案;该桥接层既不属于生产功能,也不代表 Codex 原生支持 DeepSeek 的 Chat Completions API。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`
## 模型体验
### 子任务请求
#### 模型看到的内容
Codex 子任务会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd;其模型、系统指令、工具、沙箱和身份验证来自原生 Codex 安装与配置。
#### 对 token 的影响
子任务需为独立的 Codex 上下文和轮次承担 token 开销。子任务 token 不会进入父级上下文。
#### 对 KV Cache 的影响
这与父请求缓存相互独立。能否复用只取决于 Codex 自身的提供方、模型、指令、工具和临时线程请求。
### 父级工具结果(间接)
#### 模型看到的内容
通过 `dsh-tool-subagent`,父级模型只会看到选定的 Codex 最终答案,或者在结果未完成时看到消费方给出的原样错误。Codex 的过程说明、推理(reasoning)、工具活动、stderr、工作区差异和产品标识符均不会复制到父会话。
#### 对 token 的影响
父级输入只会增加工具结果中保留的最终答案或错误内容。本提供方自身不添加父级工具 schema。
#### 对 KV Cache 的影响
仅追加:新的工具结果接在可复用的父请求前缀之后。
## 已知限制与后续工作
- **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。
- **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。
- **兼容性由开发证据锁定**:若要从已验证的 0.146.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。
- **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;部署方无法通过本包配置允许策略。
- **仅返回最终文本**:推理、过程说明、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。
- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。
- **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。
@@ -0,0 +1,55 @@
{
"name": "@deepseek-ai/dsh-subagent-codex",
"description": "One-shot Codex subagent provider over the official app-server protocol",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-sdk-protocol": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-sdk-protocol": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@openai/codex": "0.146.0",
"cordis": "^4.0.0-rc.7"
}
}
@@ -0,0 +1,101 @@
/**
* Fixed Codex one-shot subagent provider. Every accepted run starts a fresh
* official `codex app-server --stdio` process in the delegating Session's
* workspace and publishes only after an ephemeral thread exists.
*
* @module @deepseek-ai/dsh-subagent-codex
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import {
assertPositiveFinite,
NO_START_CAPABILITIES,
resolveChildCwd,
type ResolvedSubagentStartRequest,
type SubagentCapabilities,
type SubagentProvider,
} from '@deepseek-ai/dsh-subagent'
import {
DEFAULT_DISPOSE_GRACE_MS,
startCodexRun,
type CodexRunSpec,
} from './run.ts'
export const name = 'subagent-codex'
export const inject = ['subagents', 'subprocess']
/** Deployment-owned environment and process-release bound. */
export interface Config {
/**
* Explicit environment entries layered over the subprocess seam's
* credential-scrubbed parent environment.
*/
env?: Record<string, string>
/** Grace in milliseconds for app-server process-tree termination. */
disposeGraceMs?: number
}
export const Config: z<Config> = z.object({
env: z.dict(z.string()).default({}),
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
})
type ResolvedConfig = Required<Config>
class CodexProvider implements SubagentProvider {
readonly name = 'codex'
readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES
readonly inheritsParentContext = false
constructor(
private readonly ctx: Context,
private readonly config: ResolvedConfig,
) {}
start(request: ResolvedSubagentStartRequest) {
const parentCwd = request.parent.session.header.cwd
if (parentCwd === undefined) {
throw new Error(
'subagent-codex: no working directory for the child — delegate from a parent session that has one',
)
}
const spec: CodexRunSpec = {
cwd: resolveChildCwd(
'subagent-codex',
undefined,
parentCwd,
),
env: this.config.env,
disposeGraceMs: this.config.disposeGraceMs,
spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec),
onError: (error, stopReason) => {
this.ctx.logger.warn(
`subagent-codex: child run failed (${stopReason}): ${error.message}`,
)
},
}
return startCodexRun(request, spec)
}
}
/**
* Register the fixed `codex` provider.
* @param ctx - context carrying shared subagent and subprocess services.
* @param config - explicit child environment and disposal grace.
*/
export function apply(ctx: Context, config: Config): void {
const resolved = config as ResolvedConfig
assertPositiveFinite(
'subagent-codex',
'disposeGraceMs',
resolved.disposeGraceMs,
)
if (resolved.disposeGraceMs > MAX_TIMER_DELAY_MS) {
throw new Error(
`subagent-codex: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
ctx.subagents.registerProvider(new CodexProvider(ctx, resolved))
}
@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-codex`.
* @module @deepseek-ai/dsh-subagent-codex/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-codex'
/** Cordis companion plugin name. */
export const name = 'subagent-codex-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: lifecycle pairing belongs to the shared subagent
* service and process-tree ownership belongs to the subprocess service.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - plugin context carrying the invariant registry.
* @returns the installed registration's disposer.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
+200
View File
@@ -0,0 +1,200 @@
/**
* One-shot Codex child lifecycle: spawn the real app-server through the
* subprocess seam, publish only after initialization and ephemeral thread
* creation, flatten post-publication failures, and dispose to whole-tree
* quiescence.
*
* @module @deepseek-ai/dsh-subagent-codex/run
*/
import { randomUUID } from 'node:crypto'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
settleRunResult,
subprocessRunHandle,
type SubagentResult,
type SubagentRun,
type SubagentStartRequest,
type SubagentStopReason,
} from '@deepseek-ai/dsh-subagent'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { CodexAppServerWire } from './wire.ts'
/** Default POSIX grace between subprocess termination tiers. */
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
/**
* Resolve the fixed app-server command for a platform.
*
* Windows npm and pnpm installs expose `codex.cmd`, which requires `cmd.exe`;
* the argv is constant so no task or configuration text enters the
* shell boundary.
* @param platform - host platform used to select the executable boundary.
* @returns argv for the fixed Codex app-server command.
*/
export function codexAppServerArgv(
platform: NodeJS.Platform = process.platform,
): string[] {
return platform === 'win32'
? ['cmd.exe', '/d', '/s', '/c', 'codex', 'app-server', '--stdio']
: ['codex', 'app-server', '--stdio']
}
/** Fully resolved inputs for one Codex app-server run. */
export interface CodexRunSpec {
/** Parent Session workspace, also supplied to `thread/start`. */
readonly cwd: string
/** Explicit deployment/test environment layered after the shared scrub. */
readonly env: Record<string, string>
/** Subprocess termination grace passed to the shared process-tree owner. */
readonly disposeGraceMs: number
/** Shared subprocess service spawn operation. */
readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
/** Diagnostic sink for a post-publication error flattened into a result. */
readonly onError?: (error: Error, stopReason: SubagentStopReason) => void
}
function thrown(value: unknown): Error {
/* v8 ignore next -- typed subprocess/wire failures reject with Error. */
return value instanceof Error ? value : new Error(String(value))
}
/**
* Validate and preserve the one-shot task before crossing the process seam.
* @param prompt - task content accepted from the shared subagent service.
* @returns the exact non-empty text block sequence.
*/
export function textTask(prompt: readonly ContentBlock[]): string[] {
if (prompt.length === 0) {
throw new Error('subagent-codex: the one-shot task must contain only text blocks')
}
const texts: string[] = []
for (const block of prompt) {
if (block.type !== 'text') {
throw new Error('subagent-codex: the one-shot task must contain only text blocks')
}
texts.push(block.text)
}
if (texts.every(text => text.trim().length === 0)) {
throw new Error('subagent-codex: the one-shot task must not be empty')
}
return texts
}
/**
* Close the private wire, terminate the managed process tree, and wait for the
* subprocess owner to prove it is gone.
* @param wire - private app-server protocol connection.
* @param child - shared-service handle that owns the process tree.
*/
export async function disposeCodexChild(
wire: CodexAppServerWire,
child: SubprocessHandle,
): Promise<void> {
wire.close()
if (child.pid <= 0) {
await child.done.catch(() => {})
return
}
try {
child.stdin?.end()
} catch {
// A concurrently closed stdin does not change tree ownership below.
}
child.terminate()
await child.waitForExit()
await child.done
}
/**
* Start the real `codex app-server --stdio` child and publish its one-shot run.
* @param request - resolved shared subagent request.
* @param spec - workspace, environment, process seam, and diagnostic policy.
* @returns the published run after initialization and ephemeral thread creation.
*/
export async function startCodexRun(
request: SubagentStartRequest,
spec: CodexRunSpec,
): Promise<SubagentRun> {
const texts = textTask(request.prompt)
if (request.signal.aborted) {
throw new Error('subagent-codex: request was aborted before app-server startup')
}
const child = spec.spawn({
argv: codexAppServerArgv(),
cwd: spec.cwd,
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
graceMs: spec.disposeGraceMs,
env: spec.env,
})
const wire = new CodexAppServerWire(
child.stdout as NonNullable<SubprocessHandle['stdout']>,
child.stdin as NonNullable<SubprocessHandle['stdin']>,
)
const disposeProcess = (): Promise<void> => disposeCodexChild(wire, child)
const processFailure: Promise<never> = child.done.then(
outcome => Promise.reject(new Error(
'subagent-codex: app-server exited before the run settled '
+ `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`,
)),
(error: unknown) => Promise.reject(thrown(error)),
)
// A normal post-result dispose also closes the process. Keep that expected
// late rejection observed after the result race has already settled.
processFailure.catch(() => {})
const runAbort = new AbortController()
const requestCancel = (): void => {
if (runAbort.signal.aborted) return
runAbort.abort(new Error('subagent-codex: run cancelled locally'))
wire.interrupt()
}
const onAbort = (): void => { requestCancel() }
request.signal.addEventListener('abort', onAbort, { once: true })
try {
wire.start()
await Promise.race([wire.initialize(request.signal), processFailure])
await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure])
} catch (error: unknown) {
request.signal.removeEventListener('abort', onAbort)
try {
await disposeProcess()
} catch (disposeError: unknown) {
throw new AggregateError(
[thrown(error), thrown(disposeError)],
'subagent-codex: startup failed and app-server cleanup also failed',
)
}
if (runAbort.signal.aborted) {
throw new Error('subagent-codex: request was aborted before run publication')
}
throw thrown(error)
}
const collectOutput = (): ContentBlock[] => wire.collectOutput()
const result: Promise<SubagentResult> = settleRunResult({
attempt: () => Promise.race([
wire.runTurn(texts, runAbort.signal),
processFailure,
]),
collectOutput,
cancelled: () => runAbort.signal.aborted,
onError: spec.onError,
signal: request.signal,
onAbort,
})
return subprocessRunHandle({
id: SessionId(randomUUID()),
result,
signal: request.signal,
onAbort,
requestCancel,
teardown: disposeProcess,
})
}
@@ -0,0 +1,374 @@
/**
* Minimal Codex app-server 0.146.0 protocol adapter. The shared JSON-RPC
* transport owns framing and request correlation; this module owns only the
* product methods, current thread/turn association, unattended approval
* responses, and terminal-answer selection.
*
* @module @deepseek-ai/dsh-subagent-codex/wire
*/
import type { Readable, Writable } from 'node:stream'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SubagentResult } from '@deepseek-ai/dsh-subagent'
import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol'
type JsonObject = Record<string, unknown>
function object(value: unknown, label: string): JsonObject {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`subagent-codex: app-server returned invalid ${label}`)
}
return value as JsonObject
}
function string(value: unknown, label: string): string {
if (typeof value !== 'string' || value.length === 0) {
throw new Error(`subagent-codex: app-server returned invalid ${label}`)
}
return value
}
function unattendedDecision(params: JsonObject): 'cancel' | 'decline' {
const available = params.availableDecisions
if (available === undefined || available === null) return 'decline'
if (Array.isArray(available)) {
if (available.includes('cancel')) return 'cancel'
if (available.includes('decline')) return 'decline'
}
throw new Error('subagent-codex: app-server offered no unattended approval decision')
}
function isContextWindowExceeded(turn: JsonObject): boolean {
if (turn.status !== 'failed') return false
const error = turn.error
return error !== null
&& typeof error === 'object'
&& !Array.isArray(error)
&& (error as JsonObject).codexErrorInfo === 'contextWindowExceeded'
}
function thrown(value: unknown): Error {
/* v8 ignore next -- typed protocol and stream failures reject with Error. */
return value instanceof Error ? value : new Error(String(value))
}
function abortError(signal: AbortSignal): Error {
return signal.reason instanceof Error
? signal.reason
: new Error(`subagent-codex: app-server request aborted: ${String(signal.reason)}`)
}
async function raceAbort<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) {
void pending.catch(() => {})
throw abortError(signal)
}
let rejectAbort!: (error: Error) => void
const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
const onAbort = (): void => { rejectAbort(abortError(signal)) }
signal.addEventListener('abort', onAbort, { once: true })
try {
return await Promise.race([pending, aborted])
} finally {
signal.removeEventListener('abort', onAbort)
}
}
/**
* One app-server connection and its single ephemeral thread/turn.
*
* The class deliberately exposes no generic request surface. Supporting
* another product method must first become part of the provider contract.
*/
export class CodexAppServerWire {
private readonly transport: JsonRpcLineTransport
private readonly fatal = Promise.withResolvers<never>()
private threadId: string | undefined
private turnId: string | undefined
private pendingTurnId: string | undefined
private turnCompleted: PromiseWithResolvers<JsonObject> | undefined
private readonly earlyTurnNotifications: Array<{
readonly method: string
readonly params: JsonObject
}> = []
private lastFinalAnswer: string | undefined
private lastUnphasedAnswer: string | undefined
private closed = false
constructor(
private readonly input: Readable,
output: Writable,
) {
this.transport = new JsonRpcLineTransport(input, output)
// Fatal protocol state can arrive after the current guarded operation has
// already settled. Keep the shared rejection observed without inserting
// another promise-adoption hop into active races.
void this.fatal.promise.catch(() => {})
this.transport.onRequest((method, params) => this.handleServerRequest(method, params))
this.transport.onNotification((method, params) => {
try {
this.handleNotification(method, params)
} catch (error: unknown) {
this.fail(thrown(error))
}
})
this.input.on('error', this.onInputError)
this.input.on('end', this.onInputEnd)
// Pipe errors can race protocol closure and process teardown. Retain both
// error listeners for the lifetime of their per-run streams so no late
// EPIPE or read failure becomes an unhandled EventEmitter error.
output.on('error', this.onOutputError)
}
/** Start reading app-server frames. */
start(): void {
this.transport.start()
}
/**
* Perform the required app-server initialize/initialized handshake.
* @param signal - unpublished-start cancellation.
*/
async initialize(signal: AbortSignal): Promise<void> {
object(await this.guarded(this.transport.request('initialize', {
clientInfo: {
name: 'deepseek-harness',
title: 'DeepSeek Harness',
version: '0.0.1',
},
capabilities: {
experimentalApi: false,
requestAttestation: false,
},
}, signal), signal), 'initialize response')
this.transport.notify('initialized')
await this.guarded(this.transport.flush(), signal)
}
/**
* Create the run's private ephemeral thread and retain its identity.
* @param cwd - parent Session workspace.
* @param signal - unpublished-start cancellation.
*/
async startThread(cwd: string, signal: AbortSignal): Promise<void> {
const response = object(await this.guarded(this.transport.request('thread/start', {
cwd,
ephemeral: true,
}, signal), signal), 'thread/start response')
const thread = object(response.thread, 'thread/start thread')
const id = string(thread.id, 'thread/start thread id')
if (thread.ephemeral !== true) {
throw new Error('subagent-codex: app-server did not create an ephemeral thread')
}
this.threadId = id
}
/**
* Submit the one text-only task and wait for this thread/turn's authoritative
* terminal notification.
* @param texts - already validated task text blocks.
* @param signal - local cancellation for the published run.
* @returns the shared subagent result.
*/
async runTurn(
texts: readonly string[],
signal: AbortSignal,
): Promise<SubagentResult> {
const completion = Promise.withResolvers<JsonObject>()
this.turnCompleted = completion
const threadId = this.threadId as string
const response = object(await this.guarded(this.transport.request('turn/start', {
threadId,
input: texts.map(text => ({ type: 'text', text, text_elements: [] })),
}, signal), signal), 'turn/start response')
const turn = object(response.turn, 'turn/start turn')
this.commitTurnId(string(turn.id, 'turn/start turn id'))
const completed = await this.guarded(completion.promise, signal)
const terminal = object(completed.turn, 'turn/completed turn')
const status = terminal.status
if (isContextWindowExceeded(terminal)) {
return { output: this.collectOutput(), stopReason: 'max-tokens' }
}
if (status !== 'completed') {
const detail = status === 'failed'
? `: ${JSON.stringify(terminal.error)}`
: ''
throw new Error(`subagent-codex: Codex turn ended with status ${String(status)}${detail}`)
}
const output = this.collectOutput()
if (output.length === 0) {
throw new Error('subagent-codex: Codex completed without a final answer')
}
return { output, stopReason: 'completed' }
}
/**
* Best-effort remote cancellation. Local settlement and process teardown
* remain authoritative when the child no longer accepts protocol requests.
*/
interrupt(): void {
if (this.threadId === undefined || this.turnId === undefined || this.closed) return
void this.transport.request('turn/interrupt', {
threadId: this.threadId,
turnId: this.turnId,
}).catch(() => {})
}
/**
* The best non-commentary answer observed so far, preserving exact bytes.
* @returns the selected final or nullable-phase text block, if any.
*/
collectOutput(): ContentBlock[] {
const selected = this.lastFinalAnswer ?? this.lastUnphasedAnswer
return selected !== undefined && selected.trim().length > 0
? [{ type: 'text', text: selected }]
: []
}
/** Detach JSON-RPC listeners and reject outstanding requests. Idempotent. */
close(): void {
if (this.closed) return
this.closed = true
this.input.off('end', this.onInputEnd)
this.transport.close()
}
private async guarded<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
const withFatal = Promise.race([this.fatal.promise, pending])
return raceAbort(withFatal, signal)
}
private fail(error: Error): void {
this.fatal.reject(error)
}
private readonly onInputError = (error: Error): void => {
this.fail(error)
}
private readonly onOutputError = (error: Error): void => {
this.fail(error)
}
private readonly onInputEnd = (): void => {
this.fail(new Error('subagent-codex: app-server protocol stream closed'))
}
private observePendingTurnId(id: string): void {
if (this.turnCompleted === undefined) {
throw new Error('subagent-codex: app-server referenced a turn before turn/start')
}
if (this.pendingTurnId !== undefined && this.pendingTurnId !== id) {
throw new Error('subagent-codex: app-server referenced conflicting turns')
}
this.pendingTurnId = id
}
private commitTurnId(id: string): void {
if (this.pendingTurnId !== undefined && this.pendingTurnId !== id) {
throw new Error('subagent-codex: turn/start response did not match the active turn')
}
this.turnId = id
const notifications = this.earlyTurnNotifications.splice(0)
for (const notification of notifications) {
this.handleNotification(notification.method, notification.params)
}
}
private validateRunIds(params: JsonObject, nullableTurn = false): void {
if (params.threadId !== this.threadId) {
throw new Error('subagent-codex: app-server request referenced another thread')
}
if (nullableTurn && params.turnId === null) return
const id = string(params.turnId, 'server request turn id')
if (this.turnId === undefined) {
this.observePendingTurnId(id)
return
}
if (id !== this.turnId) {
throw new Error('subagent-codex: app-server request referenced another turn')
}
}
private handleServerRequest(method: string, params: JsonObject): Promise<unknown> {
try {
switch (method) {
case 'item/commandExecution/requestApproval':
case 'item/fileChange/requestApproval':
this.validateRunIds(params)
return Promise.resolve({ decision: unattendedDecision(params) })
case 'item/permissions/requestApproval':
this.validateRunIds(params)
return Promise.resolve({ permissions: {}, scope: 'turn' })
case 'item/tool/requestUserInput':
this.validateRunIds(params)
return Promise.resolve({ answers: {} })
case 'mcpServer/elicitation/request':
this.validateRunIds(params, true)
return Promise.resolve({ action: 'decline', content: null, _meta: null })
default:
throw new Error(`subagent-codex: unsupported app-server request ${JSON.stringify(method)}`)
}
} catch (error: unknown) {
const normalized = thrown(error)
this.fail(normalized)
return Promise.reject(normalized)
}
}
private handleNotification(method: string, params: JsonObject): void {
if (method === 'turn/started') {
const threadId = string(params.threadId, 'turn/started thread id')
if (threadId !== this.threadId) return
const turn = object(params.turn, 'turn/started turn')
if (this.turnCompleted !== undefined && this.turnId === undefined) {
this.observePendingTurnId(string(turn.id, 'turn/started turn id'))
}
return
}
if (method === 'item/completed') {
const threadId = string(params.threadId, 'item/completed thread id')
if (threadId !== this.threadId) return
const id = string(params.turnId, 'item/completed turn id')
if (this.turnId === undefined) {
if (this.turnCompleted !== undefined) {
this.observePendingTurnId(id)
this.earlyTurnNotifications.push({ method, params })
}
return
}
if (id !== this.turnId) return
const item = object(params.item, 'item/completed item')
if (item.type !== 'agentMessage') return
const text = typeof item.text === 'string'
? item.text
: (() => { throw new Error('subagent-codex: app-server returned an invalid agent message') })()
if (item.phase === 'final_answer') {
this.lastFinalAnswer = text
} else if (item.phase === null) {
this.lastUnphasedAnswer = text
} else if (item.phase !== 'commentary') {
throw new Error(`subagent-codex: app-server returned an unknown agent message phase ${JSON.stringify(item.phase)}`)
}
return
}
if (method !== 'turn/completed') return
const threadId = string(params.threadId, 'turn/completed thread id')
if (threadId !== this.threadId) return
const turn = object(params.turn, 'turn/completed turn')
const id = string(turn.id, 'turn/completed turn id')
const turnCompleted = this.turnCompleted
if (turnCompleted === undefined) return
if (this.turnId === undefined) {
this.observePendingTurnId(id)
this.earlyTurnNotifications.push({ method, params })
return
}
if (id !== this.turnId) return
if (!['completed', 'interrupted', 'failed'].includes(String(turn.status))) {
throw new Error(`subagent-codex: app-server returned invalid terminal turn status ${String(turn.status)}`)
}
turnCompleted.resolve(params)
}
}
@@ -0,0 +1,190 @@
import { createServer } from 'node:http'
import type {
IncomingMessage,
Server,
ServerResponse,
} from 'node:http'
import { completeResponsesEvents } from './responses-fixture.ts'
const OFFICIAL_DEEPSEEK_BASE_URL = 'https://api.deepseek.com'
const MAX_REQUEST_BYTES = 1_048_576
/** One running test-only Responses-to-DeepSeek bridge. */
export interface DeepSeekResponsesBridge {
readonly baseUrl: string
readonly completedRequests: number
close(): Promise<void>
}
function readRequest(request: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let body = ''
request.setEncoding('utf8')
request.on('data', (chunk: string) => {
body += chunk
if (Buffer.byteLength(body) > MAX_REQUEST_BYTES) {
request.destroy(new Error('DeepSeek bridge request exceeded its byte limit'))
}
})
request.on('end', () => { resolve(body) })
request.on('error', reject)
})
}
function responseInputTexts(body: Record<string, unknown>): string[] {
if (!Array.isArray(body.input)) return []
return body.input.flatMap((item): string[] => {
if (item === null || typeof item !== 'object') return []
const content = (item as Record<string, unknown>).content
if (!Array.isArray(content)) return []
return content.flatMap((part): string[] => (
part !== null
&& typeof part === 'object'
&& typeof (part as Record<string, unknown>).text === 'string'
? [(part as Record<string, unknown>).text as string]
: []
))
})
}
function taskText(body: Record<string, unknown>): string {
const input = responseInputTexts(body).join('\n')
if (input.trim().length > 0) return input
return typeof body.instructions === 'string' ? body.instructions : ''
}
function deepSeekBaseUrl(): string {
const configured = (process.env.DEEPSEEK_BASE_URL ?? OFFICIAL_DEEPSEEK_BASE_URL)
.replace(/\/+$/, '')
if (configured !== OFFICIAL_DEEPSEEK_BASE_URL) {
throw new Error('Codex DeepSeek e2e requires the official DeepSeek base URL')
}
return configured
}
async function completeWithDeepSeek(
authorization: string,
task: string,
): Promise<string> {
const response = await fetch(`${deepSeekBaseUrl()}/chat/completions`, {
method: 'POST',
headers: {
authorization,
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [
{
role: 'system',
content: 'Follow the user instruction and return only the requested nonce.',
},
{ role: 'user', content: task },
],
temperature: 0,
max_tokens: 64,
stream: false,
}),
})
if (!response.ok) {
void response.body?.cancel()
throw new Error(`DeepSeek bridge upstream returned HTTP ${response.status}`)
}
const payload = await response.json() as {
choices?: Array<{ message?: { content?: unknown } }>
}
const content = payload.choices?.[0]?.message?.content
if (typeof content !== 'string' || content.trim().length === 0) {
throw new Error('DeepSeek bridge upstream returned no text')
}
return content
}
function closeServer(server: Server): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => {
if (error !== undefined) reject(error)
else resolve()
})
server.closeAllConnections()
})
}
/**
* Start the single-purpose loopback bridge used by the Codex credentialed e2e.
* @param nonce - unique answer the incoming Responses task must request.
* @returns loopback endpoint, completion count, and close operation.
*/
export async function startDeepSeekResponsesBridge(
nonce: string,
): Promise<DeepSeekResponsesBridge> {
let seenRequests = 0
let completedRequests = 0
const openResponses = new Set<ServerResponse>()
const server = createServer((request, response) => {
openResponses.add(response)
response.on('close', () => { openResponses.delete(response) })
void (async () => {
if (request.method !== 'POST' || request.url !== '/v1/responses') {
response.writeHead(404)
response.end()
return
}
if (seenRequests !== 0) {
response.writeHead(409)
response.end()
return
}
seenRequests += 1
const authorization = request.headers.authorization
if (
typeof authorization !== 'string'
|| !authorization.startsWith('Bearer ')
|| authorization.length === 'Bearer '.length
) {
throw new Error('Codex DeepSeek bridge received no bearer credential')
}
const body = JSON.parse(await readRequest(request)) as Record<string, unknown>
const task = taskText(body)
if (!task.includes(nonce)) {
throw new Error('Codex DeepSeek bridge request omitted the expected nonce')
}
const text = await completeWithDeepSeek(authorization, task)
completedRequests += 1
response.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
'x-request-id': 'req_deepseek_e2e',
})
for (const event of completeResponsesEvents(text)) {
response.write(`data: ${JSON.stringify(event)}\n\n`)
}
response.end('data: [DONE]\n\n')
})().catch(() => {
if (!response.headersSent) {
response.writeHead(502, { 'content-type': 'application/json' })
}
response.end(JSON.stringify({ error: { message: 'DeepSeek bridge request failed' } }))
})
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
server.off('error', reject)
resolve()
})
})
const address = server.address()
if (address === null || typeof address === 'string') {
throw new Error('DeepSeek bridge did not acquire a TCP port')
}
return {
baseUrl: `http://127.0.0.1:${address.port}/v1`,
get completedRequests(): number { return completedRequests },
async close(): Promise<void> {
for (const response of openResponses) response.destroy()
await closeServer(server)
},
}
}
@@ -0,0 +1,53 @@
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import {
LOADER_SMOKE_TEST_TIMEOUT_MS,
runLoaderSmoke,
} from '@deepseek-ai/dsh-loader-smoke'
const fixtureDir = fileURLToPath(new URL(
'../../../../examples/acp-agent/tests/fixtures/subagent/subagent-codex/',
import.meta.url,
))
const driver = join(fixtureDir, 'driver.ts')
const configPath = join(fixtureDir, 'cordis.yml')
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
describe('Codex provider public Loader composition', () => {
it('loads the opt-in package and foreground tool without starting Codex', async () => {
const { stdout, stderr } = await runLoaderSmoke({
label: 'subagent-codex Loader composition',
tempDirPrefix: 'dsh-subagent-codex-loader-',
binScript: driver,
libBinScript: driver,
configPath,
tsconfigPath: repoTsconfig,
env: {
// Loading the optional package must not probe or start a Codex binary.
PATH: '',
},
})
expect(stderr).toBe('')
expect(JSON.parse(stdout)).toEqual({
providers: ['codex'],
provider: {
name: 'codex',
capabilities: {
outputSchema: false,
depthLimit: false,
toolFilter: false,
persona: false,
},
inheritsParentContext: false,
},
tool: {
name: 'subagent_codex',
parameterNames: ['description', 'prompt'],
required: ['description', 'prompt'],
},
starts: 0,
})
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})
@@ -0,0 +1,141 @@
import { execFile } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as codex from '../src/index.ts'
import {
startDeepSeekResponsesBridge,
type DeepSeekResponsesBridge,
} from './deepseek-responses-bridge.ts'
const execFileAsync = promisify(execFile)
const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
const codexBinDir = join(packageRoot, 'node_modules', '.bin')
const codexPackage = JSON.parse(readFileSync(
join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'),
'utf8',
)) as { version: string }
const roots: string[] = []
const contexts: Context[] = []
const bridges: DeepSeekResponsesBridge[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
await Promise.all(bridges.splice(0).map(bridge => bridge.close()))
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise<void> {
expect(handles.length).toBeGreaterThan(0)
for (const handle of handles) {
await expect(handle.waitForExit()).resolves.toBe(true)
await expect(handle.done).resolves.toHaveProperty('exitCode')
}
}
describe.skipIf(!process.env.DEEPSEEK_API_KEY)(
'Codex provider with real DeepSeek API',
() => {
it('returns one unique nonce through the production provider and real Codex', async () => {
const apiKey = process.env.DEEPSEEK_API_KEY
if (apiKey === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY')
const root = mkdtempSync(join(tmpdir(), 'dsh-codex-deepseek-e2e-'))
roots.push(root)
const workspace = join(root, 'workspace')
const codexHome = join(root, 'codex-home')
mkdirSync(workspace)
mkdirSync(codexHome)
const nonce = `DSH_CODEX_DEEPSEEK_${randomUUID()}`
const bridge = await startDeepSeekResponsesBridge(nonce)
bridges.push(bridge)
writeFileSync(join(codexHome, 'config.toml'), [
'model = "deepseek-v4-flash"',
'model_provider = "deepseek-e2e"',
'approval_policy = "never"',
'sandbox_mode = "read-only"',
'disable_response_storage = true',
'check_for_update_on_startup = false',
'',
'[model_providers.deepseek-e2e]',
'name = "DeepSeek E2E bridge"',
`base_url = "${bridge.baseUrl}"`,
'env_key = "DEEPSEEK_API_KEY"',
'wire_api = "responses"',
'requires_openai_auth = false',
'',
'[analytics]',
'enabled = false',
'',
].join('\n'))
const env = {
DEEPSEEK_API_KEY: apiKey,
CODEX_HOME: codexHome,
HOME: root,
XDG_CONFIG_HOME: join(root, 'xdg-config'),
PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`,
HTTP_PROXY: '',
HTTPS_PROXY: '',
ALL_PROXY: '',
NO_PROXY: '127.0.0.1,localhost',
}
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
const handles: SubprocessHandle[] = []
const spawn = ctx.subprocess.spawn.bind(ctx.subprocess)
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
const handle = spawn(spec)
handles.push(handle)
return handle
})
await ctx.plugin(codex, { env, disposeGraceMs: 2_000 })
const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], {
env: { ...process.env, ...env },
})
expect(codexPackage.version).toBe('0.146.0')
expect(version.stdout.trim()).toBe('codex-cli 0.146.0')
const parent = {
id: 'deepseek-e2e-parent',
session: { header: { cwd: workspace } },
} as unknown as Agent
const run = await ctx.subagents.start('codex', {
prompt: [{
type: 'text',
text: `Reply with exactly ${nonce} and nothing else. Do not use tools.`,
}],
parent,
signal: new AbortController().signal,
})
const result = await run.result
await run.dispose()
expect(result.stopReason).toBe('completed')
const text = result.output
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
.trim()
expect(text).toBe(nonce)
expect(bridge.completedRequests).toBe(1)
await expectQuiescent(handles)
}, 180_000)
},
)
@@ -0,0 +1,225 @@
import { execFile } from 'node:child_process'
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as codex from '../src/index.ts'
import {
startResponsesFixture,
type ResponsesBehavior,
type ResponsesFixture,
} from './responses-fixture.ts'
const execFileAsync = promisify(execFile)
const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
const codexBinDir = join(packageRoot, 'node_modules', '.bin')
const codexPackage = JSON.parse(readFileSync(
join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'),
'utf8',
)) as { version: string }
const roots: string[] = []
const fixtures: ResponsesFixture[] = []
const contexts: Context[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
await Promise.all(fixtures.splice(0).map(fixture => fixture.close()))
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
interface RealHarness {
readonly ctx: Context
readonly handles: SubprocessHandle[]
readonly parent: Agent
readonly env: Record<string, string>
readonly workspace: string
}
async function realHarness(script: readonly ResponsesBehavior[]): Promise<{
readonly harness: RealHarness
readonly fixture: ResponsesFixture
}> {
const root = mkdtempSync(join(tmpdir(), 'dsh-codex-real-'))
roots.push(root)
const workspace = join(root, 'workspace')
const codexHome = join(root, 'codex-home')
const fixture = await startResponsesFixture(script)
fixtures.push(fixture)
mkdirSync(workspace)
mkdirSync(codexHome)
writeFileSync(join(codexHome, 'config.toml'), [
'model = "fixture-model"',
'model_provider = "fixture"',
'approval_policy = "on-request"',
'sandbox_mode = "read-only"',
'disable_response_storage = true',
'check_for_update_on_startup = false',
'',
'[model_providers.fixture]',
'name = "Fixture Responses"',
`base_url = "${fixture.baseUrl}"`,
'env_key = "OPENAI_API_KEY"',
'wire_api = "responses"',
'requires_openai_auth = false',
'',
'[analytics]',
'enabled = false',
'',
].join('\n'))
const env = {
OPENAI_API_KEY: 'dsh-fake-openai-key',
CODEX_HOME: codexHome,
HOME: root,
XDG_CONFIG_HOME: join(root, 'xdg'),
PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`,
HTTP_PROXY: '',
HTTPS_PROXY: '',
ALL_PROXY: '',
NO_PROXY: '127.0.0.1,localhost',
}
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
const handles: SubprocessHandle[] = []
const spawn = ctx.subprocess.spawn.bind(ctx.subprocess)
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
const handle = spawn(spec)
handles.push(handle)
return handle
})
await ctx.plugin(codex, { env, disposeGraceMs: 2_000 })
const parent = {
id: 'real-parent',
session: { header: { cwd: workspace } },
} as unknown as Agent
return { harness: { ctx, handles, parent, env, workspace }, fixture }
}
async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise<void> {
expect(handles.length).toBeGreaterThan(0)
for (const handle of handles) {
await expect(handle.waitForExit()).resolves.toBe(true)
const outcome = await handle.done
expect(outcome).toHaveProperty('exitCode')
expect(outcome).toHaveProperty('signal')
}
}
function responseInputTexts(body: Record<string, unknown>): string[] {
if (!Array.isArray(body.input)) return []
return body.input.flatMap((item): string[] => {
if (item === null || typeof item !== 'object') return []
const content = (item as Record<string, unknown>).content
if (!Array.isArray(content)) return []
return content.flatMap((part): string[] => (
part !== null
&& typeof part === 'object'
&& typeof (part as Record<string, unknown>).text === 'string'
? [(part as Record<string, unknown>).text as string]
: []
))
})
}
describe('real @openai/codex 0.146.0 product', () => {
it('passes the exact task and fake authentication to local Responses and returns exact text', async () => {
const sentinel = 'REAL_CODEX_SENTINEL_0_146_0'
const task = 'Return the fixture sentinel exactly.'
const { harness, fixture } = await realHarness([
{ kind: 'complete', text: sentinel },
])
expect(codexPackage.version).toBe('0.146.0')
const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], {
env: { ...process.env, ...harness.env },
})
expect(version.stdout.trim()).toBe('codex-cli 0.146.0')
const run = await harness.ctx.subagents.start('codex', {
prompt: [{ type: 'text', text: task }],
parent: harness.parent,
signal: new AbortController().signal,
})
await expect(run.result).resolves.toEqual({
output: [{ type: 'text', text: sentinel }],
stopReason: 'completed',
})
await run.dispose()
expect(fixture.requests).toHaveLength(1)
const recorded = fixture.requests[0]!
expect(recorded.method).toBe('POST')
expect(recorded.path).toBe('/v1/responses')
expect(recorded.headers.authorization).toBe('Bearer dsh-fake-openai-key')
expect(responseInputTexts(recorded.body)).toContain(task)
await expectQuiescent(harness.handles)
}, 60_000)
it('cancels a real app-server command approval without executing the command', async () => {
const { harness, fixture } = await realHarness([
{
kind: 'functionCall',
name: 'exec_command',
arguments: {
cmd: 'touch approval-side-effect',
sandbox_permissions: 'require_escalated',
justification: 'exercise the unattended approval boundary',
},
},
])
const sideEffect = join(harness.workspace, 'approval-side-effect')
const run = await harness.ctx.subagents.start('codex', {
prompt: [{ type: 'text', text: 'Attempt the fixture command.' }],
parent: harness.parent,
signal: new AbortController().signal,
})
await expect(run.result).resolves.toEqual({
output: [],
stopReason: 'error',
})
await run.dispose()
expect(existsSync(sideEffect)).toBe(false)
expect(fixture.requests).toHaveLength(1)
const tools = fixture.requests[0]!.body.tools as Array<Record<string, unknown>>
expect(tools).toEqual(expect.arrayContaining([
expect.objectContaining({ type: 'function', name: 'exec_command' }),
]))
expect(fixture.requests.every(requestEntry =>
requestEntry.headers.authorization === 'Bearer dsh-fake-openai-key',
)).toBe(true)
await expectQuiescent(harness.handles)
}, 60_000)
it('settles cancellation locally and leaves the real app-server tree quiescent', async () => {
const { harness, fixture } = await realHarness([{ kind: 'hold' }])
const controller = new AbortController()
const run = await harness.ctx.subagents.start('codex', {
prompt: [{ type: 'text', text: 'Wait for cancellation.' }],
parent: harness.parent,
signal: controller.signal,
})
await fixture.requestStarted
controller.abort(new Error('real product cancellation'))
await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' })
await run.dispose()
await expectQuiescent(harness.handles)
}, 60_000)
})
@@ -0,0 +1,288 @@
import { createServer } from 'node:http'
import type {
IncomingHttpHeaders,
IncomingMessage,
Server,
ServerResponse,
} from 'node:http'
/** One request observed by the package-private Responses fixture. */
interface RecordedResponsesRequest {
readonly method: string | undefined
readonly path: string | undefined
readonly headers: IncomingHttpHeaders
readonly body: Record<string, unknown>
}
/** Behavior consumed by one Responses request. */
export type ResponsesBehavior =
| { readonly kind: 'complete'; readonly text: string }
| {
readonly kind: 'functionCall'
readonly name: string
readonly arguments: Record<string, unknown>
}
| { readonly kind: 'hold' }
/** Running package-private Responses fixture. */
export interface ResponsesFixture {
readonly baseUrl: string
readonly requests: RecordedResponsesRequest[]
readonly requestStarted: Promise<void>
close(): Promise<void>
}
function responseObject(text: string): Record<string, unknown> {
const message = {
id: 'msg_fixture',
type: 'message',
status: 'completed',
role: 'assistant',
content: [{
type: 'output_text',
annotations: [],
logprobs: [],
text,
}],
}
return {
id: 'resp_fixture',
object: 'response',
created_at: 1,
status: 'completed',
background: false,
error: null,
incomplete_details: null,
instructions: null,
max_output_tokens: null,
max_tool_calls: null,
model: 'fixture-model',
output: [message],
parallel_tool_calls: true,
previous_response_id: null,
prompt_cache_key: null,
prompt_cache_retention: null,
reasoning: { effort: null, summary: null },
safety_identifier: null,
service_tier: 'default',
store: false,
temperature: null,
text: { format: { type: 'text' }, verbosity: 'medium' },
tool_choice: 'auto',
tools: [],
top_logprobs: 0,
top_p: null,
truncation: 'disabled',
usage: {
input_tokens: 10,
input_tokens_details: { cached_tokens: 0 },
output_tokens: 1,
output_tokens_details: { reasoning_tokens: 0 },
total_tokens: 11,
},
user: null,
metadata: {},
}
}
/**
* Build the minimal Responses SSE event sequence consumed by Codex 0.146.0.
* @param text - exact assistant answer.
* @returns ordered response lifecycle events.
*/
export function completeResponsesEvents(text: string): Record<string, unknown>[] {
const completed = responseObject(text)
const message = (completed.output as Record<string, unknown>[])[0]!
const part = (message.content as Record<string, unknown>[])[0]!
return [
{
type: 'response.created',
response: { ...completed, status: 'in_progress', output: [] },
},
{
type: 'response.output_item.added',
output_index: 0,
item: { ...message, status: 'in_progress', content: [] },
},
{
type: 'response.content_part.added',
item_id: message.id,
output_index: 0,
content_index: 0,
part: { ...part, text: '' },
},
{
type: 'response.output_text.delta',
item_id: message.id,
output_index: 0,
content_index: 0,
delta: text,
logprobs: [],
},
{
type: 'response.output_text.done',
item_id: message.id,
output_index: 0,
content_index: 0,
text,
logprobs: [],
},
{
type: 'response.content_part.done',
item_id: message.id,
output_index: 0,
content_index: 0,
part,
},
{
type: 'response.output_item.done',
output_index: 0,
item: message,
},
{ type: 'response.completed', response: completed },
]
}
function functionCallEvents(
name: string,
argumentsValue: Record<string, unknown>,
): Record<string, unknown>[] {
const argumentsText = JSON.stringify(argumentsValue)
const item = {
id: 'fc_fixture',
type: 'function_call',
status: 'completed',
name,
arguments: argumentsText,
call_id: 'call_fixture',
}
const completed = {
...responseObject(''),
output: [item],
usage: {
input_tokens: 10,
input_tokens_details: { cached_tokens: 0 },
output_tokens: 5,
output_tokens_details: { reasoning_tokens: 0 },
total_tokens: 15,
},
}
return [
{
type: 'response.created',
response: { ...completed, status: 'in_progress', output: [] },
},
{
type: 'response.output_item.added',
output_index: 0,
item: { ...item, status: 'in_progress', arguments: '' },
},
{
type: 'response.function_call_arguments.delta',
item_id: item.id,
output_index: 0,
delta: argumentsText,
},
{
type: 'response.function_call_arguments.done',
item_id: item.id,
output_index: 0,
arguments: argumentsText,
},
{
type: 'response.output_item.done',
output_index: 0,
item,
},
{ type: 'response.completed', response: completed },
]
}
function readRequest(request: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let body = ''
request.setEncoding('utf8')
request.on('data', (chunk: string) => { body += chunk })
request.on('end', () => { resolve(body) })
request.on('error', reject)
})
}
function closeServer(server: Server): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => {
if (error !== undefined) reject(error)
else resolve()
})
server.closeAllConnections()
})
}
/**
* Start a loopback-only Responses SSE fixture.
* @param script - one behavior per expected Responses request.
* @returns the running fixture and its observed requests.
*/
export async function startResponsesFixture(
script: readonly ResponsesBehavior[],
): Promise<ResponsesFixture> {
const behaviors = [...script]
const requests: RecordedResponsesRequest[] = []
const started = Promise.withResolvers<undefined>()
const openResponses = new Set<ServerResponse>()
const server = createServer((request, response) => {
openResponses.add(response)
response.on('close', () => { openResponses.delete(response) })
void readRequest(request).then((body) => {
requests.push({
method: request.method,
path: request.url,
headers: request.headers,
body: JSON.parse(body) as Record<string, unknown>,
})
started.resolve(undefined)
const behavior = behaviors.shift()
if (behavior === undefined) {
response.writeHead(500, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: { message: 'fixture script exhausted' } }))
return
}
response.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
'x-request-id': 'req_fixture',
})
if (behavior.kind === 'hold') return
const events = behavior.kind === 'complete'
? completeResponsesEvents(behavior.text)
: functionCallEvents(behavior.name, behavior.arguments)
for (const event of events) {
response.write(`data: ${JSON.stringify(event)}\n\n`)
}
response.end('data: [DONE]\n\n')
}).catch((error: unknown) => {
response.destroy(error instanceof Error ? error : new Error(String(error)))
})
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
server.off('error', reject)
resolve()
})
})
const address = server.address()
if (address === null || typeof address === 'string') {
throw new Error('responses fixture did not acquire a TCP port')
}
return {
baseUrl: `http://127.0.0.1:${address.port}/v1`,
requests,
requestStarted: started.promise,
async close(): Promise<void> {
for (const response of openResponses) response.destroy()
await closeServer(server)
},
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/agent"
},
{
"path": "../../llm/llm"
},
{
"path": "../../sdk/sdk-protocol"
},
{
"path": "../../core/session"
},
{
"path": "../subagent"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../util/timeout"
},
{
"path": "../../support/invariants"
}
]
}

Some files were not shown because too many files have changed in this diff Show More