Merge commit 'a9cc0fddb40be295c43cb2badb4cbcb2b032556c' into codex/product-providers-pr2-claude-code

# Conflicts:
#	.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml
#	.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md
#	.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md
#	docs/core-data-structures/subprocess.i18n.yaml
#	packages/subprocess/subprocess/README.i18n.yaml
This commit is contained in:
pku-xht
2026-08-05 04:41:27 +08:00
34 changed files with 140 additions and 171 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-08-04-claude-code-and-codex-subagent-backends.md
2026-08-04-claude-code-and-codex-subagent-backends.md: 27e805b142ed3541d5157e2440cb0a0d57ae461b
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 633a0de7653b8e985b5bd6dcf5b9424d1764bf43
2026-08-04-claude-code-and-codex-subagent-backends.md: e576065d7629bad4f41cabf4dbce3a99d0119f52
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 1209d73c8c0a1a99061fa951ea25afb010289d61
@@ -33,7 +33,7 @@ fixed tool → shared subagent service → product provider → official product
## 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.
`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Installation, login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities.
Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize``initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session.
@@ -49,7 +49,7 @@ Codex 0.146.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp
`@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 public configuration contains the same two deployment-owned values as the Codex sibling: an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Each run creates its own `AbortController`, sets `persistSession: false`, and disables `AskUserQuestion`. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK rather than waiting for a user interface the provider does not own.
The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. Every SDK error subtype, an error-marked success, a missing result, iterator failure, protocol failure, or process failure becomes `error`. SDK turn, budget, and structured-output limits are not token-window facts, and the SDK exposes no native refusal terminal, so this provider produces neither `max-tokens` nor `refusal`. Local cancellation wins and becomes `aborted`.
@@ -33,7 +33,7 @@ fixed tool → shared subagent service → product provider → official product
## Codex 提供方
`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。
`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`,且后者不得大于仓库共享的 `MAX_TIMER_DELAY_MS`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。
发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize``initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。
@@ -49,7 +49,7 @@ Codex 0.146.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端
`@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 失败,而不会等待本提供方不负责的用户界面。
公开配置包含与 Codex 兄弟提供方相同、由部署方负责的两个值:显式的 `env` 覆盖项,以及须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` `disposeGraceMs`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。
只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"``is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。所有 SDK 错误子类型、标记为错误的成功消息、结果缺失、迭代器失败、协议失败或进程失败都会成为 `error`。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens``refusal`。本地取消会胜出并成为 `aborted`
+1 -1
View File
@@ -1599,7 +1599,7 @@ export interface Config {
}
```
Source: [`packages/subagent/subagent-codex/src/index.ts:29`](../packages/subagent/subagent-codex/src/index.ts)
Source: [`packages/subagent/subagent-codex/src/index.ts:30`](../packages/subagent/subagent-codex/src/index.ts)
## `@deepseek-ai/dsh-subagent-dsh-sdk`
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/core-data-structures/subprocess.md
subprocess.md: a97e407290aa12881e7d6e847d51fec13c134135
subprocess.zh.md: e0b6f3fc2249d7095b5e540f91a0f13b544db978
subprocess.md: 8189795c5acf2fc1882d9e5bc1c006f49c327e2f
subprocess.zh.md: c506dfe4e998be0e7b87e9ef8a62ea1e05e2d24e
+5 -4
View File
@@ -101,10 +101,11 @@ interface SubprocessSpawnSpec {
/** Per-stream stdio dispositions. */
stdio: SubprocessStdio
/**
* Grace period in milliseconds for the {@link SubprocessHandle.terminate}
* escalation and for draining still-open collected pipes after the process
* exits (an inherited descriptor held by a surviving descendant cannot hold
* the outcome open indefinitely).
* Positive finite grace period in milliseconds, no greater than
* `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation
* and for draining still-open collected pipes after the process exits (an
* inherited descriptor held by a surviving descendant cannot hold the
* outcome open indefinitely).
*/
graceMs: number
/**
+5 -4
View File
@@ -101,10 +101,11 @@ interface SubprocessSpawnSpec {
/** Per-stream stdio dispositions. */
stdio: SubprocessStdio
/**
* Grace period in milliseconds for the {@link SubprocessHandle.terminate}
* escalation and for draining still-open collected pipes after the process
* exits (an inherited descriptor held by a surviving descendant cannot hold
* the outcome open indefinitely).
* Positive finite grace period in milliseconds, no greater than
* `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation
* and for draining still-open collected pipes after the process exits (an
* inherited descriptor held by a surviving descendant cannot hold the
* outcome open indefinitely).
*/
graceMs: number
/**
+4 -2
View File
@@ -344,6 +344,7 @@ flowchart TD
pkg_storage_sqlite --> pkg_storage
pkg_subprocess_local --> pkg_invariants
pkg_subprocess_local --> pkg_subprocess
pkg_subprocess_local --> pkg_timeout
pkg_typert_loader --> pkg_invariants
pkg_typert_loader --> pkg_typert_registry
pkg_llm_deepseek --> pkg_credentials
@@ -1015,6 +1016,7 @@ flowchart TD
pkg_subagent_codex --> pkg_session
pkg_subagent_codex --> pkg_subagent
pkg_subagent_codex --> pkg_subprocess
pkg_subagent_codex --> pkg_timeout
pkg_subagent_fork --> pkg_agent
pkg_subagent_fork --> pkg_invariants
pkg_subagent_fork --> pkg_session
@@ -1130,7 +1132,7 @@ flowchart TD
| [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
| [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
| [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) |
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
@@ -1261,7 +1263,7 @@ flowchart TD
| [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
| [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
@@ -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-claude-code/README.md
README.md: 59d7dcd8406cb244854bbb704f644af1a6aa8589
README.zh.md: 90ea0a316b91da3249b934984a3f512b71b8b3b2
README.md: 5bfc8ff3ba539b6caf891577cee5820d983106cd
README.zh.md: 6db9148228ba30c0f84beb14caa7a84aa0dcd9f5
@@ -27,7 +27,7 @@ The provider advertises no optional start-time capabilities and reports `inherit
| Key | Default | Meaning |
|---|---|---|
| `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. |
| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. |
| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. |
Production uses the Claude Code CLI supplied by `@anthropic-ai/claude-agent-sdk` and the host's native settings and authentication. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or endpoint intended for the child must be supplied there; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden.
@@ -51,7 +51,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe
## 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 keyless 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. A credentialed e2e maps a runtime-only DeepSeek key to the official Claude Code environment contract, starts the production provider and real SDK/CLI against the fixed official `/anthropic` endpoint, requires a unique nonce, and proves process-tree exit without persisting the key or calling the Messages API directly from the test.
The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`, whose platform optional dependency supplies Claude Code 2.1.220. Required evidence exercises that official distribution through a keyless loopback product path and a credentialed DeepSeek path, while Loader composition proves that both opt-in product packages coexist without starting either product.
The project owner's identity-scoped distribution authorization covers the official SDK and the official CLI/platform payloads declared by each SDK version. [`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) discloses the current optional payload closure without classifying its declared terms as permissive; unrelated non-permissive runtime dependencies continue to fail the notices gate.
@@ -27,7 +27,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK
| 配置键 | 默认值 | 含义 |
|---|---|---|
| `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 |
| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值;随后资源释放会等待整棵进程树退出。 |
| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 |
生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或端点必须在该配置中显式提供;除非被覆盖,`PATH``HOME` 等普通环境变量仍然可用。
@@ -51,7 +51,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK
## 产品兼容性与证据
运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其平台可选依赖提供 Claude Code 2.1.220。无密钥真实产品测试使用非空伪密钥,驱动该官方 SDK 与 CLI 连接回环 Anthropic Messages SSE 服务,并证明原始任务、身份验证、逐字节一致的答案、继承的宿主设置标记、进程失败、取消与进程树退出。独立的 Loader 装配 e2e 会在两个产品命令均不可用时,与 Codex 提供方一起启动和 README 所示形态相同的用户配置,验证两个固定且只支持前台执行的工具,并记录零次子级启动。带密钥 e2e 会将仅在运行时提供的 DeepSeek 密钥映射到 Claude Code 官方环境契约,启动生产提供方与真实 SDK 和 CLI 并连接固定的官方 `/anthropic` 端点,要求获得一个唯一随机数,并在不持久化密钥且不由测试直接调用 Messages API 的情况下证明进程树退出
运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其平台可选依赖提供 Claude Code 2.1.220。强制证据会通过无密钥回环产品路径与带密钥 DeepSeek 路径运行该官方发行版,而 Loader 组合则证明两个选择启用的产品包能够共存,且不会启动任一产品
项目所有者按身份范围授权分发官方 SDK 及每个 SDK 版本声明的官方 CLI/平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) 会披露当前可选载荷闭包,但不会把其声明条款归类为宽松许可证;其他无关的非宽松运行时依赖仍会使第三方声明门禁失败。
@@ -32,6 +32,7 @@
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
@@ -48,6 +49,7 @@
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
@@ -8,6 +8,7 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import {
assertPositiveFinite,
NO_START_CAPABILITIES,
@@ -90,6 +91,11 @@ export function apply(ctx: Context, config: Config): void {
'disposeGraceMs',
resolved.disposeGraceMs,
)
if (resolved.disposeGraceMs > MAX_TIMER_DELAY_MS) {
throw new Error(
`subagent-claude-code: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
ctx.subagents.registerProvider(new ClaudeCodeProvider(ctx, resolved))
}
/* jscpd:ignore-end */
@@ -83,18 +83,17 @@ function complete(
/**
* Start a loopback-only Anthropic Messages SSE fixture.
* @param script - one behavior per Messages request.
* @param behavior - the single response behavior for this fixture.
* @returns the bound server and its recorded requests.
*/
export async function startMessagesFixture(
script: readonly MessagesBehavior[],
behavior: 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) })
@@ -117,18 +116,6 @@ export async function startMessagesFixture(
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)
}
@@ -64,7 +64,7 @@ interface RealHarness {
readonly env: Record<string, string>
}
async function realHarness(script: readonly MessagesBehavior[]): Promise<{
async function realHarness(behavior: MessagesBehavior): Promise<{
readonly harness: RealHarness
readonly fixture: MessagesFixture
}> {
@@ -80,7 +80,7 @@ async function realHarness(script: readonly MessagesBehavior[]): Promise<{
join(claudeConfig, 'settings.json'),
`${JSON.stringify({ model: settingsModel }, null, 2)}\n`,
)
const fixture = await startMessagesFixture(script)
const fixture = await startMessagesFixture(behavior)
fixtures.push(fixture)
const env = {
ANTHROPIC_API_KEY: fakeKey,
@@ -149,9 +149,10 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', {
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 },
])
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')
@@ -191,7 +192,7 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', {
})
it('maps a real CLI process failure to error', async () => {
const { harness, fixture } = await realHarness([{ kind: 'hold' }])
const { harness, fixture } = await realHarness({ kind: 'hold' })
const run = await startRequest(harness, 'Exercise the failure path.')
await fixture.requestStarted
expect(harness.handles).toHaveLength(1)
@@ -207,7 +208,7 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', {
})
it('settles cancellation and leaves the real SDK-spawned CLI tree quiescent', async () => {
const { harness, fixture } = await realHarness([{ kind: 'hold' }])
const { harness, fixture } = await realHarness({ kind: 'hold' })
const controller = new AbortController()
const run = await startRequest(
harness,
@@ -27,6 +27,7 @@ import type {
SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import * as claudeCode from '../src/index.ts'
import * as invariant from '../src/invariant.ts'
import {
@@ -74,8 +75,6 @@ async function nextTask(): Promise<void> {
interface FakeChildOptions {
readonly pid?: number
readonly stdin?: PassThrough | undefined
readonly stdout?: PassThrough | undefined
readonly exitOnTerminate?: boolean
readonly waitForExitError?: Error
readonly doneError?: Error
@@ -145,8 +144,8 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild {
})
const handle: SubprocessHandle = {
pid: options.pid ?? 1234,
stdin: options.stdin === undefined ? stdin : options.stdin,
stdout: options.stdout === undefined ? stdout : options.stdout,
stdin,
stdout,
stderr: undefined,
collected: {},
done,
@@ -232,7 +231,6 @@ function sdkSpawnOptions(
interface FakeRun {
readonly child: FakeChild
readonly query: Query
readonly close: ReturnType<typeof vi.fn>
readonly spawnSpecs: SubprocessSpawnSpec[]
readonly options: Options[]
@@ -262,7 +260,7 @@ function fakeRun(
params.options.spawnClaudeCodeProcess!(sdkSpawnOptions())
return query
})
return { child, query, close, spawnSpecs, options, spec }
return { child, close, spawnSpecs, options, spec }
}
beforeEach(() => {
@@ -318,6 +316,11 @@ describe('task admission and package contracts', () => {
await expect(ctx.plugin(claudeCode, { disposeGraceMs }))
.rejects.toThrow('disposeGraceMs must be a positive finite number')
}
await expect(ctx.plugin(claudeCode, {
disposeGraceMs: MAX_TIMER_DELAY_MS + 1,
})).rejects.toThrow(
`disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`,
)
await ctx.fiber.dispose()
})
@@ -27,6 +27,9 @@
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../util/timeout"
},
{
"path": "../../support/invariants"
}
@@ -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-codex/README.md
README.md: 1dde57e10e27786ae06d395c7088976bf6f60ece
README.zh.md: cb56281d9018e7a400ceb770e31da8a60d10d54f
README.md: c25ee90edf8972da66448fe84cb659b0aec79e6f
README.zh.md: 10c8fcc47a9ab04bca983857bd44ede265c23435
+1 -1
View File
@@ -23,7 +23,7 @@ The provider advertises no optional start-time capabilities and reports `inherit
| Key | Default | Meaning |
|---|---|---|
| `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. |
| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. |
| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. |
Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden.
@@ -23,7 +23,7 @@
| 配置键 | 默认值 | 含义 |
|---|---|---|
| `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 |
| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值;随后资源释放会等待整棵进程树退出。 |
| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 |
生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH``HOME` 等普通环境变量值仍然可用。
@@ -33,6 +33,7 @@
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
@@ -48,6 +49,7 @@
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@openai/codex": "0.146.0",
"cordis": "^4.0.0-rc.7"
}
@@ -8,6 +8,7 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import {
assertPositiveFinite,
NO_START_CAPABILITIES,
@@ -85,5 +86,10 @@ export function apply(ctx: Context, config: Config): void {
'disposeGraceMs',
resolved.disposeGraceMs,
)
if (resolved.disposeGraceMs > MAX_TIMER_DELAY_MS) {
throw new Error(
`subagent-codex: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
ctx.subagents.registerProvider(new CodexProvider(ctx, resolved))
}
+9 -22
View File
@@ -14,22 +14,6 @@ import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol'
type JsonObject = Record<string, unknown>
interface Deferred<T> {
readonly promise: Promise<T>
readonly resolve: (value: T) => void
readonly reject: (reason?: unknown) => void
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((settle, fail) => {
resolve = settle
reject = fail
})
return { promise, resolve, reject }
}
function object(value: unknown, label: string): JsonObject {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`subagent-codex: app-server returned invalid ${label}`)
@@ -98,11 +82,11 @@ async function raceAbort<T>(pending: Promise<T>, signal: AbortSignal): Promise<T
*/
export class CodexAppServerWire {
private readonly transport: JsonRpcLineTransport
private readonly fatal = deferred<never>()
private readonly fatal = Promise.withResolvers<never>()
private threadId: string | undefined
private turnId: string | undefined
private pendingTurnId: string | undefined
private turnCompleted: Deferred<JsonObject> | undefined
private turnCompleted: PromiseWithResolvers<JsonObject> | undefined
private readonly earlyTurnNotifications: Array<{
readonly method: string
readonly params: JsonObject
@@ -193,7 +177,7 @@ export class CodexAppServerWire {
signal: AbortSignal,
cancelled: () => boolean,
): Promise<SubagentResult> {
const completion = deferred<JsonObject>()
const completion = Promise.withResolvers<JsonObject>()
this.turnCompleted = completion
const threadId = this.threadId as string
const response = object(await this.guarded(this.transport.request('turn/start', {
@@ -340,7 +324,8 @@ export class CodexAppServerWire {
private handleNotification(method: string, params: JsonObject): void {
if (method === 'turn/started') {
if (params.threadId !== this.threadId) return
const threadId = string(params.threadId, 'turn/started thread id')
if (threadId !== this.threadId) return
const turn = object(params.turn, 'turn/started turn')
if (this.turnCompleted !== undefined && this.turnId === undefined) {
this.observePendingTurnId(string(turn.id, 'turn/started turn id'))
@@ -348,7 +333,8 @@ export class CodexAppServerWire {
return
}
if (method === 'item/completed') {
if (params.threadId !== this.threadId) return
const threadId = string(params.threadId, 'item/completed thread id')
if (threadId !== this.threadId) return
const id = string(params.turnId, 'item/completed turn id')
if (this.turnId === undefined) {
if (this.turnCompleted !== undefined) {
@@ -373,7 +359,8 @@ export class CodexAppServerWire {
return
}
if (method !== 'turn/completed') return
if (params.threadId !== this.threadId) return
const threadId = string(params.threadId, 'turn/completed thread id')
if (threadId !== this.threadId) return
const turn = object(params.turn, 'turn/completed turn')
const id = string(turn.id, 'turn/completed turn id')
const turnCompleted = this.turnCompleted
@@ -6,6 +6,7 @@ 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 { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type {
SubprocessHandle,
SubprocessOutcome,
@@ -294,6 +295,8 @@ describe('task admission and package contracts', () => {
await expect(ctx.plugin(codex, { disposeGraceMs }))
.rejects.toThrow('disposeGraceMs must be a positive finite number')
}
await expect(ctx.plugin(codex, { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 }))
.rejects.toThrow(`disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
await ctx.fiber.dispose()
})
@@ -513,6 +516,16 @@ describe('CodexAppServerWire', () => {
}
})
it('fails closed when terminal notification params are not an object', async () => {
const { child, wire } = await initializeWire()
const result = wire.runTurn(['task'], new AbortController().signal, () => false)
const turnStart = await child.peer.nextMethod('turn/start')
child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
child.peer.send({ method: 'turn/completed', params: null })
await expect(result).rejects.toThrow('invalid turn/completed thread id')
wire.close()
})
it('keeps an unsupported request authoritative over an early terminal in the same chunk', async () => {
const { child, wire } = await initializeWire()
const result = wire.runTurn(['task'], new AbortController().signal, () => false)
@@ -35,6 +35,9 @@
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../util/timeout"
},
{
"path": "../../support/invariants"
}
@@ -29,11 +29,13 @@
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
@@ -15,6 +15,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { setTimeout as sleepMs } from 'node:timers/promises'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type {
CollectedOutput,
SubprocessCollect,
@@ -55,47 +56,6 @@ function sleepTick(): Promise<void> {
return sleepMs(15)
}
/** Largest delay Node schedules without collapsing it to one millisecond. */
const MAX_TIMER_DELAY_MS = 2_147_483_647n
/**
* Schedule a positive finite millisecond delay across as many Node-safe timer
* segments as necessary. Fractional milliseconds round up so a grace never
* expires earlier than configured.
* @param delayMs - positive finite delay in milliseconds.
* @param callback - work to run after the complete delay.
* @returns a handle that cancels the active segment and all future segments.
*/
export function scheduleFiniteTimeout(
delayMs: number,
callback: () => void,
): { cancel(): void } {
let remaining = BigInt(Math.ceil(delayMs))
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) {
callback()
} else {
arm()
}
}, Number(chunk))
}
arm()
return {
cancel(): void {
if (timer === undefined) return
clearTimeout(timer)
timer = undefined
},
}
}
let spillCounter = 0
let defaultSpillDir: string | undefined
@@ -339,8 +299,12 @@ function signalTree(
* @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment.
* @param internals - test-only spill-directory, platform, and taskkill overrides.
* @returns live subprocess handle.
* @throws when `graceMs` cannot be represented by one Node timer.
*/
export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle {
if (!Number.isFinite(spec.graceMs) || spec.graceMs <= 0 || spec.graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
}
const spillDir = internals.spillDir ?? privateSpillDir()
const platform = internals.platform ?? process.platform
const taskkill = internals.taskkill ?? taskkillProcessTree
@@ -382,7 +346,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
const stdoutCollector = collectStream(outMode, child.stdout, 'stdout')
const stderrCollector = collectStream(errMode, child.stderr, 'stderr')
let graceTimer: ReturnType<typeof scheduleFiniteTimeout> | undefined
let graceTimer: ReturnType<typeof setTimeout> | undefined
let treeExitObserved = false
let treeExitObservation: Promise<void> | undefined
let settled = false
@@ -426,7 +390,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
treeExitObservation ??= (async () => {
while (treeAlive()) await sleepTick()
treeExitObserved = true
graceTimer?.cancel()
if (graceTimer !== undefined) clearTimeout(graceTimer)
graceTimer = undefined
})()
return treeExitObservation
@@ -457,7 +421,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
// kill() re-probes tree liveness before force-killing. It stays ref'd:
// the pending SIGKILL is a commitment, and a parent exiting before it
// fires would orphan a trapped survivor. Self-bounds at graceMs.
graceTimer = scheduleFiniteTimeout(spec.graceMs, () => { kill('SIGKILL') })
graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs)
}
// The caller owns timeout classification; this layer only reacts to abort.
@@ -472,7 +436,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
}
const done = new Promise<SubprocessOutcome>((resolve, reject) => {
let pipeDrainTimer: ReturnType<typeof scheduleFiniteTimeout> | undefined
let pipeDrainTimer: ReturnType<typeof setTimeout> | undefined
const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
if (settled) return
settled = true
@@ -495,15 +459,15 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
// A surviving descendant that inherited a pipe must not hold the
// outcome open indefinitely: after exit, the same bounded grace that
// governs kills also bounds the close wait.
pipeDrainTimer = scheduleFiniteTimeout(spec.graceMs, () => {
pipeDrainTimer = setTimeout(() => {
settle(exitCode, signal)
})
}, spec.graceMs)
})
child.on('close', settle)
function cleanup(): void {
// graceTimer deliberately NOT cleared: the SIGKILL escalation must be
// able to reach tree survivors after the direct child settles.
pipeDrainTimer?.cancel()
if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer)
spec.signal?.removeEventListener('abort', onAbort)
}
})
@@ -5,11 +5,11 @@ import { describe, expect, it, vi } from 'vitest'
import {
killGroup,
OutputCollector,
scheduleFiniteTimeout,
spawnSubprocess,
taskkillProcessTree,
} from '../src/spawn.ts'
import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
const { failNextClose, failNextUnlink } = vi.hoisted(() => ({
failNextClose: { value: false },
@@ -107,31 +107,15 @@ async function waitForPidFile(path: string, timeoutMs = 5_000): Promise<number>
throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`)
}
describe('scheduleFiniteTimeout', () => {
it('rounds fractions up, chains Node-safe segments, and cancels idempotently', async () => {
vi.useFakeTimers()
try {
const fired = vi.fn()
const chained = scheduleFiniteTimeout(2_147_483_647.25, fired)
await vi.advanceTimersByTimeAsync(2_147_483_647)
expect(fired).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(fired).toHaveBeenCalledOnce()
chained.cancel()
const cancelled = vi.fn()
const timer = scheduleFiniteTimeout(0.25, cancelled)
timer.cancel()
timer.cancel()
await vi.advanceTimersByTimeAsync(1)
expect(cancelled).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
})
describe('spawnSubprocess', () => {
it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, MAX_TIMER_DELAY_MS + 1])(
'rejects an invalid grace before spawning: %s',
(graceMs) => {
expect(() => spawnSubprocess(spec('true', { graceMs })))
.toThrow(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
},
)
it('captures stdout on success', async () => {
const result = await finish(spawnSubprocess(spec('echo hello')))
expect(result.exitCode).toBe(0)
@@ -194,17 +178,6 @@ describe('spawnSubprocess', () => {
expect(result.signal).toBe('SIGKILL')
})
it('cancels a larger-than-Node escalation timer once SIGTERM removes the tree', async () => {
const running = spawnSubprocess(spec('echo ready; sleep 60', {
graceMs: Number.MAX_VALUE,
}))
await waitForStdout(running, 'ready\n')
running.terminate()
const result = await running.done
expect(result.signal).toBe('SIGTERM')
await expect(running.waitForExit()).resolves.toBe(true)
})
it('cancels escalation when the terminated group vanishes before collected pipes drain', async () => {
const pidFile = join(spillDir, `escaped-pipe-holder-${Date.now()}.pid`)
const graceMs = 160
@@ -17,6 +17,9 @@
{
"path": "../subprocess"
},
{
"path": "../../util/timeout"
},
{
"path": "../../support/invariants"
}
@@ -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/subprocess/subprocess/README.md
README.md: 13c634429bfae9408dc732aea69df673e5da87aa
README.zh.md: fe7b28d3a8f256e0eb9b4cbb98093bac33816fdf
README.md: e59dd96df036826f36bd0286c977438d2d87d1cf
README.zh.md: 2268af3cf2cfdac87ce9677c8d6d81c6be4c9631
+1 -1
View File
@@ -7,7 +7,7 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes
## Contract
- `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures.
- The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself.
- The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). Grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so the implementation can represent it with one Node timer instead of accepting a value that Node collapses to one millisecond. `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself.
- Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement.
- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, a no-op once the tree is gone), and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence — the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification).
- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and the spec's explicit `env` merges after the scrub with no namespace validation — a string deliberately forwards or overrides a value, while an `undefined` tombstone removes an ordinary ambient entry. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub.
+1 -1
View File
@@ -7,7 +7,7 @@
## 契约
- `spawn(spec)` 立即返回一个活动句柄;`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。
- spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的子进程默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`
- spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的子进程默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样实现便可用一个 Node 定时器表示它,而不会接受会被 Node 折叠为 1 毫秒的值。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`
- stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACPAgent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。
- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit(signal?)` 观察整棵进程树的存活状态,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。
- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,spec 的显式 `env` 在清理后合并且不做命名空间校验——字符串会有意转发或覆盖某个值,而 `undefined` tombstone 则会删除普通的环境条目。无法把 spawn 路由到该服务的进程启动方(node-pty 后端、由 SDK 管理的传输层)会导入该环境清理定义。
+5 -4
View File
@@ -80,10 +80,11 @@ export interface SubprocessSpawnSpec {
/** Per-stream stdio dispositions. */
stdio: SubprocessStdio
/**
* Grace period in milliseconds for the {@link SubprocessHandle.terminate}
* escalation and for draining still-open collected pipes after the process
* exits (an inherited descriptor held by a surviving descendant cannot hold
* the outcome open indefinitely).
* Positive finite grace period in milliseconds, no greater than
* `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation
* and for draining still-open collected pipes after the process exits (an
* inherited descriptor held by a surviving descendant cannot hold the
* outcome open indefinitely).
*/
graceMs: number
/**
+9
View File
@@ -5125,6 +5125,9 @@ importers:
'@deepseek-ai/dsh-subprocess-local':
specifier: workspace:^
version: link:../../subprocess/subprocess-local
'@deepseek-ai/dsh-timeout':
specifier: workspace:^
version: link:../../util/timeout
cordis:
specifier: ^4.0.0-rc.7
version: link:../../../vendor/cordis
@@ -5162,6 +5165,9 @@ importers:
'@deepseek-ai/dsh-subprocess-local':
specifier: workspace:^
version: link:../../subprocess/subprocess-local
'@deepseek-ai/dsh-timeout':
specifier: workspace:^
version: link:../../util/timeout
'@openai/codex':
specifier: 0.146.0
version: 0.146.0
@@ -5503,6 +5509,9 @@ importers:
'@deepseek-ai/dsh-subprocess':
specifier: workspace:^
version: link:../subprocess
'@deepseek-ai/dsh-timeout':
specifier: workspace:^
version: link:../../util/timeout
cordis:
specifier: ^4.0.0-rc.7
version: link:../../../vendor/cordis