feat(subagent): add Claude Code provider

This commit is contained in:
pku-xht
2026-08-04 20:57:55 +08:00
parent c09b20f96b
commit be56f64b7a
54 changed files with 3014 additions and 220 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/feature/2026-06-21-subagent-capability-seam.md
2026-06-21-subagent-capability-seam.md: fd22b883572e5304c1587c818026c36235ef504d
2026-06-21-subagent-capability-seam.zh.md: fcfdf3e9c1eb35d7c372aa4311ebf8da192b56a7
2026-06-21-subagent-capability-seam.md: 5b9b018df151f0d734b54cfdd4dacfd09058f7d7
2026-06-21-subagent-capability-seam.zh.md: 49571288e35abb1369c16abd5c77a81dd3212a12
@@ -14,7 +14,7 @@ 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);
- **Codex app-server** — a current one-shot sibling that applies the same named-provider seam to the official product process ([product-provider Agent Note](../../proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md));
- **Codex app-server** — a current one-shot sibling that applies the same named-provider seam to the official product process ([product-provider Agent Note](../../implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md));
- later: **A2A** and the **Claude Code Agent SDK** — the same out-of-process "start a child, prompt it, settle, cancel" shape; the Claude sibling remains in the product-provider proposal.
## Alternatives considered
@@ -14,7 +14,7 @@ harness 有一个长期搁置的 seam 用于 **subagent**:一个 agent(智
- **进程内**:在同一个 `Context` 上创建一个具体的子 `Agent`(最廉价,且鉴于现有 agent 工厂几乎零成本);
- **ACP**:作为 ACP *客户端*驱动另一个 agent 进程(可以是自身的另一个实例);
- **Codex app-server**:当前的一次性兄弟提供方,将同一个命名提供方 seam 应用于官方产品进程([产品提供方 Agent Note](../../proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md));
- **Codex app-server**:当前的一次性兄弟提供方,将同一个命名提供方 seam 应用于官方产品进程([产品提供方 Agent Note](../../implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md));
- 后续:**A2A** 与 **Claude Code Agent SDK**——两者采用同样的进程外形态:「启动子 agent、发送提示词、结算、取消」;Claude 兄弟提供方仍在产品提供方提案中。
## 曾考虑的替代方案
@@ -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: ea0ec821b1f7a55d173f58c5bc4ba8829ef65c54
2026-06-22-acp-subagent-backend.zh.md: 32e83a7a4e89eb7adb17220dc66952bca0a165aa
2026-06-22-acp-subagent-backend.md: d839ab6f75d8a518c9bc850894d1c3c5ffdbed92
2026-06-22-acp-subagent-backend.zh.md: e9027e282bf351890643e0545b01fe00287375a6
@@ -59,4 +59,4 @@ Every run pays a fresh subprocess (spawn + `initialize` + `newSession`). The par
## Future providers
The [Codex app-server provider](../../proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md) now applies the same out-of-process spawn/prompt/settle/cancel boundary as a sibling registered by name. A2A and the Claude Code Agent SDK remain future sibling transports; the ACP backend proves that the common seam supports the boundary without owning their private protocols.
The [Codex app-server provider](../../implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md) now applies the same out-of-process spawn/prompt/settle/cancel boundary as a sibling registered by name. A2A and the Claude Code Agent SDK remain future sibling transports; the ACP backend proves that the common seam supports the boundary without owning their private protocols.
@@ -59,4 +59,4 @@ ACP `StopReason` → harness `SubagentStopReason``end_turn`→`completed`、`
## 后续提供方
[Codex app-server 提供方](../../proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)已将同样的进程外启动/提示词/结算/取消边界应用于按名称注册的兄弟提供方。A2A 与 Claude Code Agent SDK 仍是未来的兄弟传输方式;ACP 后端证明了通用 seam 能够支持该边界,而无需负责它们的私有协议。
[Codex app-server 提供方](../../implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md)已将同样的进程外启动/提示词/结算/取消边界应用于按名称注册的兄弟提供方。A2A 与 Claude Code Agent SDK 仍是未来的兄弟传输方式;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: 923ac10573ee34746ff26d97b650edf4cddcaa9d
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: c1368296f2f9c3887db74f16516fad6d0de8d620
@@ -0,0 +1,86 @@
# 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 keyless evidence therefore separates two facts: a real-product test proves the official integration, native authentication shape, final answer, and teardown, while a Loader composition test proves that the public package and documented tool configuration load without starting the product. Direct model HTTP or a product double cannot prove the former; a hand-mounted plugin cannot prove the latter.
## 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`. 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 nonblank `agentMessage` with `phase: "final_answer"` wins. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback; 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 wire data, 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.
## 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`. 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.
## Distribution and evidence
Each product owns branch-complete package tests, a required real-product spec, and a Loader composition e2e. The real-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 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 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 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. 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, real-product loopback tests, public Loader composition, built-package and NodeNext consumer checks, generated documentation and notices, and the repository CI matrix. A supported product baseline change must refresh those facts; production performs no separate runtime version probe.
@@ -0,0 +1,86 @@
# 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)和文档所示的工具配置无需启动产品即可加载。直接发起模型 HTTP 请求或使用产品替身无法证明前者;手工挂载插件无法证明后者。
## 决策
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`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。
发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize``initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。
`turn/completed` 是权威的远端终止事实。以最后一条非空白的 `agentMessage` 为准,但它必须带有 `phase: "final_answer"`。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`
对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。
若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树并等待其退出,然后拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,并等待整棵进程树退出。结果失败与清理失败仍可彼此独立地观察。
## 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` 覆盖项,以及须为正有限值的 `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 关闭失败、进程失败和清理失败仍可彼此独立地观察。
## 分发与证据
每个产品都负责覆盖所有分支的包测试、一项必跑的真实产品测试和一项 Loader 组合 e2e。真实产品测试层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Loader 层级会启动 README 所示形态的用户配置,在同一个上下文中验证两个固定且只支持前台执行的工具,并且不会启动任何产品进程。
Codex 证据锁定 `@openai/codex@0.146.0``codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。
Claude Code 证据锁定 Agent SDK 0.3.220 及其平台分发的 Claude Code 2.1.220 CLI。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消以及整棵进程树退出。Loader e2e 会在两个产品命令均不可用时按名称解析两个产品包,并记录零次子级启动。
项目所有者的分发授权范围限定为官方 `@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 契约委派一项自包含任务。官方产品集成会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。
每次委派都要承担新建产品进程和独立模型上下文的开销,且只有最终文本会到达父级。产品原生配置使行为取决于部署环境中安装的产品、账户状态和工作区设置。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。
兼容性由包级单元测试覆盖率、真实产品回环测试、公开 Loader 组合、已构建包与 NodeNext 消费方检查、生成的文档与声明以及仓库 CI 矩阵共同锁定。更改受支持的产品基线时必须刷新这些事实;生产环境不会另行执行运行时版本探测。
@@ -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-08-04-claude-code-and-codex-subagent-backends.md
2026-08-04-claude-code-and-codex-subagent-backends.md: 3b9fd51632439da5b3c3fd9187de552d6c9ca5e2
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 36be903640ad1c839d45ed1bf5e605f4e4d6e000
@@ -1,86 +0,0 @@
# Agent Note: Claude Code and Codex subagent backends
Status: proposed
English | [中文](2026-08-04-claude-code-and-codex-subagent-backends.zh.md)
## Problem
The named [`ctx.subagents`](../../implemented/feature/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. A useful first version must hand either 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 keyless evidence therefore separates two facts: a real-product test proves the official protocol, native authentication shape, final answer, and teardown, while a Loader composition test proves that the public package and documented tool configuration load without starting the product. Direct model HTTP or a product double cannot replace the former; a hand-mounted plugin cannot replace the latter.
## Proposal
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.
The Codex provider is implemented against Codex 0.146.0. The Claude Code provider remains unimplemented. This Note remains proposed until both siblings and their combined evidence are present.
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 always starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs`. 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 nonblank `agentMessage` with `phase: "final_answer"` wins. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback; 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 wire data, 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.
## Claude Code provider
The Claude Code sibling is not yet implemented. Its product version, official integration, terminal mapping, product-specific configuration, interaction policy, and evidence are not fixed by this intermediate proposal. Its eventual implementation must preserve the shared fixed-name, standalone-task, parent-cwd, shared-result, and managed-tree boundaries above before this Note can become implemented.
## Evidence contract
Each product owns branch-complete package tests, a required real-product spec, and a Loader composition e2e. The real-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 separate Loader tier boots the README-shaped user configuration, verifies the fixed provider and foreground-only common tool, and must not start a product process.
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. Its Loader e2e resolves `@deepseek-ai/dsh-subagent-codex` by package name, verifies the `codex` registration and `subagent_codex` schema with background omitted, accepts `maxDepth: 'provider-managed'`, and records zero child starts while no `codex` command is available. The npm package is a development dependency for reproducible real-product evidence; production still supplies `codex` on `PATH`.
The combined contract is complete only when the Claude sibling has equivalent real-product evidence and both public Loader configurations prove the fixed tools use the unchanged common subagent contract.
## 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 before the two products demonstrate a missing common contract, so each private 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 are useful for exhaustive private protocol branches but do not prove package exports, official binaries, authentication, or real process behavior. Required evidence drives each official product against a loopback model fixture.
**Plugin-managed login, product home, models, or permissions.** Those settings 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 first user result needs 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.
## Acceptance criteria
Both public provider packages load from user-owned Cordis configurations and form their fixed foreground tools without appearing in the shipped CLI defaults. Separate required real-product specs return exact final answers or explicit failure or cancellation and prove managed process-tree quiescence. Both packages document their configuration, lifecycle, failure behavior, model experience, and limitations; generated package, configuration, capability, dependency, and third-party records agree with the shipped manifests.
The implemented Codex half satisfies this contract for its fixed tool and 0.146.0 baseline. The proposal becomes implemented only after the Claude Code sibling and the combined two-product evidence satisfy the same ownership and lifecycle boundaries.
## Risks
- The product protocols are versioned and may change. Production performs no runtime version probe, so every supported baseline change requires refreshed compatibility evidence.
- Product-native configuration makes behavior depend on the deployment's installed product and account state. Required tests isolate those inputs, while production deliberately leaves them under the product's authority.
- Every delegation pays for a fresh process and independent model context, and only final text reaches the parent.
- Product tool or file side effects are not rolled back when a run fails or is cancelled.
- Unattended interaction denial prevents hidden approval hangs but cannot satisfy tasks that require new permission or human input.
@@ -1,86 +0,0 @@
# Agent Note: Claude Code 与 Codex subagent 后端
Status: proposed
[English](2026-08-04-claude-code-and-codex-subagent-backends.md) | 中文
## 问题
命名的 [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 注册表让父 agent(智能体)无需了解子级的运行方式即可委派工作,但 harness 需要通往真实 Codex 与 Claude Code 产品的第一方路径。可用的首版必须能向任一产品交付一项自包含任务,让它在父会话的工作区中执行,返回最终回答或明确的失败或取消结果,并且不留下任何受管的产品进程。
产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。因此,强制性的无密钥证据会分别证明两个事实:真实产品测试证明官方协议、原生身份验证形态、最终回答和资源清理;Loader 装配测试证明公开包与文档中的工具配置可以加载,且不会启动产品。直接发起模型 HTTP 请求或使用产品替身无法取代前者,手工挂载插件则无法取代后者。
## 提案
harness 将两个一次性兄弟提供方发布为可独立安装的可选包。用户在自己的 `cordis.yml` 中加载提供方与现有的通用 subagent 工具:`subagent_codex` 绑定 `codex`,而 `subagent_claude_code` 绑定 `claude-code`。正式 CLI 的依赖闭包以及基础、Web 和 headless 配置都不加载这两个提供方。每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。
Codex 提供方基于 Codex 0.146.0 实现。Claude Code 提供方仍未实现。在两个兄弟提供方及其组合证据全部具备之前,本 Agent Note 将保持提案状态。
这两个提供方都报告 `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`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。
发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize``initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。
`turn/completed` 是权威的远端终止事实。以最后一条非空白的 `agentMessage` 为准,但它必须带有 `phase: "final_answer"`。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、协议数据格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`
对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。
若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树并等待其退出,然后拒绝 `start()`。对已发布的运行执行释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与清理失败仍可彼此独立地观察。
## Claude Code 提供方
Claude Code 兄弟提供方尚未实现。其中间提案不固定产品版本、官方接入方式、终态映射、产品特定配置、交互策略或证据。它的最终实现必须保留上文所述的固定名称、独立任务、父级 cwd、共享结果和受管进程树边界,本 Agent Note 才能进入 implemented 状态。
## 证据契约
每个产品都负责覆盖所有分支的包(package)测试、一项必跑的真实产品测试和一项 Loader 装配 e2e。真实产品测试层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。独立的 Loader 层级会启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的通用工具,并且不得启动产品进程。
Codex 证据锁定 `@openai/codex@0.146.0``codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。其 Loader e2e 会按包名解析 `@deepseek-ai/dsh-subagent-codex`,验证 `codex` 注册与省略后台参数的 `subagent_codex` schema,接受 `maxDepth: 'provider-managed'`,并在环境中没有可用 `codex` 命令时记录零次子级启动。该 NPM 包是用于复现真实产品证据的开发依赖;生产环境仍提供 `codex`,并通过 `PATH` 解析。
只有在 Claude 兄弟提供方具备同等的真实产品证据,并且两个公开 Loader 配置都证明固定工具使用未变的通用 subagent 契约时,组合契约才算完整。
## 曾考虑的替代方案
**直接模型 HTTP、`codex exec` 或手写的 Claude CLI 协议。** 这些路径会绕过产品的官方可扩展接入面,无法证明原生配置、工具、审批、结果语义或资源清理。每个提供方都使用对应产品的官方接入方式。
**共享产品进程辅助包。** 现有 subagent 与子进程 seam 已负责围绕任务、结果、环境和进程树的全部共享职责。在两个产品尚未证明通用契约存在缺口时,新辅助包只会造成责任重复,因此各自的私有适配器会直接调用现有 seam。
**面向模型的产品选择器。** 产品可用性和身份验证属于部署事实。两个固定工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。
**以产品替身作为强制证据。** 替身有助于穷尽覆盖私有协议分支,但无法证明包导出、官方二进制程序、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture(测试前置数据)。
**由插件管理登录、产品主目录、模型或权限。** 这些设置会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。提供方只公开显式环境覆盖项和清理宽限期;无人值守交互会以默认拒绝方式失败。
**续接、进度、后台收集和共享父级上下文。** 首个用户结果只需要一项自包含任务和一个最终回答。产品会话、恢复、后续交互、中间消息、父级 transcript(文本记录)传递、结构化输出和后台收集都需要独立的用户契约,本提案不会预先构建这些功能。
## 验收标准
两个公开提供方包都能从用户自有的 Cordis 配置加载并组成固定的前台工具,而且不会出现在正式 CLI 默认配置中。独立的强制真实产品测试会返回完全一致的最终回答或明确的失败或取消结果,并证明受管进程树完全停稳。两个包都会记录其配置、生命周期、失败行为、模型体验和限制;生成的包、配置、功能、依赖与第三方记录均与已交付的 manifest(元数据清单)一致。
已经实现的 Codex 部分为其固定工具和 0.146.0 基线满足了本契约。只有在 Claude Code 兄弟提供方及两种产品的组合证据满足相同的归属与生命周期边界后,本提案才会进入 implemented 状态。
## 风险
- 产品协议受版本约束,且可能发生变化。生产环境不会执行运行时版本探测,因此每次更改受支持的基线都必须刷新兼容性证据。
- 产品原生配置使行为取决于部署环境中安装的产品与账户状态。强制测试会隔离这些输入,而生产环境会有意让产品继续负责它们。
- 每次委派都要承担新建进程和独立模型上下文的开销,且只有最终文本会到达父级。
- 运行失败或被取消时,产品工具或文件产生的副作用不会回滚。
- 拒绝无人值守交互可以防止审批流程暗中挂起,但无法完成需要新权限或人工输入的任务。
+21 -1
View File
@@ -5,7 +5,7 @@
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.
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 |
@@ -87,6 +89,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.
+5 -2
View File
@@ -104,6 +104,7 @@ flowchart LR
pkg_lsp_local["lsp-local"]
pkg_subagent_acp["subagent-acp"]
pkg_subagent_codex["subagent-codex"]
pkg_subagent_claude_code["subagent-claude-code"]
pkg_subagent_dsh_sdk["subagent-dsh-sdk"]
pkg_bash["bash"]
svc_bash["ctx.bash<br/>Bash executor seam"]
@@ -225,6 +226,7 @@ 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
@@ -315,6 +317,7 @@ 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_subprocess --> pkg_subagent_dsh_sdk
svc_systemPrompt --> pkg_agent_loop
@@ -376,7 +379,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), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | - | The bash executors, the LSP host, and the out-of-process ACP, Codex, and DSH SDK 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.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), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | - | The bash executors, the LSP host, and the out-of-process ACP, Codex, Claude Code, and DSH SDK 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) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. |
| `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the 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. |
@@ -387,7 +390,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), [`subagent-codex`](../packages/subagent/subagent-codex), [`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.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. |
+19
View File
@@ -1520,6 +1520,25 @@ export type PermissionPolicy = 'allow' | 'reject'
Source: [`packages/subagent/subagent-acp/src/index.ts:26`](../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:31`](../packages/subagent/subagent-claude-code/src/index.ts)
## `@deepseek-ai/dsh-subagent-codex`
Requires: `subagents` · `subprocess`
+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: 820d7fce8560028f592ec101f4f222013105f035
extension-cookbook.zh.md: bb48f584729ad3a67fd43ca8c4c9f6f4902de656
extension-cookbook.md: f9328220a3d2776cc6576f9478215be4f2fe51e1
extension-cookbook.zh.md: 52b67de97b5adbf2b85a60a613f49776d60673cb
+1 -1
View File
@@ -118,7 +118,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`/`-codex`/`-dsh-sdk`) + `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
@@ -118,7 +118,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`/`-codex`/`-dsh-sdk`+ `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: c810ae40f57a0f84f1a6b53e092d6bec88af206f
subagent.zh.md: efeec0a0f20b7ac85020df012878cf41f264073d
subagent.md: 9070c64da6cf2fa5a95074acb65f475bc9c05035
subagent.zh.md: 9819e6f707770caf208697f977d683103d2783e1
+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`, `-codex`, `-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. 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``-codex``-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 发现。设计理由见 [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)
@@ -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'
+1
View File
@@ -63,6 +63,7 @@
"@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:*",
+12
View File
@@ -45,6 +45,8 @@
"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",
@@ -555,6 +557,16 @@
"@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/subagent/README.md
README.md: abe1432d3c4ea0f67ed3cdf1bb4aec5f817d17b5
README.zh.md: 3df2b6c62dd355db2991468ad19883cd27c280cd
README.md: 75d90616e25f74761ac4f78c429a2e0c1aabfb38
README.zh.md: f22a5ecb4fc5d1324a5abe5afb16f34044d5312e
+2 -1
View File
@@ -12,11 +12,12 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](..
| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix, with cold resume | (registers on `ctx.subagents`) |
| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP (one-shot) | (registers on `ctx.subagents`) |
| `subagent-codex/` | Out-of-process backend: a real Codex app-server process with one ephemeral thread and turn | (registers on `ctx.subagents`) |
| `subagent-claude-code/` | Out-of-process backend: the official Claude Agent SDK with one real Claude Code CLI query | (registers on `ctx.subagents`) |
| `subagent-dsh-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) |
| `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
| `tool-subagent-control/` | The optional, globally named `send_message` and `list_agents` tools over `ctx.subagents` | (registers on `ctx.tools`) |
| `tool-subagent-report/` | Child-scoped `report` return channel for continuable in-process children | (registers in each child scope) |
The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-codex` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only external or nondeterministic product boundaries with package-local fixtures.
The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-codex` / `subagent-claude-code` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only external or nondeterministic product boundaries with package-local fixtures.
The design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).
+2 -1
View File
@@ -12,11 +12,12 @@ subagent(子 agentseam 允许 agent(智能体)把工作委派给子 age
| `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容、支持冷恢复的子 agent | (注册到 `ctx.subagents` |
| `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACPAgent Client Protocol)驱动的一次性子 agent | (注册到 `ctx.subagents` |
| `subagent-codex/` | 进程外后端:一个真实的 Codex app-server 进程,包含一个临时 thread 和一个轮次 | (注册到 `ctx.subagents` |
| `subagent-claude-code/` | 进程外后端:使用官方 Claude Agent SDK 与一次真实 Claude Code CLI query | (注册到 `ctx.subagents` |
| `subagent-dsh-sdk/` | 进程外后端:在 spawn 的子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents` |
| `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools` |
| `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message``list_agents` 工具 | (注册到 `ctx.tools` |
| `tool-subagent-report/` | 子级作用域的 `report` 返回通道,用于可继续的进程内子级 | (注册到每个子级作用域) |
接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-codex` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换外部或非确定性的产品边界。
接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-codex` / `subagent-claude-code` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换外部或非确定性的产品边界。
设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md) 和 [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
@@ -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: facaae300eeb8907076182a129aa216863dec8ec
README.zh.md: 75627cb54032edde07ec1ba5e21a058768515e29
@@ -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 process-tree termination grace in milliseconds; the final exit proof is bounded at twice this value. |
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 endpoint intended for the child must be supplied there; 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_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. The real-product spec drives that official SDK and CLI against a loopback Anthropic Messages SSE service with a non-empty fake key. It proves the exact task, authentication, byte-exact answer, inherited host-setting marker, process failure, cancellation, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration alongside the Codex provider with neither product command available, verifies both fixed foreground-only tools, and records zero child starts.
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` | 进程树终止宽限期,须为正有限值,单位为毫秒;最终退出确认的等待时间上限为该值的两倍。 |
生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或端点必须在该配置中显式提供;除非被覆盖,`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。真实产品测试使用非空伪密钥,驱动该官方 SDK 与 CLI 连接回环 Anthropic Messages SSE 服务,并证明原始任务、身份验证、逐字节一致的答案、继承的宿主设置标记、进程失败、取消与进程树退出。独立的 Loader 装配 e2e 会在两个产品命令均不可用时,与 Codex 提供方一起启动和 README 所示形态相同的用户配置,验证两个固定且只支持前台执行的工具,并记录零次子级启动。
项目所有者按身份范围授权分发官方 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",
"lib/types/**/*.d.ts.map",
"src"
],
"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",
"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:^",
"cordis": "^4.0.0-rc.7"
}
}
@@ -0,0 +1,95 @@
/**
* 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 {
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 spec: ClaudeCodeRunSpec = {
cwd: resolveChildCwd(
'subagent-claude-code',
undefined,
request.parent.session.header.cwd,
),
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,
)
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,159 @@
/**
* 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 type {
SubprocessHandle,
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))
}
/**
* Convert the SDK environment to the shared subprocess seam's defined-value
* overlay without changing the effective child environment.
* @param env - SDK-composed child environment.
* @returns entries whose values survive Node's subprocess environment.
*/
export function definedEnvironment(
env: SpawnOptions['env'],
): Record<string, string> {
const defined: Record<string, string> = {}
for (const [name, value] of Object.entries(env)) {
if (value !== undefined) defined[name] = value
}
return defined
}
/**
* 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: definedEnvironment(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) {
if (child.stdin === undefined || child.stdout === undefined) {
throw new Error('subagent-claude-code: SDK child requires piped stdin and stdout')
}
this.stdin = child.stdin
this.stdout = child.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,357 @@
/**
* 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
/** Largest delay Node schedules without collapsing it to one millisecond. */
const MAX_TIMER_DELAY_MS = 2_147_483_647n
/**
* Bound final exit observation at twice a positive finite grace without
* narrowing the public config to Node's single-timer integer range.
*/
function doubledGraceWindow(graceMs: number): {
readonly signal: AbortSignal
readonly cancel: () => void
} {
const whole = Math.floor(graceMs)
let remaining = BigInt(whole) * 2n
+ BigInt(Math.ceil((graceMs - whole) * 2))
const controller = new AbortController()
let timer: ReturnType<typeof setTimeout> | undefined
const arm = (): void => {
const chunk = remaining > MAX_TIMER_DELAY_MS
? MAX_TIMER_DELAY_MS
: remaining
remaining -= chunk
timer = setTimeout(() => {
timer = undefined
if (remaining === 0n) {
controller.abort()
} else {
arm()
}
}, Number(chunk))
}
arm()
return {
signal: controller.signal,
cancel: () => {
if (timer === undefined) return
clearTimeout(timer)
timer = undefined
},
}
}
type QueryFactory = (params: {
prompt: string
options: Options
}) => Query
/** 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 and final tree-exit bound. */
readonly disposeGraceMs: number
/** Shared subprocess service spawn operation. */
readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
/** Official query entrypoint; replaced only by package-local unit tests. */
readonly query?: QueryFactory
/** 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 -- SDK and subprocess failures reject with Error. */
return value instanceof Error ? value : new Error(String(value))
}
/**
* 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.
* @param setOutput - captures the candidate result for error diagnostics.
* @returns the completed shared result.
*/
export async function consumeClaudeQuery(
query: AsyncIterable<SDKMessage>,
setOutput: (output: ContentBlock[]) => void,
): Promise<SubagentResult> {
let answer: string | undefined
for await (const message of query) {
if (message.type !== 'result') continue
answer = successfulResult(message)
setOutput([{ type: 'text', text: answer }])
}
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.
* @param graceMs - termination grace used to bound final exit observation.
*/
export async function disposeClaudeCodeChild(
query: Pick<Query, 'close'> | undefined,
child: SubprocessHandle,
graceMs: number,
): Promise<void> {
const failures: Error[] = []
let treeExited = child.pid <= 0
try {
query?.close()
} catch (error: unknown) {
failures.push(thrown(error))
}
if (child.pid > 0) {
child.terminate()
const exitWindow = doubledGraceWindow(graceMs)
try {
treeExited = await child.waitForExit(exitWindow.signal)
if (!treeExited) {
failures.push(new Error(
'subagent-claude-code: Claude Code process tree did not exit within its dispose window',
))
}
} catch (error: unknown) {
failures.push(thrown(error))
} finally {
exitWindow.cancel()
}
}
if (treeExited) {
try {
await child.done
} catch (error: unknown) {
failures.push(thrown(error))
}
} else {
// The bounded tree observation owns teardown completion. Keep a later
// direct-child spawn failure observed without turning that bound into an
// unbounded wait.
void child.done.catch(() => {})
}
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 = (spec.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, spec.disposeGraceMs)
} 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)
}
let output: ContentBlock[] = []
const publishedQuery = query
const publishedChild = child
const result = settleRunResult({
attempt: () => consumeClaudeQuery(publishedQuery, (value) => {
output = value
}),
collectOutput: () => output,
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,
spec.disposeGraceMs,
),
})
}
@@ -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,163 @@
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 script - one behavior per Messages request.
* @returns the bound server and its recorded requests.
*/
export async function startMessagesFixture(
script: readonly MessagesBehavior[],
): Promise<MessagesFixture> {
const requests: RecordedMessagesRequest[] = []
let requestStartedResolve!: () => void
const requestStarted = new Promise<void>((resolve) => {
requestStartedResolve = resolve
})
let behaviorIndex = 0
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.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()
const behavior = script[behaviorIndex++]
if (behavior === undefined) {
response.writeHead(500, { 'content-type': 'application/json' })
response.end(JSON.stringify({
type: 'error',
error: {
type: 'api_error',
message: 'Messages fixture script was exhausted',
},
}))
return
}
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,226 @@
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 { 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 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 })
}
})
interface RealHarness {
readonly ctx: Context
readonly handles: SubprocessHandle[]
readonly parent: Agent
readonly workspace: string
readonly env: Record<string, string>
}
async function realHarness(script: readonly 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(script)
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()
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,845 @@
import { PassThrough } from 'node:stream'
import type {
Query,
SDKMessage,
SDKResultMessage,
SpawnOptions,
} from '@anthropic-ai/claude-agent-sdk'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { afterEach, 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 * as claudeCode from '../src/index.ts'
import * as invariant from '../src/invariant.ts'
import {
claudeSpawnSpec,
definedEnvironment,
ManagedClaudeCodeProcess,
} from '../src/process.ts'
import {
claudeQueryOptions,
consumeClaudeQuery,
disposeClaudeCodeChild,
startClaudeCodeRun,
successfulResult,
textTask,
type ClaudeCodeRunSpec,
} from '../src/run.ts'
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 stdin?: PassThrough | undefined
readonly stdout?: PassThrough | undefined
readonly exitOnTerminate?: boolean
readonly waitForExitResult?: 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 (options.waitForExitResult !== undefined) {
return options.waitForExitResult
}
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: options.stdin === undefined ? stdin : options.stdin,
stdout: options.stdout === undefined ? stdout : options.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 query: Query
readonly close: ReturnType<typeof vi.fn>
readonly spawnSpecs: SubprocessSpawnSpec[]
readonly options: Array<Parameters<NonNullable<ClaudeCodeRunSpec['query']>>[0]['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
},
query: (params) => {
options.push(params.options)
params.options.spawnClaudeCodeProcess!(sdkSpawnOptions())
return query
},
}
return { child, query, close, spawnSpecs, options, spec }
}
afterEach(() => {
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 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,
})
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', () => {
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(definedEnvironment(options.env)).toEqual({ A: 'one', C: 'three' })
expect(claudeSpawnSpec(options, 321)).toEqual({
argv: ['/official/claude', '--one', 'two'],
cwd: '/parent/workspace',
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
graceMs: 321,
signal,
env: { A: 'one', C: 'three' },
})
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 and rejects handles without the required pipes', 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()
const missingStdin = fakeChild({ stdin: undefined })
Object.defineProperty(missingStdin.handle, 'stdin', { value: undefined })
expect(() => new ManagedClaudeCodeProcess(missingStdin.handle))
.toThrow('requires piped stdin and stdout')
const missingStdout = fakeChild({ stdout: undefined })
Object.defineProperty(missingStdout.handle, 'stdout', { value: undefined })
expect(() => new ManagedClaudeCodeProcess(missingStdout.handle))
.toThrow('requires piped stdin and stdout')
})
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 outputs: ContentBlock[][] = []
const query = queryFrom([
{ type: 'system', subtype: 'init' } as SDKMessage,
success('first'),
success('last'),
])
await expect(consumeClaudeQuery(query, (output) => {
outputs.push(output)
})).resolves.toEqual({
output: [{ type: 'text', text: 'last' }],
stopReason: 'completed',
})
expect(outputs).toEqual([
[{ type: 'text', text: 'first' }],
[{ type: 'text', text: 'last' }],
])
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('preserves candidate output when iteration fails 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: [{ type: 'text', text: 'partial final' }],
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,
query: ({ 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('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()
await expect(startClaudeCodeRun(request(), {
...unused.spec,
query: () => queryFrom([], undefined, noChildClose),
})).rejects.toThrow('did not publish a controllable')
expect(noChildClose).toHaveBeenCalledOnce()
const closeFailure = vi.fn(() => { throw new Error('close boom') })
const noChild = startClaudeCodeRun(request(), {
...unused.spec,
query: () => queryFrom([], undefined, closeFailure),
})
await expect(noChild).rejects.toBeInstanceOf(AggregateError)
const startupAbort = new AbortController()
const abortedChild = fakeChild()
const abortedClose = vi.fn()
const abortedDuringStartup = startClaudeCodeRun(
request(undefined, startupAbort.signal),
{
...unused.spec,
spawn: () => abortedChild.handle,
query: ({ options }) => {
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
startupAbort.abort(new Error('startup cancelled'))
return queryFrom([], undefined, abortedClose)
},
},
)
await expect(abortedDuringStartup)
.rejects.toThrow('aborted before SDK startup')
expect(abortedClose).toHaveBeenCalledOnce()
expect(abortedChild.terminate).toHaveBeenCalledOnce()
await expect(startClaudeCodeRun(request(), {
...unused.spec,
query: () => {
throw new Error('query failed before resource creation')
},
})).rejects.toThrow('query failed before resource creation')
const spawned = fakeChild()
const spawnSpecs: SubprocessSpawnSpec[] = []
let factoryController: AbortController | undefined
const factoryFailure = startClaudeCodeRun(request(), {
...unused.spec,
spawn: (spawnSpec) => {
spawnSpecs.push(spawnSpec)
return spawned.handle
},
query: ({ options }) => {
factoryController = options.abortController
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
throw new Error('query construction failed')
},
})
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('bounded 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, 5)
expect(close).toHaveBeenCalledOnce()
expect(child.terminate).toHaveBeenCalledOnce()
expect(child.waitForExit).toHaveBeenCalledOnce()
await expect(child.handle.done).resolves.toEqual({
exitCode: 0,
signal: null,
})
})
it('accepts fractional and larger-than-Node grace windows', async () => {
for (const graceMs of [0.25, Number.MAX_VALUE]) {
const child = fakeChild()
await expect(disposeClaudeCodeChild(
{ close: vi.fn() },
child.handle,
graceMs,
)).resolves.toBeUndefined()
const signal = child.waitForExit.mock.calls[0]?.[0]
expect(signal?.aborted).toBe(false)
}
})
it('chains a doubled grace window beyond one Node timer segment', async () => {
vi.useFakeTimers()
try {
const child = fakeChild({ exitOnTerminate: false })
const disposal = disposeClaudeCodeChild(
{ close: vi.fn() },
child.handle,
1_073_741_823.75,
)
const rejected = expect(disposal)
.rejects.toThrow('did not exit within its dispose window')
await vi.advanceTimersByTimeAsync(2_147_483_647)
await vi.advanceTimersByTimeAsync(1)
await rejected
} finally {
vi.useRealTimers()
}
})
it('does not turn a missed tree-exit bound into an unbounded done wait', async () => {
const child = fakeChild({
exitOnTerminate: false,
waitForExitResult: false,
})
await expect(disposeClaudeCodeChild(
{ close: vi.fn() },
child.handle,
5,
)).rejects.toThrow('did not exit within its dispose window')
child.fail(new Error('late direct-child failure'))
await nextTask()
})
it('reports wait, close, and direct-child failures without skipping cleanup', async () => {
const waitFailure = fakeChild({
exitOnTerminate: false,
waitForExitError: new Error('wait boom'),
})
const closeFailure = vi.fn(() => { throw new Error('close boom') })
await expect(disposeClaudeCodeChild(
{ close: closeFailure },
waitFailure.handle,
5,
)).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,
5,
)).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,
5,
)).rejects.toBeInstanceOf(AggregateError)
})
})
@@ -0,0 +1,28 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/types/.tsbuildinfo"
},
"include": [
"src/**/*.ts"
],
"references": [
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../subagent"
},
{
"path": "../../subprocess/subprocess"
},
{
"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/subagent/README.md
README.md: 4682b06ae105a0ae70ea7e78a80776ac18d817e7
README.zh.md: c39afb26d8c6baf4774ae3b4a8f8151529a29e15
README.md: a388f5a57fd32768dc9b66e3637ff53bf6479149
README.zh.md: 48e5694e82ee0269661fbb5ede75cf995cbc00aa
+2 -1
View File
@@ -15,6 +15,7 @@ The family separates the stable interface from implementations and model-facing
| `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns; supports continuable children. |
| `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). |
| `@deepseek-ai/dsh-subagent-codex` | Fresh real Codex app-server child with one ephemeral thread and turn (one-shot). |
| `@deepseek-ai/dsh-subagent-claude-code` | Fresh official Claude Agent SDK query with a real managed Claude Code CLI child (one-shot). |
| `@deepseek-ai/dsh-subagent-dsh-sdk` | Fresh out-of-process harness child driven through the TypeScript SDK client (one-shot). |
| `@deepseek-ai/dsh-tool-subagent` | Model-facing delegation tool over one configured provider. |
| `@deepseek-ai/dsh-tool-subagent-control` | The globally named `send_message` follow-up tool. |
@@ -64,7 +65,7 @@ The seam owns the versioned `subagent/descriptor` session event vocabulary (`src
The seam owns the depth vocabulary shared by implementations and consumers: the `AgentOptions.subagentDepth` declaration, `assertSubagentMaxDepth`, and `delegationDepthOf(agent)`. The persisted `SessionHeader.delegationDepth` is authoritative and monotone — runtime options may deepen the count but never lower it, so a resumed child cannot be re-counted as top-level.
`inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and ACP do not), not whether it inherits tools, services, or authority.
`inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and the out-of-process one-shot providers do not), not whether it inherits tools, services, or authority.
## One-shot ownership and lifecycle
+2 -1
View File
@@ -15,6 +15,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
| `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容的进程内子 agent;支持可继续子 agent。 |
| `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACPAgent Client Protocol)子 agent(一次性)。 |
| `@deepseek-ai/dsh-subagent-codex` | 全新的真实 Codex app-server 子 agent,包含一个临时 thread 和一个轮次(一次性)。 |
| `@deepseek-ai/dsh-subagent-claude-code` | 通过官方 Claude Agent SDK 启动的全新 query,带有一个真实且受管的 Claude Code CLI 子进程(一次性)。 |
| `@deepseek-ai/dsh-subagent-dsh-sdk` | 通过 TypeScript SDK 客户端驱动的全新进程外 harness 子 agent(一次性)。 |
| `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的委派工具。 |
| `@deepseek-ai/dsh-tool-subagent-control` | 全局具名 `send_message` 后续操作工具。 |
@@ -64,7 +65,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
该 seam 拥有实现和消费方共享的深度词汇:`AgentOptions.subagentDepth` 声明、`assertSubagentMaxDepth``delegationDepthOf(agent)`。持久化的 `SessionHeader.delegationDepth` 具有权威性且单调:运行时选项可以加深计数,但绝不能降低它,因此恢复后的子 agent 不会被重新计为顶层。
`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` ACP 不可以),不表示是否继承工具、服务或权限。
`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn`各进程外一次性提供方不可以),不表示是否继承工具、服务或权限。
## 一次性所有权与生命周期
+149
View File
@@ -712,6 +712,9 @@ importers:
'@deepseek-ai/dsh-subagent-acp':
specifier: workspace:*
version: link:../packages/subagent/subagent-acp
'@deepseek-ai/dsh-subagent-claude-code':
specifier: workspace:*
version: link:../packages/subagent/subagent-claude-code
'@deepseek-ai/dsh-subagent-codex':
specifier: workspace:*
version: link:../packages/subagent/subagent-codex
@@ -4951,6 +4954,46 @@ importers:
specifier: ^4.0.0-rc.7
version: link:../../../vendor/cordis
packages/subagent/subagent-claude-code:
dependencies:
'@anthropic-ai/claude-agent-sdk':
specifier: 0.3.220
version: 0.3.220(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)
'@anthropic-ai/sdk':
specifier: 0.93.0
version: 0.93.0(zod@4.4.3)
schemastery:
specifier: ^3.18.0
version: link:../../../vendor/schemastery
devDependencies:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-loader-smoke':
specifier: workspace:^
version: link:../../support/loader-smoke
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../subagent
'@deepseek-ai/dsh-subprocess':
specifier: workspace:^
version: link:../../subprocess/subprocess
'@deepseek-ai/dsh-subprocess-local':
specifier: workspace:^
version: link:../../subprocess/subprocess-local
cordis:
specifier: ^4.0.0-rc.7
version: link:../../../vendor/cordis
packages/subagent/subagent-codex:
dependencies:
schemastery:
@@ -6824,6 +6867,58 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.220':
resolution: {integrity: sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q==}
cpu: [arm64]
os: [darwin]
'@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.220':
resolution: {integrity: sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA==}
cpu: [x64]
os: [darwin]
'@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.220':
resolution: {integrity: sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.220':
resolution: {integrity: sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.220':
resolution: {integrity: sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@anthropic-ai/claude-agent-sdk-linux-x64@0.3.220':
resolution: {integrity: sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.220':
resolution: {integrity: sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA==}
cpu: [arm64]
os: [win32]
'@anthropic-ai/claude-agent-sdk-win32-x64@0.3.220':
resolution: {integrity: sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw==}
cpu: [x64]
os: [win32]
'@anthropic-ai/claude-agent-sdk@0.3.220':
resolution: {integrity: sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA==}
engines: {node: '>=18.0.0'}
peerDependencies:
'@anthropic-ai/sdk': '>=0.93.0'
'@modelcontextprotocol/sdk': ^1.29.0
zod: ^4.0.0
'@anthropic-ai/sdk@0.91.1':
resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==}
hasBin: true
@@ -6833,6 +6928,15 @@ packages:
zod:
optional: true
'@anthropic-ai/sdk@0.93.0':
resolution: {integrity: sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA==}
hasBin: true
peerDependencies:
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta:
zod:
optional: true
'@asamuzakjp/css-color@5.1.11':
resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -12234,12 +12338,57 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.2.4
'@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.220':
optional: true
'@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.220':
optional: true
'@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.220':
optional: true
'@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.220':
optional: true
'@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.220':
optional: true
'@anthropic-ai/claude-agent-sdk-linux-x64@0.3.220':
optional: true
'@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.220':
optional: true
'@anthropic-ai/claude-agent-sdk-win32-x64@0.3.220':
optional: true
'@anthropic-ai/claude-agent-sdk@0.3.220(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)':
dependencies:
'@anthropic-ai/sdk': 0.93.0(zod@4.4.3)
'@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
zod: 4.4.3
optionalDependencies:
'@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.220
'@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.220
'@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.220
'@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.220
'@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.220
'@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.220
'@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.220
'@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.220
'@anthropic-ai/sdk@0.91.1(zod@4.4.3)':
dependencies:
json-schema-to-ts: 3.1.1
optionalDependencies:
zod: 4.4.3
'@anthropic-ai/sdk@0.93.0(zod@4.4.3)':
dependencies:
json-schema-to-ts: 3.1.1
optionalDependencies:
zod: 4.4.3
'@asamuzakjp/css-color@5.1.11':
dependencies:
'@asamuzakjp/generational-cache': 1.0.1
+3 -3
View File
@@ -320,8 +320,8 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Subprocess seam',
mode: 'seam',
implementations: ['subprocess-local'],
consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp', 'subagent-codex', 'subagent-dsh-sdk'],
note: 'The bash executors, the LSP host, and the out-of-process ACP, Codex, and DSH SDK subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.',
consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp', 'subagent-codex', 'subagent-claude-code', 'subagent-dsh-sdk'],
note: 'The bash executors, the LSP host, and the out-of-process ACP, Codex, Claude Code, and DSH SDK subagent backends spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.',
},
{
key: 'bash',
@@ -416,7 +416,7 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'subagent',
title: 'Subagent provider and continuation service',
mode: 'seam',
implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-codex', 'subagent-dsh-sdk'],
implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-codex', 'subagent-claude-code', 'subagent-dsh-sdk'],
consumers: ['tool-subagent', 'tool-subagent-control', 'tool-ralph'],
note: '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.',
},
+82 -2
View File
@@ -2,7 +2,20 @@ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSyn
import { join, resolve } from 'node:path'
import { tmpdir } from 'node:os'
import { describe, expect, it } from 'vitest'
import { collectPythonDependencies, isPermissive, type Manifest, manifestPatterns, parsePyprojectRequirements, parseVendoredRows, render, tierExternalDeps, virtualManifest } from './gen-third-party-notices.ts'
import {
CLAUDE_AGENT_SDK_PACKAGE,
claudeDistributionFromManifest,
collectPythonDependencies,
isOwnerAuthorizedRuntime,
isPermissive,
type Manifest,
manifestPatterns,
parsePyprojectRequirements,
parseVendoredRows,
render,
tierExternalDeps,
virtualManifest,
} from './gen-third-party-notices.ts'
const root = resolve(import.meta.dirname, '..')
@@ -223,7 +236,14 @@ describe('collectPythonDependencies', () => {
describe('isPermissive', () => {
it('accepts the licenses this project ships and rejects copyleft or unknown ones', () => {
expect(['MIT', 'ISC', 'BSD-3-Clause', 'Apache-2.0', 'MIT / Apache-2.0', '(MIT OR CC0-1.0)'].every(isPermissive)).toBe(true)
expect(['LGPL-3.0-only', 'MPL-2.0', 'GPL-3.0-or-later', 'SEE LICENSE IN LICENSE'].some(isPermissive)).toBe(false)
expect([
'LGPL-3.0-only',
'MPL-2.0',
'GPL-3.0-or-later',
'SEE LICENSE IN LICENSE',
'SEE LICENSE IN README.md',
'SEE LICENSE IN LICENSE.md',
].some(isPermissive)).toBe(false)
})
it('requires every operand of an AND, so a copyleft conjunct cannot ride along', () => {
@@ -245,6 +265,66 @@ describe('isPermissive', () => {
})
})
describe('official Claude distribution authorization', () => {
it('authorizes only the direct SDK identity without relabeling its license', () => {
expect(isOwnerAuthorizedRuntime(CLAUDE_AGENT_SDK_PACKAGE)).toBe(true)
expect(isOwnerAuthorizedRuntime(`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`))
.toBe(false)
expect(isOwnerAuthorizedRuntime('@anthropic-ai/unrelated')).toBe(false)
expect(isPermissive('SEE LICENSE IN README.md')).toBe(false)
})
it('derives version-independent platform payloads from the official SDK manifest', () => {
expect(claudeDistributionFromManifest({
name: CLAUDE_AGENT_SDK_PACKAGE,
version: '9.8.7',
license: 'future declared terms',
claudeCodeVersion: '6.5.4',
optionalDependencies: {
[`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`]: '9.8.7',
[`${CLAUDE_AGENT_SDK_PACKAGE}-darwin-arm64`]: '9.8.7',
},
})).toEqual({
sdkVersion: '9.8.7',
claudeCodeVersion: '6.5.4',
payloads: [
{
name: `${CLAUDE_AGENT_SDK_PACKAGE}-darwin-arm64`,
version: '9.8.7',
},
{
name: `${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`,
version: '9.8.7',
},
],
})
})
it('rejects a wrong SDK identity, missing payloads, and unrelated optionals', () => {
expect(() => claudeDistributionFromManifest({
name: '@anthropic-ai/unrelated',
version: '1.0.0',
claudeCodeVersion: '1.0.0',
optionalDependencies: {
[`${CLAUDE_AGENT_SDK_PACKAGE}-linux-x64`]: '1.0.0',
},
})).toThrow(`expected ${CLAUDE_AGENT_SDK_PACKAGE} manifest`)
expect(() => claudeDistributionFromManifest({
name: CLAUDE_AGENT_SDK_PACKAGE,
version: '1.0.0',
claudeCodeVersion: '1.0.0',
})).toThrow('declares no optional platform payloads')
expect(() => claudeDistributionFromManifest({
name: CLAUDE_AGENT_SDK_PACKAGE,
version: '1.0.0',
claudeCodeVersion: '1.0.0',
optionalDependencies: {
'@anthropic-ai/unrelated': '1.0.0',
},
})).toThrow('outside its authorized platform-payload identity')
})
})
describe('manifestPatterns', () => {
it('derives globs from the declared members, so a new member area is read', () => {
expect(manifestPatterns(['packages/*/*', 'tools/*'], ['packages/*'])).toEqual([
+155 -6
View File
@@ -49,6 +49,21 @@ const FIRST_PARTY = new Set([
'node-addon-landlock-run-linux-x64',
])
/** Official SDK identity covered by the project's narrow owner authorization. */
export const CLAUDE_AGENT_SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk'
const CLAUDE_PLATFORM_PACKAGE_PREFIX = `${CLAUDE_AGENT_SDK_PACKAGE}-`
const CLAUDE_PLATFORM_DECLARED_LICENSE = 'SEE LICENSE IN LICENSE.md'
/**
* Whether a non-permissive runtime declaration has an identity-scoped owner
* authorization. This does not reclassify its terms as permissive.
* @param name - exact npm package identity.
* @returns true only for the official Claude Agent SDK package.
*/
export function isOwnerAuthorizedRuntime(name: string): boolean {
return name === CLAUDE_AGENT_SDK_PACKAGE
}
/**
* Metadata overrides where the installed manifest is wrong or unreachable.
* Each entry documents why the store cannot answer.
@@ -92,6 +107,7 @@ const BUILD_TIME_TOOLS = [
/** The `package.json` fields this generator reads. */
export interface Manifest {
name?: string
version?: string
private?: boolean
license?: string
dependencies?: Record<string, string>
@@ -164,7 +180,74 @@ function loadWorkspaceManifests(): { manifests: Map<string, Manifest>; names: Se
return { manifests, names }
}
type VirtualManifest = Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }
type VirtualManifest = Manifest & {
claudeCodeVersion?: string
license?: string
repository?: string | { url?: string }
homepage?: string
}
/** One platform payload declared by the official Claude Agent SDK. */
export interface ClaudePlatformPayload {
readonly name: string
readonly version: string
}
/** Current SDK and CLI distribution facts derived from the installed SDK manifest. */
export interface ClaudeDistribution {
readonly sdkVersion: string
readonly claudeCodeVersion: string
readonly payloads: ClaudePlatformPayload[]
}
function requiredManifestString(
value: string | undefined,
field: string,
): string {
if (value === undefined || value.length === 0) {
throw new Error(`gen-third-party-notices: ${CLAUDE_AGENT_SDK_PACKAGE} has no ${field}.`)
}
return value
}
/**
* Derive the official platform payload set without a version or platform
* allowlist. Only identities in the SDK's own package namespace are covered.
* @param manifest - installed official SDK manifest.
* @returns current SDK, CLI, and optional platform payload facts.
*/
export function claudeDistributionFromManifest(
manifest: VirtualManifest,
): ClaudeDistribution {
if (manifest.name !== CLAUDE_AGENT_SDK_PACKAGE) {
throw new Error(
`gen-third-party-notices: expected ${CLAUDE_AGENT_SDK_PACKAGE} manifest, got ${JSON.stringify(manifest.name)}.`,
)
}
const sdkVersion = requiredManifestString(manifest.version, 'version')
const claudeCodeVersion = requiredManifestString(
manifest.claudeCodeVersion,
'claudeCodeVersion',
)
const entries = Object.entries(manifest.optionalDependencies ?? {})
if (entries.length === 0) {
throw new Error(
`gen-third-party-notices: ${CLAUDE_AGENT_SDK_PACKAGE} declares no optional platform payloads.`,
)
}
const payloads = entries.map(([name, version]) => {
if (!name.startsWith(CLAUDE_PLATFORM_PACKAGE_PREFIX)) {
throw new Error(
`gen-third-party-notices: ${CLAUDE_AGENT_SDK_PACKAGE} optional dependency ${name} is outside its authorized platform-payload identity.`,
)
}
return {
name,
version: requiredManifestString(version, `${name} optional dependency version`),
}
}).sort((left, right) => left.name.localeCompare(right.name))
return { sdkVersion, claudeCodeVersion, payloads }
}
/**
* Resolve one package's manifest inside a pnpm virtual store. The prefix scan
@@ -193,9 +276,8 @@ export function virtualManifest(virtual: string, name: string): VirtualManifest
return undefined
}
/** License and repository URL for an installed external package, from the pnpm store. */
function installedMetadata(name: string): { license: string; repo: string } {
const override = OVERRIDES[name]
/** Resolve one installed external package manifest from either pnpm store. */
function installedManifest(name: string): VirtualManifest | undefined {
let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined
// The nested Landlock workspace installs into its own store, so a package
// only that workspace depends on is unreachable from the root one.
@@ -210,6 +292,13 @@ function installedMetadata(name: string): { license: string; repo: string } {
manifest = virtualManifest(virtual, name)
if (manifest !== undefined) break
}
return manifest
}
/** License and repository URL for an installed external package, from the pnpm store. */
function installedMetadata(name: string): { license: string; repo: string } {
const override = OVERRIDES[name]
const manifest = installedManifest(name)
const license = override?.license ?? manifest?.license
const rawRepo = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url ?? manifest?.homepage
const repo = override?.repo ?? normalizeRepo(rawRepo)
@@ -219,6 +308,37 @@ function installedMetadata(name: string): { license: string; repo: string } {
return { license, repo }
}
function collectClaudeDistribution(): ClaudeDistribution {
const manifest = installedManifest(CLAUDE_AGENT_SDK_PACKAGE)
if (manifest === undefined) {
throw new Error(
`gen-third-party-notices: cannot resolve ${CLAUDE_AGENT_SDK_PACKAGE}; run \`pnpm install\`.`,
)
}
const distribution = claudeDistributionFromManifest(manifest)
let installedPayloads = 0
for (const payload of distribution.payloads) {
const installed = installedManifest(payload.name)
if (installed === undefined) continue
installedPayloads += 1
if (
installed.name !== payload.name
|| installed.version !== payload.version
|| installed.license !== CLAUDE_PLATFORM_DECLARED_LICENSE
) {
throw new Error(
`gen-third-party-notices: installed ${payload.name} does not match its SDK-declared version and ${CLAUDE_PLATFORM_DECLARED_LICENSE} license field.`,
)
}
}
if (installedPayloads === 0) {
throw new Error(
'gen-third-party-notices: no SDK-declared Claude platform payload is installed; install optional dependencies before regenerating.',
)
}
return distribution
}
/** Normalize a manifest repository/homepage value to a browsable https URL. */
function normalizeRepo(raw: string | undefined): string | undefined {
if (raw === undefined || raw === '') return undefined
@@ -519,6 +639,26 @@ function renderNpmTable(deps: ExternalDep[]): string {
return lines.join('\n')
}
function renderClaudeDistribution(
distribution: ClaudeDistribution | undefined,
): string {
if (distribution === undefined) return ''
const rows = distribution.payloads.map(payload =>
`| [\`${payload.name}\`](https://www.npmjs.com/package/${payload.name}) | ${payload.version} | ${CLAUDE_PLATFORM_DECLARED_LICENSE} |`,
)
return `
## Official Claude Code platform payloads
The project owner authorizes distribution of every version of the official \`${CLAUDE_AGENT_SDK_PACKAGE}\` 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 ${distribution.sdkVersion} declares the following optional platform packages. Each carries the official Claude Code ${distribution.claudeCodeVersion} 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 |
| --- | --- | --- |
${rows.join('\n')}
`
}
/**
* Render the complete notices document.
* @returns the exact bytes `THIRD_PARTY_NOTICES.md` must hold.
@@ -531,11 +671,19 @@ export function render(): string {
const vendored = collectVendored()
const python = collectPython()
const patched = collectPatched()
const claudeDistribution = runtimeDeps.some(
dep => dep.name === CLAUDE_AGENT_SDK_PACKAGE,
)
? collectClaudeDistribution()
: undefined
const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license))
// A copyleft license reaching a shipped surface is a distribution decision,
// not a rendering detail; the notices cannot quietly absorb it.
const nonPermissiveRuntime = runtimeDeps.filter(dep => !isPermissive(dep.license))
const nonPermissiveRuntime = runtimeDeps.filter(dep =>
!isPermissive(dep.license)
&& !isOwnerAuthorizedRuntime(dep.name),
)
if (nonPermissiveRuntime.length > 0) {
throw new Error(`gen-third-party-notices: runtime ${nonPermissiveRuntime.map(dep => `${dep.name} (${dep.license})`).join(', ')} is not a permissive license; review the distribution terms and record the decision before regenerating.`)
}
@@ -548,7 +696,7 @@ export function render(): string {
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.
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).
@@ -569,6 +717,7 @@ ${renderNpmTable(runtimeDeps)}
pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
${patchedLines.join('\n')}
${renderClaudeDistribution(claudeDistribution)}
## Development-only npm dependencies
+1
View File
@@ -194,6 +194,7 @@
{ "path": "./packages/subagent/subagent-spawn" },
{ "path": "./packages/subagent/subagent-fork" },
{ "path": "./packages/subagent/subagent-acp" },
{ "path": "./packages/subagent/subagent-claude-code" },
{ "path": "./packages/subagent/subagent-codex" },
{ "path": "./packages/subagent/subagent-dsh-sdk" },
{ "path": "./packages/tasks/tasks" },
+1
View File
@@ -54,6 +54,7 @@ const coverageExemptExcludes = coverageExemptRaw === '1'
// Keep the narrow exception in forks while the rest of the inventory avoids per-file processes.
const processBoundTests = [
'packages/subprocess/subprocess-local/tests/spawn.spec.ts',
'packages/subagent/subagent-claude-code/tests/real-product.spec.ts',
'packages/subagent/subagent-codex/tests/real-product.spec.ts',
'packages/context/time-context/tests/time-context.spec.ts',
'packages/llm/llm-pi-ai/tests/adapter.spec.ts',