Merge branch 'stack/agent-profiles-3-wire' into stack/agent-profiles-4-settings

This commit is contained in:
Yichen Jiang
2026-08-07 01:01:50 +08:00
188 changed files with 8407 additions and 654 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/bash-local/README.md
README.md: bb87ad6fe021e3144cef4adced3d798bf3d94d67
README.zh.md: d2f8c9091072bbf3d75909f6826432601001ab88
README.md: cb40cb8fa40d95d5b4589b7c804f450a2bf38c8e
README.zh.md: bd4f73babdb47ff92e87e20eb7d60657ed515ec4

View File

@@ -23,7 +23,7 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
## Behavior
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` with no rc files.
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Background processes** — `start()` returns a live `BashProcess` handle immediately with no timeout, and `readOutput()` merges offset-based stdout/stderr reads into one consuming delta, placing stderr under a `[stderr]` marker when present. A running process belongs to the subprocess service, survives executor reloads, and is killed and joined on service disposal. Task ids, ownership, polling, and notices belong to the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with.

View File

@@ -23,7 +23,7 @@
## 行为
- **每次调用都 spawn不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`,且不读取 rc 文件。
- **在受管进程组之上应用配置预算**`resolve()` 从配置补全 `workdir``timeoutMs``stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算stderr 和后台运行仍使用 `maxOutputBytes`
- **在受管进程组之上应用配置预算**`resolve()` 从配置补全 `workdir``timeoutMs``stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算stderr 和后台运行仍使用 `maxOutputBytes`
- **超时与取消分类**`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
- **适合模型的终端环境**`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
- **后台进程**`start()` 会立即返回活动的 `BashProcess` 句柄且不应用超时;`readOutput()` 把基于偏移量的 stdout/stderr 读取合并为一条消费式增量,并在存在 stderr 时将其置于 `[stderr]` 标记下。运行中的进程属于 subprocess 服务,可在执行器重载后存活,并在服务 dispose 时被终止且等待退出。task id、所有权、轮询和通知属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄。

View File

@@ -14,7 +14,7 @@ import z from 'schemastery'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
/**
* Model-friendly environment overrides: disable colors, pagers, and
@@ -48,7 +48,7 @@ export interface Config {
maxOutputBytes?: number
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
maxSpillBytes?: number
/** Grace period for kill escalation and for inherited pipes after shell exit. */
/** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */
graceMs?: number
}
@@ -102,6 +102,9 @@ export class LocalBashExecutor extends BashExecutor {
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
assertPositiveFinite('graceMs', this.config.graceMs)
if (this.config.graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
}
}
/**

View File

@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { BashProcess } from '@deepseek-ai/dsh-bash'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
@@ -70,6 +71,8 @@ describe('LocalBashExecutor.run', () => {
await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/)
await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/)
await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/)
await expect(setup({ graceMs: MAX_TIMER_DELAY_MS + 1 }))
.rejects.toThrow(`graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
const { bash } = await setup()
expect(() => bash.resolve({ command: 'true', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/pwsh-local/README.md
README.md: 2914c46ab91dd9555dab04551e52321f6eac05bf
README.zh.md: ce9696b276a2e60acf116d7124cd5cd256d7ebde
README.md: 3e38ea3830cb651a80eaee744a42f68891767358
README.zh.md: 8d32ce865d299bac37704e3e8730a7faa63ee108

View File

@@ -30,7 +30,7 @@ The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantic
- **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output.
- **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected.
- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction.
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent.
- **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins.
- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies, and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry.

View File

@@ -30,7 +30,7 @@
- **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。
- **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding``$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认pwsh 7 默认为 UTF-8不受影响。
- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数,在构造时执行一次。
- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。进程树终止Windows 用 taskkillPOSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算stderr 与后台运行仍使用 `maxOutputBytes`
- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止Windows 用 taskkillPOSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算stderr 与后台运行仍使用 `maxOutputBytes`
- **超时与取消分类**——`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)。Windows 将强制终止报告为退出码 1 且无信号,因此基于信号的实情(`signal``killed` 状态)在那里仅限 POSIX超时/取消分类与平台无关。
- **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。
- **后台进程**——`start()` 立即返回存活的 `BashProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为带标记分段的增量与消费游标。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务销毁(被终止并 join。一切任务形状的职责id、所有权、轮询、通知都在通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。

View File

@@ -18,7 +18,7 @@ import z from 'schemastery'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { resolvePwshPath } from './resolve.ts'
/* jscpd:ignore-start -- deliberate call-for-call mirror of dsh-bash-local (Agent Note: pwsh-tool-and-executor). */
@@ -62,7 +62,7 @@ export interface Config {
maxOutputBytes?: number
/** Per-stream spill-file cap; larger streams retain only their in-memory tail. */
maxSpillBytes?: number
/** Grace period for kill escalation and for inherited pipes after shell exit. */
/** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */
graceMs?: number
/**
* Explicit pwsh executable. When omitted, well-known Windows install
@@ -129,6 +129,9 @@ export class PwshLocalExecutor extends BashExecutor {
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
assertPositiveFinite('graceMs', this.config.graceMs)
if (this.config.graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
}
this.pwshPath = resolvePwshPath(this.config.pwshPath)
}

View File

@@ -19,6 +19,7 @@ import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPa
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import SubprocessService from '@deepseek-ai/dsh-subprocess'
import type { SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { BashProcess } from '@deepseek-ai/dsh-bash'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-'))
@@ -187,6 +188,8 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => {
await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/)
await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/)
await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/)
await expect(setup({ graceMs: MAX_TIMER_DELAY_MS + 1 }))
.rejects.toThrow(`graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
const { bash } = await setup()
expect(() => bash.resolve({ command: 'Write-Output ok', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/)

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/bundle/web-app/README.md
README.md: 95cdbc9694b44539742e5b871157eefa7cb4c290
README.zh.md: b8d6e9d80bac82a7798cc07d3a34c01d219f2174
README.md: a835497982e41c29c5dbc287c43b0515a61f77c9
README.zh.md: 00d8636e848a7927792dfc8ad3a460015c9e7fef

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection, storage) and the browser plugin roster, and mounts this package's own `web-runtime` glue plugin (config `{mode, printUrl, lanAddresses}`). That plugin owns what used to be launcher code: it resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports (workspace knowledge of this bundle, never user config), mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses`/`printUrl` and the flag family over these rows; [`dsh-headless`](../headless/README.md) layers on top and silences the URL line.
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection, storage) and the browser plugin roster, and mounts this package's own `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin owns what used to be launcher code: it resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports (workspace knowledge of this bundle, never user config), mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses` and the flag family over these rows; [`dsh-headless`](../headless/README.md) layers on top, silences the URL line, and disables the surface context.
## Model Experience
@@ -10,7 +10,7 @@ The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides ove
#### What the model sees
The `app:web-surface` global section (order 98) orients the model to the GUI: the canonical local URL, the "this page" referent, the HMR/rebuild update contract for the active mode, and the instruction not to start replacement servers. `DSH_WEB_URL` and `DSH_WEB_MODE` additionally appear in the managed bash environment with their descriptions, resolved per invocation from the live server.
When `surfaceContext` is true, the `app:web-surface` global section (order 98) orients the model to the GUI: the canonical local URL, the "this page" referent, the HMR/rebuild update contract for the active mode, and the instruction not to start replacement servers. `DSH_WEB_URL` and `DSH_WEB_MODE` additionally appear in the managed bash environment with their descriptions, resolved per invocation from the live server. When it is false, neither the section nor the variables are registered.
#### Token effect

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona插入 Web 宿主行webserver、API 网关、workspace、投影、存储与浏览器插件名录并挂载本包自己的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, lanAddresses}`)。该插件接管了原先属于启动器的代码:它通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist这是本组合包的 workspace 知识,绝不是用户配置),在其上挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL``DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode``lanAddresses``printUrl` 与相应 flag 家族 patch 到这些行上;[`dsh-headless`](../headless/README.md) 再叠加一层关闭 URL 行。
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona插入 Web 宿主行webserver、API 网关、workspace、投影、存储与浏览器插件名录并挂载本包自己的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件接管了原先属于启动器的代码:它通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist这是本组合包的 workspace 知识,绝不是用户配置),在其上挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,`surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL``DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode``lanAddresses` 与相应 flag 家族 patch 到这些行上;[`dsh-headless`](../headless/README.md) 再叠加一层关闭 URL 行并禁用表层上下文
## 模型体验
@@ -10,7 +10,7 @@ dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在
#### 模型看到的内容
全局段落 `app:web-surface`(顺序 98向模型说明 GUI规范的本地 URL、「this page」指代什么、当前模式下 HMR热模块替换重建的更新契约以及不要启动替代服务器的指令。`DSH_WEB_URL``DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。
`surfaceContext` 为 true 时,全局段落 `app:web-surface`(顺序 98向模型说明 GUI规范的本地 URL、「this page」指代什么、当前模式下 HMR热模块替换重建的更新契约以及不要启动替代服务器的指令。`DSH_WEB_URL``DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,该提示词段和这些变量都不会注册。
#### Token 影响

View File

@@ -103,12 +103,15 @@
# assembly fact of dsh-web-app, never user config), mounts the
# frontend-static fallback owner, registers the web-surface prompt
# section and bash runtime variables, and prints the URL line. `dsh web`
# patches mode/lanAddresses over these defaults.
# patches mode/lanAddresses over these defaults; complete-prompt overlays
# set surfaceContext false to suppress every model- and shell-visible Web
# runtime contribution.
- id: web-runtime
name: '@deepseek-ai/dsh-web-app'
config:
mode: production
printUrl: true
surfaceContext: true
# ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ──

View File

@@ -7,7 +7,7 @@
import { useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { BrandWordmark, Button } from '@deepseek-ai/dsh-client-ui-primitives'
import { BrandWordmark, Button, OnboardingSurface } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
import { deepSeekReadiness } from './store.ts'
@@ -66,6 +66,9 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
openSection('models')
}
// Null covers the still-deciding and nothing-to-do states alike: the
// takeover chrome below is part of THIS render, so declining paints and
// blocks nothing while the shared join is in flight.
switch (readiness.kind) {
case 'loading':
case 'adapter-absent':
@@ -80,25 +83,27 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
}
return (
<section className={styles['page']} role="region" aria-labelledby="deepseek-onboarding-title">
<div className={styles['brand']} aria-hidden="true"><BrandWordmark size={24} /></div>
<h2
ref={titleRef}
id="deepseek-onboarding-title"
className={styles['title']}
tabIndex={-1}
>
{t('onboardingTitle')}
</h2>
<p className={styles['description']}>{t('onboardingDescription')}</p>
<div className={styles['actions']}>
<Button variant="ghost" className={styles['later']} onClick={complete}>
{t('onboardingLater')}
</Button>
<Button variant="primary" className={styles['primary']} onClick={openModels}>
{t('onboardingGoToSettings')}
</Button>
</div>
</section>
<OnboardingSurface>
<section className={styles['page']} role="region" aria-labelledby="deepseek-onboarding-title">
<div className={styles['brand']} aria-hidden="true"><BrandWordmark size={24} /></div>
<h2
ref={titleRef}
id="deepseek-onboarding-title"
className={styles['title']}
tabIndex={-1}
>
{t('onboardingTitle')}
</h2>
<p className={styles['description']}>{t('onboardingDescription')}</p>
<div className={styles['actions']}>
<Button variant="ghost" className={styles['later']} onClick={complete}>
{t('onboardingLater')}
</Button>
<Button variant="primary" className={styles['primary']} onClick={openModels}>
{t('onboardingGoToSettings')}
</Button>
</div>
</section>
</OnboardingSurface>
)
}

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/client/ui-primitives/README.md
README.md: 385730c94831d2fd4af83f9eca0f55941551c796
README.zh.md: b8a75dbffc6549f6294dfda5988c67d6569386c9
README.md: 7571cb48424b650a1aaa5222b33a3ee14faa69b4
README.zh.md: fa0c3f24023ec8c1eb77553bfe191801b6698687

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, DiffBlock, ReadBlock, SearchBlock, and WebBlock. Contract: api-contracts v3 §8.
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the OnboardingSurface first-run takeover (body-portaled mask + opaque stage that holds `#root` inert for exactly its own lifetime), the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, DiffBlock, ReadBlock, SearchBlock, and WebBlock. Contract: api-contracts v3 §8.
## Hover cards

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
纯 React 原子组件(零 cordisStateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族MessageText/MarkdownText/JsonBlock、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook把底部锚定的浮层高度收敛到锚点上方的视口空间并在 resize、scroll 与调用方提供的依赖变化时重新测量、TerminalBlock、DiffBlock、ReadBlock、SearchBlock以及 WebBlock。契约api-contracts v3 §8。
纯 React 原子组件(零 cordisStateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、OnboardingSurface 首次使用接管层portal 到 body 的遮罩加不透明展示层,在自身生命周期内保持 `#root``inert`)、markdown 家族MessageText/MarkdownText/JsonBlock、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook把底部锚定的浮层高度收敛到锚点上方的视口空间并在 resize、scroll 与调用方提供的依赖变化时重新测量、TerminalBlock、DiffBlock、ReadBlock、SearchBlock以及 WebBlock。契约api-contracts v3 §8。
## 悬浮卡片

View File

@@ -0,0 +1,29 @@
/* First-run stage: keep the product top bar visible, then let onboarding own
the complete workspace instead of presenting another settings modal. */
.onboardingOverlay {
position: fixed;
inset: 0;
z-index: 1100;
}
/* Mask */
.onboardingMask {
position: absolute;
left: 0px;
right: 0px;
top: 80px;
bottom: 0px;
background: rgba(0, 0, 0, 0.24);
/* Mask-blur */
backdrop-filter: blur(2px);
}
.onboardingStage {
position: absolute;
z-index: 1;
inset: 0;
display: flex;
justify-content: center;
overflow: hidden;
background: var(--dsw-alias-bg-layer-1);
}

View File

@@ -0,0 +1,34 @@
// OnboardingSurface: the full-viewport first-run takeover an onboarding step
// wraps its visible content in. The overlay portals to this document's body
// (the Modal precedent: ancestor stacking contexts cannot leave sticky page
// controls above the mask), and the surface holds `#root` inert for exactly
// its own lifetime — a step that renders null paints nothing and blocks
// nothing, so "should onboarding show right now" stays a plain render
// decision inside the step component.
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import css from './OnboardingSurface.module.css'
/**
* Render the onboarding takeover chrome (mask + opaque stage) around one
* step's content and keep the application root inert while mounted.
* @param props.children - the step's page content, centered on the stage.
* @returns the body-portaled overlay tree.
*/
export function OnboardingSurface({ children }: { children: ReactNode }) {
useEffect(() => {
const appRoot = document.getElementById('root')
if (appRoot === null) return
appRoot.inert = true
return () => { appRoot.inert = false }
}, [])
return createPortal((
<div className={css.onboardingOverlay} role="presentation">
<div className={css.onboardingMask} aria-hidden="true" />
<div className={css.onboardingStage}>{children}</div>
</div>
), document.body)
}

View File

@@ -13,6 +13,7 @@ export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx'
export { useAnchoredMaxHeight } from './useAnchoredMaxHeight.ts'
export { HoverCard } from './HoverCard.tsx'
export { Modal } from './Modal.tsx'
export { OnboardingSurface } from './OnboardingSurface.tsx'
export { RiskConfirmation } from './RiskConfirmation.tsx'
export type { RiskConfirmationProps } from './RiskConfirmation.tsx'
export { ConnectionBanner } from './ConnectionBanner.tsx'

View File

@@ -0,0 +1,47 @@
// @vitest-environment jsdom
import { cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { OnboardingSurface } from '@deepseek-ai/dsh-client-ui-primitives'
let appRoot: HTMLDivElement
beforeEach(() => {
appRoot = document.createElement('div')
appRoot.id = 'root'
document.body.appendChild(appRoot)
})
afterEach(() => {
cleanup()
appRoot.remove()
})
describe('OnboardingSurface', () => {
it('portals the overlay chrome to document.body around its content', () => {
const view = render(<OnboardingSurface><p>step content</p></OnboardingSurface>)
// Portaled: the overlay is a body child, not inside the render container.
expect(view.container.querySelector('[class*="onboardingOverlay"]')).toBeNull()
const overlay = document.body.querySelector('[class*="onboardingOverlay"]')
expect(overlay).not.toBeNull()
// The onboarding e2e pins the mask by class substring; the stage carries
// the content.
expect(overlay!.querySelector('[class*="onboardingMask"]')).not.toBeNull()
const stage = overlay!.querySelector('[class*="onboardingStage"]')
expect(stage).not.toBeNull()
expect(stage!.textContent).toBe('step content')
})
it('holds #root inert for exactly its own lifetime', () => {
const view = render(<OnboardingSurface>x</OnboardingSurface>)
expect(appRoot.inert).toBe(true)
view.unmount()
expect(appRoot.inert).toBe(false)
})
it('renders without an #root element (compositions that mount elsewhere)', () => {
appRoot.remove()
const view = render(<OnboardingSurface>x</OnboardingSurface>)
expect(document.body.querySelector('[class*="onboardingStage"]')!.textContent).toBe('x')
view.unmount()
})
})

View File

@@ -3,7 +3,7 @@
import { useCallback, useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { BrandWordmark, Button } from '@deepseek-ai/dsh-client-ui-primitives'
import { BrandWordmark, Button, OnboardingSurface } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { WelcomeNoticeState, WelcomeNoticeStore } from './welcome-store.ts'
import css from './WelcomeNotice.module.css'
@@ -55,6 +55,9 @@ export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
if (state.status === 'ready' && !state.acknowledged) titleRef.current?.focus()
}, [state.acknowledged, state.status])
// Null while the acknowledgement fact is still loading (or already given):
// the takeover chrome below is part of THIS render, so deciding not to
// show paints and blocks nothing.
if (state.status === 'idle' || state.status === 'loading' || state.acknowledged) return null
const acknowledge = async (): Promise<void> => {
@@ -62,25 +65,27 @@ export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
}
return (
<section className={css.page} role="region" aria-labelledby="welcome-notice-title">
<div className={css.brand} aria-hidden="true"><BrandWordmark size={24} /></div>
<h2 ref={titleRef} id="welcome-notice-title" className={css.title} tabIndex={-1}>{t('welcome.title')}</h2>
<p className={css.opening}>{t('welcome.paragraph.0')}</p>
<blockquote className={css.reflection}>{t('welcome.paragraph.1')}</blockquote>
<p className={css.feedback}>
{emphasizedFeedback(t('welcome.paragraph.2'), t('welcome.feedbackEmphasis'))}
</p>
{state.error === null ? null : <p className={css.error} role="alert">{t('welcome.error')}</p>}
<div className={css.footer}>
<Button
variant="primary"
className={css.primary}
disabled={state.status === 'saving'}
onClick={() => { void acknowledge() }}
>
{t('welcome.continue')}
</Button>
</div>
</section>
<OnboardingSurface>
<section className={css.page} role="region" aria-labelledby="welcome-notice-title">
<div className={css.brand} aria-hidden="true"><BrandWordmark size={24} /></div>
<h2 ref={titleRef} id="welcome-notice-title" className={css.title} tabIndex={-1}>{t('welcome.title')}</h2>
<p className={css.opening}>{t('welcome.paragraph.0')}</p>
<blockquote className={css.reflection}>{t('welcome.paragraph.1')}</blockquote>
<p className={css.feedback}>
{emphasizedFeedback(t('welcome.paragraph.2'), t('welcome.feedbackEmphasis'))}
</p>
{state.error === null ? null : <p className={css.error} role="alert">{t('welcome.error')}</p>}
<div className={css.footer}>
<Button
variant="primary"
className={css.primary}
disabled={state.status === 'saving'}
onClick={() => { void acknowledge() }}
>
{t('welcome.continue')}
</Button>
</div>
</section>
</OnboardingSurface>
)
}

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/client/ui-settings/README.md
README.md: de78d599b7833179339ceeb680fbd665b056bd83
README.zh.md: 8ae3bdf34f59ca03e4796c354df739aa9fe29bd9
README.md: 785f0417f00ec8eb1f8c9273b4d81f8ca5ca1810
README.zh.md: 8e7bd7325b78416345985ee25a56a5eb8b382478

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their actions, sections, rows, and conditional onboarding pages). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency).
The shell projects the onboarding ledger into ascending order and mounts exactly one page at a time in a body-level stage while marking the underlying app root inert. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, and mutations, so independently registered flows cannot stack and the shell does not become a second configuration fact source.
The shell projects the onboarding ledger into ascending order and mounts exactly one page at a time; the takeover chrome (body-level stage, mask, app-root `inert`) belongs to the step itself through ui-primitives' `OnboardingSurface`, so a mounted step still resolving its private facts renders null and neither paints nor blocks anything — the shell shows no empty stage while a step decides. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, mutations, and the surface wrap, so independently registered flows cannot stack and the shell does not become a second configuration fact source.
## Model Experience

View File

@@ -4,7 +4,7 @@
设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot`settings.trigger``settings.header``settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)和 `settings.onboarding`由各功能持有、显示在全视口展示层中的有序页面。外壳不自带文案所有文本都来自注册方ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的操作、分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。
外壳将首次使用引导记录按升序投影,在 body 层级的展示层中每次只挂载一个页面,同时将下层应用根节点标记为 `inert`。当前注册方会收到该条目的 id、`complete()``openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案变更操作均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。
外壳将首次使用引导记录按升序投影,每次只挂载一个页面接管界面框架body 层级的展示层、遮罩、应用根节点 `inert`)经 ui-primitives 的 `OnboardingSurface` 由步骤自身持有,因此已挂载但仍在判定私有事实的步骤渲染 null 时不绘制也不阻塞任何内容——步骤判定期间外壳不会露出空白展示层。当前注册方会收到该条目的 id、`complete()``openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案变更操作以及页面的外层包裹均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。
## 模型体验

View File

@@ -219,33 +219,3 @@
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* First-run stage: keep the product top bar visible, then let onboarding own
the complete workspace instead of presenting another settings modal. */
.onboardingOverlay {
position: fixed;
inset: 0;
z-index: 1100;
}
/* Mask */
.onboardingMask {
position: absolute;
left: 0px;
right: 0px;
top: 80px;
bottom: 0px;
background: rgba(0, 0, 0, 0.24);
/* Mask-blur */
backdrop-filter: blur(2px);
}
.onboardingStage {
position: absolute;
z-index: 1;
inset: 0;
display: flex;
justify-content: center;
overflow: hidden;
background: var(--dsw-alias-bg-layer-1);
}

View File

@@ -7,10 +7,11 @@
* aria-labelledby the title node; close: visually-hidden slot text). Modal
* open state and the active section id are component-local viewing state;
* the onboarding coordinator mounts exactly one ordered registrant while the
* sessions-derived empty-Hero fact is active.
* sessions-derived empty-Hero fact is active — the takeover chrome
* (OnboardingSurface) belongs to the step, so a mounted-but-deciding step
* paints nothing here.
*/
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
@@ -134,14 +135,6 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
})
}, [])
useEffect(() => {
if (onboardingStep === undefined) return
const appRoot = document.getElementById('root')
if (appRoot === null) return
appRoot.inert = true
return () => { appRoot.inert = false }
}, [onboardingStep])
return (
<>
<button
@@ -162,18 +155,15 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
onClose={close}
/>
)}
{onboardingStep !== undefined && createPortal((
<div className={css.onboardingOverlay} role="presentation">
<div className={css.onboardingMask} aria-hidden="true" />
<div className={css.onboardingStage}>
{renderSlot('settings.onboarding', {
stepId: onboardingStep.id,
complete: () => { completeOnboardingStep(onboardingStep.id) },
openSection,
}, { only: onboardingStep.id })}
</div>
</div>
), document.body)}
{/* The takeover chrome (OnboardingSurface: mask, opaque stage, `#root`
inert) lives inside the step component, wrapped around its visible
content — a step still deciding (private facts loading) renders
null, so nothing paints or blocks while it decides. */}
{onboardingStep !== undefined && renderSlot('settings.onboarding', {
stepId: onboardingStep.id,
complete: () => { completeOnboardingStep(onboardingStep.id) },
openSection,
}, { only: onboardingStep.id })}
</>
)
}

View File

@@ -57,7 +57,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* Root-scoped onboarding steps contributed by settings features. The
* shell mounts one ordered step at a time; the active registrant either
* completes itself or keeps ownership until the user completes its sole
* path. Registrants own readiness, copy, and dialog behavior.
* path. Registrants own readiness, copy, dialog behavior, AND the
* takeover chrome: a step wraps its visible content in the
* OnboardingSurface primitive (mask, opaque stage, `#root` inert) and
* renders null while its private facts are still loading — the shell
* paints no chrome of its own, so a mounted-but-deciding step shows and
* blocks nothing (the reload white-flash fix; a bare unwrapped step
* would render without mask or stage).
*/
'settings.onboarding': { kind: 'list'; scope: 'root'; owner: SettingsOnboardingOwnerProps }
}

View File

@@ -204,14 +204,19 @@ describe('SettingsPanel navigation', () => {
expect(inactive).toHaveLength(0)
})
it('makes the underlying application inert while onboarding owns the viewport', () => {
it('paints no takeover chrome of its own around the mounted step', () => {
// The chrome (mask, opaque stage, #root inert) belongs to the step via
// the OnboardingSurface primitive — a mounted-but-deciding step that
// renders null must show and block nothing (the reload white-flash fix;
// onboarding-surface.spec.tsx pins the primitive's half).
const appRoot = document.createElement('div')
appRoot.id = 'root'
document.body.append(appRoot)
const { view } = mount()
expect(appRoot.inert).toBe(true)
expect(view.container.querySelector('[class*="onboarding"]')).toBeNull()
expect(document.body.querySelector('[class*="onboarding"]')).toBeNull()
expect(appRoot.inert).not.toBe(true)
view.unmount()
expect(appRoot.inert).toBe(false)
appRoot.remove()
})

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/context/workspace-context/README.md
README.md: 7add269c7a1b38e9624cf6d0368151661bc72a6b
README.zh.md: 3456c5c36275e8e521b312b1b0b7e1dd102ca23c
README.md: 82aee27a8fbd6a1ab0f0860226b081e28ba72e6e
README.zh.md: 9d983f95f4e018cb8fe983d9862cf5f31f83c5f2

View File

@@ -6,7 +6,7 @@ Per-session workspace instruction loading for `AGENTS.md`-compatible files. The
## Lifecycle
The first eligible `agent/pre-step` of each live session composes the baseline. When the downstream decision enters a nonempty first-step batch, the plugin folds the baseline into that final batch right after the claimed prompt, so the direct prompt and the durable baseline enter step 1 and reach the first request together. A rejected or empty first-step decision leaves the baseline in the agent's `next-step` inbox for a later wakeup. The loader reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. If a previously queued workspace context is still pending, the plugin removes and replaces that exact inbox item instead of accumulating duplicates.
The first eligible `agent/pre-step` of each live session composes the baseline. When the downstream decision enters a nonempty first-step batch, the plugin folds the baseline into that final batch right after the claimed prompt, so the direct prompt and the durable baseline enter step 1 and reach the first request together. A rejected or empty first-step decision leaves the baseline in the agent's `next-step` inbox for a later wakeup. The loader reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. If a previously queued workspace context is still pending, the plugin removes and replaces that exact inbox item instead of accumulating duplicates. A resumed session retains one compatible visible baseline and appends only current-file transitions; a changed discovery, precedence, project-root, or budget identity instead folds one explicitly superseding complete baseline into the entering batch.
The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. Each configured candidate name is an independent scope in its directory: a newly present file is attached through the result's `additionalContexts`; a changed file appends a replacement; a file that disappears or becomes a per-directory duplicate of an earlier candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable.
@@ -48,11 +48,11 @@ The plugin owns the complete `<system-reminder>` framing, and every injected `us
## State And Refresh
Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; the complete startup or resume baseline also carries `baseline: true`. A matching durable `user/message` confirms a queued baseline and its candidate versions. An entering pre-step folds newly composed context into its final batch immediately after the claimed messages and removes the pending inbox copy; rejection keeps the current context queued. If a listener rewrites away a claimed workspace message without entering its replacement, a later boundary recomposes the current context. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. If the owning `step/end` arrives before a matching dynamic context reaches the log, the plugin clears that pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; a complete baseline also carries `baseline: true` and a `baselineIdentity` derived from normalized discovery, precedence, project-root, and budget configuration. A matching durable `user/message` confirms a queued baseline and its candidate versions. An entering pre-step folds newly composed context into its final batch immediately after the claimed messages and removes the pending inbox copy; rejection keeps the current context queued. If a listener rewrites away a claimed workspace message without entering its replacement, a later boundary recomposes the current context. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. If the owning `step/end` arrives before a matching dynamic context reaches the log, the plugin clears that pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter the source, pending state, and version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates only the provider cache.
The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface; the next successful filesystem touch re-adds an unchanged baseline scope after compaction, or appends its replacement or removal. The in-memory scope marker and provider-version cache only select and accelerate probes. A hot plugin remount retains a baseline only when its typed event remains visible, while rebuilding current scope and version tracking; otherwise it queues a current baseline. A resumed loop always recomposes the current baseline and also reconciles still-visible dynamic scopes at its first pre-step; an entering first request records that context in the same step. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop prepares its baseline.
The initial baseline event itself is not rewritten. Its typed changes remain authoritative only while that event is in the visible session surface. When compaction shadows the event, the next entering pre-step composes the current baseline and records it in the same request; a successful filesystem touch can instead re-add an unchanged baseline scope or append its replacement or removal. The in-memory scope marker and provider-version cache only select and accelerate probes. At the first pre-step after resume or hot remount, a compatible visible baseline is retained and compared with the files retained by the current complete rendering. Unchanged and budget-omitted files append nothing; offline additions, edits, removals, and files leaving the retained budget set append `set`, `replace`, or `remove` transitions. An incompatible visible baseline is superseded by one complete current baseline, including an explicit empty baseline when no candidate remains. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, when a resumed session reconciles its baseline, or when an entering pre-step restores a shadowed baseline.
## Configuration
@@ -83,7 +83,7 @@ Instruction content is read through `streamText()` under `maxSourceBytes`, even
#### What the model sees
At the first request of each loop instance, the model receives one durable user-role message containing the bounded user-global and project instruction chain in broad-to-specific order.
At the first request, derived history contains one durable user-role message with the bounded user-global and project instruction chain in broad-to-specific order. Resume reuses that message when its visible baseline is compatible.
##### Baseline instruction template
@@ -107,7 +107,7 @@ The rendered baseline is appended once and remains in derived history until comp
#### KV Cache effect
Append-only after the existing reusable prefix. A new or resumed instance may append a recomposed baseline, so instruction, precedence, cwd, candidate, or byte-budget changes affect cache reuse from that history position.
Append-only after the existing reusable prefix. Resume preserves reuse when the visible baseline identity is compatible; an incompatible identity appends a complete replacement, so discovery, precedence, project-root, or budget changes affect reuse only from that history position.
### Newly discovered scope context
@@ -162,7 +162,7 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam.
- **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, or when a resumed loop prepares its baseline.
- **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, when resume reconciles a visible baseline, or when an entering pre-step restores a shadowed baseline.
- **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; project scopes load `AGENTS.local.md`/`CLAUDE.local.md` overlays by default, but the user-global `$DSH_HOME` scope has no local overlay and other custom names require explicit candidate configuration.
- **Per-directory dedup is content-based** — sibling candidates collapse only when byte-identical after trimming leading and trailing whitespace; a `CLAUDE.md` that symlinks its sibling `AGENTS.md` resolves to the same content and collapses like any duplicate, while a distinct real copy that has drifted from `AGENTS.md` loads in full alongside it.
- **Symlinked instruction files are followed across the trust boundary** — a candidate whose final component is a symlink is resolved and its target loaded, so a cloned repository can surface off-tree file content as lower-authority workspace guidance (it never overrides system, developer, or direct user instructions). Confine `ctx.fs` with the filesystem policy gate or an OS sandbox when loading untrusted repositories.

View File

@@ -6,7 +6,7 @@
## 生命周期
每个实时会话第一次符合条件的 `agent/pre-step` 会组合基线。当下游决策让非空的第一步批次进入时,插件会将基线折入最终批次、紧随已领取的直接提示词之后,使直接提示词与持久基线一同进入步骤 1并共同抵达第一次请求。reject 或空的第一步决策会将基线留在 agent 的 `next-step` inbox等待后续唤醒。loader 先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。若之前排队的 workspace 上下文仍在等待,插件会删除并替换该确切 inbox 条目,而不会不断累积副本。
每个实时会话第一次符合条件的 `agent/pre-step` 会组合基线。当下游决策让非空的第一步批次进入时,插件会将基线折入最终批次、紧随已领取的直接提示词之后,使直接提示词与持久基线一同进入步骤 1并共同抵达第一次请求。reject 或空的第一步决策会将基线留在 agent 的 `next-step` inbox等待后续唤醒。loader 先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。若之前排队的 workspace 上下文仍在等待,插件会删除并替换该确切 inbox 条目,而不会不断累积副本。恢复后的会话会保留一条兼容的可见基线,并只追加当前文件的转换;如果发现、优先级、项目根目录或预算标识发生变化,则会将一条明确取代旧基线的完整基线折入进入步骤的批次。
该插件还会监听 `tools/post-execute` 中成功的第一方 `read``write``edit` 调用。每次 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope新出现的文件通过结果的 `additionalContexts` 附加;已改变文件追加替换;文件消失或成为同一目录中较早候选文件的重复项时,追加移除通知。原生调用与 Code Mode 子分派共享该路径:`run_code` 将每个嵌套上下文延迟到外层结果,因此 loop 仍会在工具调用/结果相邻关系完成后追加更新。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell解析任意 shell 语法也不可靠。
@@ -48,11 +48,11 @@ These instructions apply to work under `packages/app`. Use them as guidance when
## 状态与刷新
模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整的启动或恢复基线还会携带 `baseline: true`。匹配的持久 `user/message` 会确认已排队基线及其候选版本。进入步骤的 pre-step 会把新组合的上下文折入最终批次,位置紧随已领取的消息,并移除 inbox 中仍待处理的副本reject 则让当前上下文继续排队。若监听器改写掉已领取的 workspace 消息,又没有让替代消息进入,后续边界会重新组合当前上下文。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。如果所属 `step/end` 在匹配的动态上下文进入日志之前到达,插件会清除该 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。
模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整基线还会携带 `baseline: true`,以及从规范化的发现、优先级、项目根目录和预算配置派生的 `baselineIdentity`。匹配的持久 `user/message` 会确认已排队基线及其候选版本。进入步骤的 pre-step 会把新组合的上下文折入最终批次,位置紧随已领取的消息,并移除 inbox 中仍待处理的副本reject 则让当前上下文继续排队。若监听器改写掉已领取的 workspace 消息,又没有让替代消息进入,后续边界会重新组合当前上下文。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。如果所属 `step/end` 在匹配的动态上下文进入日志之前到达,插件会清除该 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。
路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1也是每目录重复 key因此较早候选文件与某个未更改文件的内容收敛后后者仍可被移除。恢复可行因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩compaction会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。
初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态;下一次成功的文件系统 touch 会在压缩后重新添加未变的基线 scope或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。插件热重挂只有在其带类型的事件仍然可见时才保留基线,同时会重建当前 scope 与版本跟踪状态;否则会排队当前基线。恢复的 loop 始终在第一次 pre-step 重新组合当前基线,并对账仍可见的动态 scope首次请求若进入步骤就会在同一步骤记录该上下文。没有文件 watcher因此磁盘变更会在下一次成功 `read``write``edit` touch 时可见,也会在恢复 loop 准备基线时可见。
初始基线事件自身不会被改写。其带类型的变更仅在该事件仍位于可见会话表层时才是权威状态。当压缩遮蔽该事件时,下一次进入步骤的 pre-step 会组合当前基线,并在同一请求中记录它;也可以改由一次成功的文件系统 touch 重新添加未变的基线 scope或追加其替换或移除。内存中的 scope 标记和提供方版本 cache 只负责选择探测对象并加速探测。恢复或插件热重挂后的第一次 pre-step 会保留兼容的可见基线并将它与当前完整渲染所保留的文件进行比较。未变化和被预算省略的文件不追加任何内容agent 离线期间新增、编辑、移除或不再属于预算保留集的文件会追加 `set``replace``remove` 转换。不兼容的可见基线会被一条完整的当前基线取代;如果没有候选文件,这条当前基线会是显式空基线。没有文件 watcher因此磁盘变更会在下一次成功 `read``write``edit` touch 时可见,也会在恢复后的会话对账其基线时,或进入步骤的 pre-step 恢复被遮蔽的基线时可见。
## 配置
@@ -83,7 +83,7 @@ export interface Config {
#### 模型看到的内容
在每个 loop 实例的第一请求中,模型会收到一条持久 user 角色消息,其中按从宽泛到具体的顺序包含有界用户全局指令与项目指令链。
第一请求的派生历史中包含一条持久 user 角色消息,其中按从宽泛到具体的顺序包含有界用户全局指令与项目指令链。可见基线兼容时,恢复会复用该消息。
##### 基线指令模板
@@ -107,7 +107,7 @@ Instructions from: AGENTS.md
#### KV Cache 影响
仅追加,位于现有可复用前缀之后。新建或恢复的实例可能追加重新组合的基线,因此指令、优先级、cwd、候选文件或字节预算变更会从该历史位置起影响缓存复用。
仅追加,位于现有可复用前缀之后。可见基线标识兼容时,恢复会保持复用;不兼容的标识会追加一条完整的替代基线,因此发现、优先级、项目根目录或预算变更会从该历史位置起影响复用。
### 新发现的 scope 上下文
@@ -162,7 +162,7 @@ The previously loaded instructions from this file no longer apply.
## 已知限制与暂缓事项
- **发现跟随结构化 fs 工具,而非 shell 导航**:更改目录的 `bash` 命令不会触发嵌套指令发现,因为 shell 语法与每次调用 shell 状态不是可靠的文件系统 seam。
- **刷新由 touch 驱动**:没有 watcher外部编辑会在下一次成功的第一方 `read``write``edit`可见,也会在恢复 loop 准备基线时可见。
- **刷新由 touch 驱动**:没有 watcher外部编辑会在下一次成功的第一方 `read``write``edit`、恢复过程对账可见基线时,或进入步骤的 pre-step 恢复被遮蔽的基线时可见。
- **候选语义有意保持简单**:不解释小写名称、`.claude/rules/``@path` import项目 scope 默认加载 `AGENTS.local.md``CLAUDE.local.md` overlay但用户全局 `$DSH_HOME` scope 没有本地 overlay其他自定义名称需要显式候选配置。
- **每目录去重基于内容**:只有在去除首尾空白后字节完全一致时,才折叠同级候选文件。`CLAUDE.md` 若 symlink 到同级 `AGENTS.md`,会解析为相同内容,并像任何重复项一样折叠;从 `AGENTS.md` 漂移的独立实体副本则会与它一起完整加载。
- **Symlink 指令文件会跨越信任边界跟随**:最终组件是 symlink 的候选文件会被解析并加载其目标,因此克隆仓库可以将树外文件内容呈现为较低优先级的工作区指引(它绝不会覆盖 system、developer 或用户直接下达的指令)。加载不受信任仓库时,请用文件系统策略门禁或 OS 沙箱限制 `ctx.fs`

View File

@@ -4,6 +4,7 @@
* @module @deepseek-ai/dsh-workspace-context/config
*/
import { relative } from 'node:path'
import z from 'schemastery'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
@@ -58,6 +59,28 @@ export interface ResolvedConfig extends ResolvedDiscoveryConfig {
maxSourceBytes: number
}
/**
* Identify the discovery, precedence, and budget semantics of one baseline.
* @param config - normalized plugin configuration.
* @param cwd - absolute session working directory.
* @param projectRoot - project root selected for the current baseline.
* @returns stable serialized identity for compatibility checks on resume.
*/
export function workspaceBaselineIdentity(
config: ResolvedConfig,
cwd: string,
projectRoot: string,
): string {
return JSON.stringify({
projectRoot: relative(cwd, projectRoot),
projectRootMarkers: config.projectRootMarkers,
maxBytes: config.maxBytes,
maxSourceBytes: config.maxSourceBytes,
instructionFileCandidates: config.instructionFileCandidates,
localInstructionFileCandidates: config.localInstructionFileCandidates,
})
}
/**
* Resolve defaults, the harness home, and valid same-directory candidates.
* @param config - user-facing plugin configuration.

View File

@@ -46,17 +46,22 @@ interface DiscoverOptions {
projectRootMarkers?: string[]
instructionFileCandidates?: string[]
localInstructionFileCandidates?: string[]
projectRoot?: string
signal?: AbortSignal
}
interface LoadOptions extends DiscoverOptions {
maxBytes: number
maxSourceBytes?: number
replacePreviousBaseline?: boolean
}
/** Rendered baseline plus the files that survived byte budgeting. */
/** Rendered baseline plus the successfully read and byte-budget-retained files. */
export interface RenderedInstructionSet {
rendered: RenderedWorkspaceContext
/** Successfully read candidates before content deduplication and byte budgeting. */
observed: LoadedInstructionFile[]
/** Candidates retained by content deduplication and byte budgeting. */
included: LoadedInstructionFile[]
}
@@ -286,7 +291,8 @@ async function discoverInstructionFiles(
}
const cwd = resolve(options.cwd)
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal)
const projectRoot = options.projectRoot
?? await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal)
for (const dir of ancestorChain(projectRoot, cwd)) {
for (const candidates of [config.instructionFileCandidates, config.localInstructionFileCandidates]) {
for (const file of await allExistingInstructionFiles(dir, projectRoot, candidates, fileSystem, options.signal)) {
@@ -389,7 +395,7 @@ export async function loadBaselineInstructions(
* Load a baseline together with the files retained after rendering.
* @param options - discovery, source-size, byte-budget, and cancellation configuration.
* @param fileSystem - optional provider used instead of host filesystem reads.
* @returns rendered context and retained files, or undefined when empty or disabled.
* @returns rendered context and retained files, an explicit empty replacement set, or undefined when empty or disabled.
*/
export async function loadBaselineInstructionSet(
options: LoadOptions,
@@ -412,10 +418,29 @@ export async function loadBaselineInstructionSet(
}
}
const deduped = dedupInstructionFilesByDirectory(loaded)
if (deduped.length === 0) return undefined
const rendered = renderWorkspaceContext(deduped, { maxBytes: config.maxBytes })
if (deduped.length === 0) {
if (options.replacePreviousBaseline !== true) return undefined
return {
rendered: renderWorkspaceContext([], {
maxBytes: config.maxBytes,
replacePreviousBaseline: true,
}),
observed: [],
included: [],
}
}
const rendered = renderWorkspaceContext(deduped, {
maxBytes: config.maxBytes,
...options.replacePreviousBaseline === undefined
? {}
: { replacePreviousBaseline: options.replacePreviousBaseline },
})
const omitted = new Set(rendered.omitted.map(file => file.absolutePath))
return { rendered, included: deduped.filter(file => !omitted.has(file.absolutePath)) }
return {
rendered,
observed: loaded,
included: deduped.filter(file => !omitted.has(file.absolutePath)),
}
}
/**

View File

@@ -13,10 +13,10 @@ import type { Context } from 'cordis'
import { isDeepStrictEqual } from 'node:util'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import { Config, resolveConfig, type ResolvedConfig } from './config.ts'
import { loadBaselineInstructionSet } from './files.ts'
import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts'
import { findProjectRoot, loadBaselineInstructionSet } from './files.ts'
import {
applyInstructionVersionUpdates,
baselineInstructionState,
@@ -24,6 +24,7 @@ import {
reconcileInstructionContext,
workspaceContextMessage,
type InstructionVersionCache,
type WorkspaceInstructionSource,
} from './state.ts'
import type { WorkspaceInstructionChange } from './render.ts'
@@ -39,13 +40,22 @@ export type {
export { renderWorkspaceContext } from './render.ts'
export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts'
function hasVisibleBaseline(agent: Agent): boolean {
return agent.session.surface.nodes.some((seq) => {
function visibleBaselineSource(
agent: Agent,
authorityMessages: readonly UserMessage[],
): WorkspaceInstructionSource | undefined {
for (const message of authorityMessages.toReversed()) {
if (message.source.kind === 'workspace-instructions' && message.source.baseline === true) {
return message.source
}
}
for (const seq of agent.session.surface.nodes.toReversed()) {
const event = agent.session.events[seq]
return event?.type === 'user/message'
if (event?.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions'
&& event.data.source.baseline === true
})
&& event.data.source.baseline === true) return event.data.source
}
return undefined
}
function isWorkspaceContext(message: UserMessage): boolean {
@@ -70,6 +80,10 @@ function filePathFromExecution(exec: ToolExecution): string | undefined {
export function apply(ctx: Context, config: Config): void {
const resolved: ResolvedConfig = resolveConfig(config)
const instructionVersions: InstructionVersionCache = new WeakMap()
const baselinePreparations = new WeakMap<Session, {
identity: string
excludedScopes: ReadonlySet<string>
}>()
const projectionLifecycle = new AbortController()
ctx.effect(
() => () => {
@@ -99,11 +113,20 @@ export function apply(ctx: Context, config: Config): void {
const changes: WorkspaceInstructionChange[] = []
let desiredBaseline = false
const authorityMessages = [...claimed]
const baselinePresent = hasVisibleBaseline(agent) || claimed.some(message =>
message.source.kind === 'workspace-instructions' && message.source.baseline === true)
if (!baselinePresent) {
/* v8 ignore next -- normal agents carry an absolute session cwd. */
const cwd = agent.session.header.cwd ?? process.cwd()
/* v8 ignore next -- normal agents carry an absolute session cwd. */
const cwd = agent.session.header.cwd ?? process.cwd()
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, signal)
const identity = workspaceBaselineIdentity(resolved, cwd, projectRoot)
const visibleBaseline = visibleBaselineSource(agent, authorityMessages)
const baselinePresent = visibleBaseline !== undefined
const keepVisibleBaseline = visibleBaseline?.baselineIdentity === identity
const prepared = baselinePreparations.get(agent.session)
let excludedBaselineScopes = keepVisibleBaseline && prepared?.identity === identity
? prepared.excludedScopes
: undefined
let nextPreparation: { identity: string; excludedScopes: ReadonlySet<string> } | undefined
if (!baselinePresent || !keepVisibleBaseline || excludedBaselineScopes === undefined) {
const replacePreviousBaseline = baselinePresent && !keepVisibleBaseline
const instructions = await loadBaselineInstructionSet({
cwd,
dshHome: resolved.dshHome,
@@ -112,18 +135,45 @@ export function apply(ctx: Context, config: Config): void {
maxSourceBytes: resolved.maxSourceBytes,
instructionFileCandidates: resolved.instructionFileCandidates,
localInstructionFileCandidates: resolved.localInstructionFileCandidates,
projectRoot,
replacePreviousBaseline,
signal,
}, fileSystem)
const baseline = baselineInstructionState(instructions?.included ?? [])
const observedBaseline = baselineInstructionState(instructions?.observed ?? [])
const excludedScopes = new Set(observedBaseline.changes.keys())
for (const scope of baseline.changes.keys()) excludedScopes.delete(scope)
excludedBaselineScopes = excludedScopes
nextPreparation = { identity, excludedScopes }
let versionStates = instructionVersions.get(agent.session)
if (versionStates === undefined && baseline.versions.size > 0) {
versionStates = new Map()
instructionVersions.set(agent.session, versionStates)
}
for (const [scope, state] of baseline.versions) versionStates?.set(scope, state)
if (instructions !== undefined && instructions.rendered.text.length > 0) {
content.push(...workspaceContextMessage(instructions.rendered.text).content)
changes.push(...baseline.changes.values())
if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) {
const baselineContent = workspaceContextMessage(instructions.rendered.text).content
content.push(...baselineContent)
const replacementScopes = new Set(baseline.changes.keys())
const replacementRemovals = replacePreviousBaseline
? visibleBaseline.changes.flatMap(change => (
change.action === 'remove' || replacementScopes.has(change.scope)
? []
: [{ action: 'remove' as const, scope: change.scope, path: change.path }]
))
: []
const baselineChanges = [...replacementRemovals, ...baseline.changes.values()]
changes.push(...baselineChanges)
authorityMessages.push(createUserMessage({
content: baselineContent,
source: {
kind: 'workspace-instructions',
form: 'instructions',
baseline: true,
baselineIdentity: identity,
changes: baselineChanges,
},
}))
desiredBaseline = true
}
}
@@ -132,7 +182,15 @@ export function apply(ctx: Context, config: Config): void {
resolved,
instructionVersions,
fileSystem,
{ authorityMessages, scopeMessages: pending, includeBaselineScopes: baselinePresent, touchedPaths, signal },
{
authorityMessages,
scopeMessages: pending,
includeBaselineScopes: keepVisibleBaseline,
...keepVisibleBaseline ? { excludedBaselineScopes } : {},
touchedPaths,
projectRoot,
signal,
},
)
if (update !== undefined) {
content.push(...update.context.content)
@@ -142,6 +200,7 @@ export function apply(ctx: Context, config: Config): void {
}
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
}
if (nextPreparation !== undefined) baselinePreparations.set(agent.session, nextPreparation)
if (content.length === 0) return undefined
return createUserMessage({
content,
@@ -149,6 +208,7 @@ export function apply(ctx: Context, config: Config): void {
kind: 'workspace-instructions',
form: 'instructions',
...desiredBaseline ? { baseline: true } : {},
...desiredBaseline ? { baselineIdentity: identity } : {},
changes,
},
})

View File

@@ -12,6 +12,10 @@ const SYSTEM_REMINDER_CLOSE = '</system-reminder>'
const WORKSPACE_CONTEXT_INTRO = 'The following workspace instructions may be relevant to your work. '
+ 'Use them as guidance when applicable. More specific instructions take precedence over broader ones. '
+ 'They do not override system, developer, or direct user instructions.'
const REPLACEMENT_WORKSPACE_CONTEXT_INTRO = 'This complete workspace instruction baseline replaces all earlier workspace instruction baselines. '
+ WORKSPACE_CONTEXT_INTRO
const EMPTY_REPLACEMENT_WORKSPACE_CONTEXT_INTRO = 'This complete workspace instruction baseline replaces all earlier workspace instruction baselines. '
+ 'No workspace instructions are currently active.'
const COMPACT_WORKSPACE_CONTEXT_INTRO = 'Workspace instructions were omitted or truncated to fit the configured byte budget.'
/** Byte-accounting record for one truncated instruction file. */
@@ -294,12 +298,20 @@ function renderInstructionContext(
/**
* Render the baseline instruction chain with deterministic precedence budgeting.
* @param files - loaded files ordered from broadest to most specific.
* @param options - required rendering byte budget.
* @param options - rendering byte budget and whether this baseline supersedes a visible predecessor.
* @returns bounded baseline prompt text and budget diagnostics.
*/
export function renderWorkspaceContext(
files: LoadedInstructionFile[],
options: { maxBytes: number },
options: { maxBytes: number; replacePreviousBaseline?: boolean },
): RenderedWorkspaceContext {
return renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE)
const style = options.replacePreviousBaseline === true
? {
...BASELINE_RENDER_STYLE,
intro: files.length === 0
? EMPTY_REPLACEMENT_WORKSPACE_CONTEXT_INTRO
: REPLACEMENT_WORKSPACE_CONTEXT_INTRO,
}
: BASELINE_RENDER_STYLE
return renderInstructionContext(files, options.maxBytes, style)
}

View File

@@ -40,6 +40,8 @@ export interface WorkspaceInstructionSource {
form: 'instructions'
/** Marks the complete startup/resume baseline rather than a later delta. */
baseline?: true
/** Discovery, precedence, and budget identity used to validate a resumed baseline. */
baselineIdentity?: string
changes: WorkspaceInstructionChange[]
}
@@ -251,6 +253,8 @@ export async function reconcileInstructionContext(
scopeMessages: readonly UserMessage[]
touchedPaths: readonly string[]
includeBaselineScopes: boolean
excludedBaselineScopes?: ReadonlySet<string>
projectRoot?: string
signal?: AbortSignal
},
): Promise<ReconciledInstructionContext | undefined> {
@@ -260,7 +264,8 @@ export async function reconcileInstructionContext(
const cwd = session.header.cwd ?? process.cwd()
// TODO(frozen-project-root): retain the baseline root for the loop instance;
// recomputing it after marker edits reinterprets the existing relative scope keys.
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
const projectRoot = options.projectRoot
?? await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
const scopes = new Set<string>()
const baselineScopes = new Set<string>()
const addDirScopes = (target: Set<string>, directory: string): void => {
@@ -324,11 +329,23 @@ export async function reconcileInstructionContext(
else directoryScopes.push(scope)
}
for (const [directory, directoryScopes] of scopesByDirectory) {
const probedScopes: string[] = []
for (const scope of directoryScopes) {
if (options.excludedBaselineScopes !== undefined
&& baselineScopes.has(scope)
&& options.excludedBaselineScopes.has(scope)) {
const previous = effective.get(scope)
if (previous === undefined || previous.action === 'remove') versions.delete(scope)
else pushRemoval(scope, previous.path)
} else {
probedScopes.push(scope)
}
}
const itemStart = items.length
const versionUpdateStart = versionUpdates.length
const addedAbsolutePaths: string[] = []
const priorVersions = new Map(directoryScopes.map(scope => [scope, versions.get(scope)]))
for (const scope of directoryScopes) {
const priorVersions = new Map(probedScopes.map(scope => [scope, versions.get(scope)]))
for (const scope of probedScopes) {
const previous = effective.get(scope)
const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
if (probe.kind === 'unavailable') {

View File

@@ -955,6 +955,267 @@ describe('workspace context request injection', () => {
}
})
it('retains one visible baseline across repeated session resumes', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const original = stubAgent(root)
await composeBaselinePrefix(ctx, original)
const firstResume = stubAgent(root, [...original.session.events])
await composeBaselinePrefix(ctx, firstResume)
const secondResume = stubAgent(root, [...firstResume.session.events])
await composeBaselinePrefix(ctx, secondResume)
expect(baselineEvents(firstResume)).toHaveLength(1)
expect(baselineEvents(secondResume)).toHaveLength(1)
expect(secondResume.session.events.filter(event => event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions')).toHaveLength(1)
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('preserves a visible baseline when its source is unavailable during resume', async () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' })
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const original = stubAgent(root)
await composeBaselinePrefix(ctx, original)
fs.throwOnStat.add(join(root, 'AGENTS.md'))
const resumed = stubAgent(root, [...original.session.events])
await composeBaselinePrefix(ctx, resumed)
expect(baselineEvents(resumed)).toHaveLength(1)
expect(resumed.session.events.filter(event => event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions')).toHaveLength(1)
} finally {
await ctx.fiber.dispose()
await rm(dirname(root), { recursive: true, force: true })
await rm(dirname(home), { recursive: true, force: true })
}
})
it('does not promote an unchanged budget-omitted baseline file during resume', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
const cwd = join(root, 'pkg')
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'root '.repeat(200))
await write(join(cwd, 'AGENTS.md'), 'package rule')
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 })
const original = stubAgent(cwd)
await composeBaselinePrefix(ctx, original)
const firstResume = stubAgent(cwd, [...original.session.events])
await composeBaselinePrefix(ctx, firstResume)
const secondResume = stubAgent(cwd, [...firstResume.session.events])
await composeBaselinePrefix(ctx, secondResume)
expect(baselineEvents(secondResume)).toHaveLength(1)
expect(secondResume.session.events.filter(event => event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions')).toHaveLength(1)
expect(blocksText(secondResume.session.deriveMessages()[0]?.content)).toContain('omitted AGENTS.md')
expect(blocksText(secondResume.session.deriveMessages()[0]?.content)).not.toContain('root root')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('removes a previously visible baseline file that leaves the retained budget set', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
const cwd = join(root, 'pkg')
await mkdir(join(root, '.git'), { recursive: true })
await mkdir(cwd, { recursive: true })
await write(join(root, 'AGENTS.md'), 'root '.repeat(200))
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 })
const original = stubAgent(cwd)
await composeBaselinePrefix(ctx, original)
await write(join(cwd, 'AGENTS.md'), 'package rule')
const resumed = stubAgent(cwd, [...original.session.events])
await composeBaselinePrefix(ctx, resumed)
expect(baselineEvents(resumed)).toHaveLength(1)
const update = resumed.session.events.findLast(event => event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions'
&& event.data.source.baseline !== true)
expect(update?.type === 'user/message' && update.data.source.kind === 'workspace-instructions'
? update.data.source.changes
: undefined).toMatchObject([
{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' },
{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') },
])
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('recomposes the baseline when candidate precedence changes between resumes', async () => {
const root = await tempRepo()
const home = await tempRepo()
const originalCtx = new Context()
const resumedCtx = new Context()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'agents rule')
await write(join(root, 'CLAUDE.md'), 'claude rule')
await mountWorkspaceContext(originalCtx, { dshHome: home, maxBytes: 65536 })
const original = stubAgent(root)
await composeBaselinePrefix(originalCtx, original)
await mountWorkspaceContext(resumedCtx, {
dshHome: home,
maxBytes: 65536,
instructionFileCandidates: ['CLAUDE.md', 'AGENTS.md'],
})
const resumed = stubAgent(root, [...original.session.events])
await composeBaselinePrefix(resumedCtx, resumed)
const baselines = baselineEvents(resumed)
expect(baselines).toHaveLength(2)
const replacement = baselines.at(-1)
const replacementText = replacement?.type === 'user/message'
? blocksText(replacement.data.content)
: ''
expect(replacementText).toContain('replaces all earlier workspace instruction baselines')
expect(replacementText.indexOf('Instructions from: CLAUDE.md'))
.toBeLessThan(replacementText.indexOf('Instructions from: AGENTS.md'))
const baselineIdentities = baselines.flatMap(event => event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions'
&& typeof event.data.source.baselineIdentity === 'string'
? [event.data.source.baselineIdentity]
: [])
expect(new Set(baselineIdentities).size).toBe(2)
const repeated = stubAgent(root, [...resumed.session.events])
await composeBaselinePrefix(resumedCtx, repeated)
expect(baselineEvents(repeated)).toHaveLength(2)
} finally {
await originalCtx.fiber.dispose()
await resumedCtx.fiber.dispose()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('tombstones candidates removed across successive baseline configurations', async () => {
const root = await tempRepo()
const home = await tempRepo()
const agentsCtx = new Context()
const claudeCtx = new Context()
const restoredCtx = new Context()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'agents rule')
await write(join(root, 'CLAUDE.md'), 'claude rule')
await mountWorkspaceContext(agentsCtx, {
dshHome: home,
maxBytes: 65536,
instructionFileCandidates: ['AGENTS.md'],
})
const original = stubAgent(root)
await composeBaselinePrefix(agentsCtx, original)
await mountWorkspaceContext(claudeCtx, {
dshHome: home,
maxBytes: 65536,
instructionFileCandidates: ['CLAUDE.md'],
})
const claudeResume = stubAgent(root, [...original.session.events])
await composeBaselinePrefix(claudeCtx, claudeResume)
const claudeBaseline = baselineEvents(claudeResume).at(-1)
expect(claudeBaseline?.type === 'user/message' && claudeBaseline.data.source.kind === 'workspace-instructions'
? claudeBaseline.data.source.changes
: undefined).toMatchObject([
{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' },
{ action: 'set', scope: sk('.', 'CLAUDE.md'), path: 'CLAUDE.md' },
])
await mountWorkspaceContext(restoredCtx, {
dshHome: home,
maxBytes: 65536,
instructionFileCandidates: ['AGENTS.md'],
})
const restored = stubAgent(root, [...claudeResume.session.events])
await composeBaselinePrefix(restoredCtx, restored)
const restoredBaseline = baselineEvents(restored).at(-1)
expect(restoredBaseline?.type === 'user/message' && restoredBaseline.data.source.kind === 'workspace-instructions'
? restoredBaseline.data.source.changes
: undefined).toMatchObject([
{ action: 'remove', scope: sk('.', 'CLAUDE.md'), path: 'CLAUDE.md' },
{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' },
])
} finally {
await agentsCtx.fiber.dispose()
await claudeCtx.fiber.dispose()
await restoredCtx.fiber.dispose()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('supersedes an incompatible visible baseline when no current candidate exists', async () => {
const root = await tempRepo()
const home = await tempRepo()
const originalCtx = new Context()
const resumedCtx = new Context()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'agents rule')
await mountWorkspaceContext(originalCtx, { dshHome: home, maxBytes: 65536 })
const original = stubAgent(root)
await composeBaselinePrefix(originalCtx, original)
await mountWorkspaceContext(resumedCtx, {
dshHome: home,
maxBytes: 65536,
instructionFileCandidates: ['POLICY.md'],
})
const resumed = stubAgent(root, [...original.session.events])
await composeBaselinePrefix(resumedCtx, resumed)
const baselines = baselineEvents(resumed)
expect(baselines).toHaveLength(2)
const replacement = baselines.at(-1)
expect(replacement?.type === 'user/message' ? blocksText(replacement.data.content) : '')
.toContain('No workspace instructions are currently active.')
expect(replacement?.type === 'user/message' && replacement.data.source.kind === 'workspace-instructions'
? replacement.data.source.changes
: undefined).toMatchObject([
{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' },
])
const repeated = stubAgent(root, [...resumed.session.events])
await composeBaselinePrefix(resumedCtx, repeated)
expect(baselineEvents(repeated)).toHaveLength(2)
} finally {
await originalCtx.fiber.dispose()
await resumedCtx.fiber.dispose()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('reuses an inserted but unadmitted baseline after session recovery and plugin reload', async () => {
const root = await tempRepo()
const home = await tempRepo()
@@ -1323,7 +1584,50 @@ describe('workspace context request injection', () => {
}
})
it('recomposes the baseline from current files when a resumed session edited it offline', async () => {
it('folds a compacted baseline into the next entering pre-step before another filesystem touch', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
await mkdir(join(root, '.git'), { recursive: true })
await write(join(root, 'AGENTS.md'), 'first post-compaction request rule')
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
await composeBaselinePrefix(ctx, agent)
const baseline = baselineEvents(agent)[0]
expect(baseline).toBeDefined()
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'compacted summary' }],
source: { kind: 'plugin', plugin: 'compact' },
}), {
surfaceOp: { op: 'replace', start: baseline!.seq, end: baseline!.seq },
sourceEventSeqs: [baseline!.seq],
})
const prompt = createUserMessage({
content: [{ type: 'text', text: 'continue after compaction' }],
source: { kind: 'user' },
})
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step',
{ messages: [prompt], turn: 2, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve({ kind: 'enter' as const, messages: [prompt] }),
)
if (decision.kind !== 'enter') throw new Error('post-compaction request was rejected')
expect(decision.messages).toHaveLength(2)
expect(decision.messages[0]).toBe(prompt)
expect(decision.messages[1]?.source).toMatchObject({ kind: 'workspace-instructions', baseline: true })
expect(blocksText(decision.messages[1]?.content)).toContain('first post-compaction request rule')
expect(agent.inbox.nextStep).toEqual([])
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('appends a replacement transition when a resumed session edited its baseline offline', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -1334,13 +1638,8 @@ describe('workspace context request injection', () => {
const original = stubAgent(root)
await composeBaselinePrefix(ctx, original)
// Offline edit to the baseline file, then resume on a fresh session whose
// seeded log already carries the original baseline. A resumed session is
// registered after this mount's apply(), so the remount guard never seeds
// it: its first step re-composes a fresh baseline from current files,
// reflecting the offline edit before the first resumed request. The old
// baseline stays in history unmutated (note: resume without mutating an
// earlier history event).
// The first resumed pre-step retains the compatible visible baseline and
// appends only the offline file transition needed to reach current state.
await write(join(root, 'AGENTS.md'), 'new root rule after offline edit')
const resumed = stubAgent(root, [...original.session.events])
@@ -1352,6 +1651,9 @@ describe('workspace context request injection', () => {
expect(baselines).toHaveLength(1)
const latest = resumed.session.events.findLast(event =>
event.type === 'user/message' && event.data.source.kind === 'workspace-instructions')
expect(latest?.type === 'user/message' ? latest.data.source : undefined).toMatchObject({
changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
expect(latest?.type === 'user/message' && blocksText(latest.data.content))
.toContain('new root rule after offline edit')
const original0 = baselines[0]
@@ -2756,6 +3058,80 @@ describe('dynamic nested workspace context injection', () => {
}
})
it('ignores an unavailable scope whose visible state is already removed', async () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.throwOnStat.add(join(root, 'pkg/AGENTS.md'))
const agent = stubAgent(root)
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'removed nested instructions' }],
source: {
kind: 'workspace-instructions',
form: 'instructions',
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
},
}), { surfaceOp: 'append' })
const resolved = resolveConfig({
dshHome: home,
maxBytes: 65536,
instructionFileCandidates: ['AGENTS.md'],
localInstructionFileCandidates: [],
})
const result = await reconcileInstructionContext(agent, resolved, new WeakMap(), fs, {
authorityMessages: [],
scopeMessages: [],
touchedPaths: [],
includeBaselineScopes: false,
signal: testToolSignal,
})
expect(result).toBeUndefined()
} finally {
await ctx.fiber.dispose()
await rm(dirname(root), { recursive: true, force: true })
await rm(dirname(home), { recursive: true, force: true })
}
})
it('loads one transition when user-global and project scopes resolve to the same file', async () => {
const root = join(await tempRepo(), 'virtual-repo')
const ctx = new Context()
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'shared rule' })
const agent = stubAgent(root)
const resolved = resolveConfig({
dshHome: root,
maxBytes: 65536,
instructionFileCandidates: ['AGENTS.md'],
localInstructionFileCandidates: [],
})
const result = await reconcileInstructionContext(agent, resolved, new WeakMap(), fs, {
authorityMessages: [],
scopeMessages: [],
touchedPaths: [],
includeBaselineScopes: true,
signal: testToolSignal,
})
expect(result?.context.source).toMatchObject({
changes: [{ action: 'set', scope: sk(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE) }],
})
} finally {
await ctx.fiber.dispose()
await rm(dirname(root), { recursive: true, force: true })
}
})
it('removes a previously rendered sibling once its content becomes a duplicate of an earlier candidate', async () => {
const root = await tempRepo()
const home = await tempRepo()

View File

@@ -2849,7 +2849,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SubprocessSpawnSpec',
declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdio: SubprocessStdio;\n graceMs: number;\n signal?: AbortSignal | undefined;\n env?: Record<string, string> | undefined;\n}',
declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdio: SubprocessStdio;\n graceMs: number;\n signal?: AbortSignal | undefined;\n env?: NodeJS.ProcessEnv | undefined;\n}',
},
{
name: 'SubprocessStdinMode',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/tool-fs-search/README.md
README.md: 78ffa069e56da5fc987913acf761eb5c6ae15b1a
README.zh.md: b7bb08f94682b0c85baf33ad9045320e3306bbb2
README.md: 32fa61e3bb09b2166499003953a5631a93baf73b
README.zh.md: bf2eac320bf3dc0811ec8e3747487b4f3ce634b7

View File

@@ -30,7 +30,7 @@ The binary ships with the package on every supported platform (macOS/Linux/Windo
| `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. |
| `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. |
| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`; the subprocess seam's terminate escalation is the hard kill. |
| `graceMs` | `3000` | Terminate-escalation grace period the subprocess seam grants past `timeoutMs` before the search fails as `SEARCH_ABORTED`. |
| `graceMs` | `3000` | Positive terminate-escalation grace the subprocess seam grants past `timeoutMs` before the search fails as `SEARCH_ABORTED`; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). |
| `stderrMaxBytes` | `65536` | Diagnostic-tail budget for `rg` stderr, captured through the subprocess seam's collect disposition; a lossy read keeps only the tail (marked `[stderr truncated]`). |
## Tools

View File

@@ -30,7 +30,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
| `grepMaxLineBytes` | `2000` | 每条匹配行预览的字节上限;截断会保留 UTF-8 边界,并标记为 `(line truncated)`。 |
| `rawOutputMaxBytes` | `20000000` | 搜索将解析的完整原始 `rg` stdout 上限(与 Claude Code 的 ripgrep 原始 buffer 相同);更大的原始输出以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败。 |
| `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-timeout-policy` 通过 `exec.signal` 强制执行subprocess seam 的终止升级提供硬终止。 |
| `graceMs` | `3000` | subprocess seam 在 `timeoutMs` 之外授予的终止升级宽限期;超过后搜索以 `SEARCH_ABORTED` 失败。 |
| `graceMs` | `3000` | subprocess seam 在 `timeoutMs` 之外授予的终止升级宽限期须为正值;超过后搜索以 `SEARCH_ABORTED` 失败;该宽限期不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 |
| `stderrMaxBytes` | `65536` | `rg` stderr 的诊断尾部预算,经 subprocess seam 的 collect 形态捕获lossy 读取只保留尾部(标记 `[stderr truncated]`)。 |
## 工具

View File

@@ -36,6 +36,7 @@
"@deepseek-ai/dsh-spill": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
@@ -49,6 +50,7 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-spill": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}

View File

@@ -28,6 +28,7 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts'
import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts'
import { RAW_OUTPUT_MAX_BYTES, SEARCH_GRACE_MS, SEARCH_META_MAX_BYTES, SEARCH_STDERR_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
@@ -82,7 +83,7 @@ export interface Config {
searchMetaMaxBytes?: number
/** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */
rawOutputMaxBytes?: number
/** Terminate-escalation grace period (ms) for one search process, handed to the subprocess seam. */
/** Terminate-escalation grace (ms), handed to the subprocess seam and bounded by `MAX_TIMER_DELAY_MS`. */
graceMs?: number
/** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */
stderrMaxBytes?: number
@@ -130,6 +131,9 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
assertPositiveInteger('searchMetaMaxBytes', resolved.searchMetaMaxBytes)
assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes)
assertPositiveInteger('graceMs', resolved.graceMs)
if (resolved.graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`tool-fs-search: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
}
assertPositiveInteger('stderrMaxBytes', resolved.stderrMaxBytes)
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
applyGlobTool(ctx, {

View File

@@ -18,6 +18,7 @@ import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecution, type ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import { SubprocessService } from '@deepseek-ai/dsh-subprocess'
import type { SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputRead, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { rgPath } from '@vscode/ripgrep'
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
@@ -309,6 +310,17 @@ describe('config validation', () => {
await ctx.plugin(FakeSubprocess)
await expect(ctx.plugin(ToolFsSearch, { ...DEFAULT_CONFIG, ...config })).rejects.toThrow(new RegExp(`tool-fs-search: ${name} must be a positive integer`))
})
it('rejects a grace beyond the Node timer range at load', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FakeSubprocess)
await expect(ctx.plugin(ToolFsSearch, {
...DEFAULT_CONFIG,
graceMs: MAX_TIMER_DELAY_MS + 1,
})).rejects.toThrow(`tool-fs-search: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
})
})
describe('command construction (plain argv)', () => {

View File

@@ -36,6 +36,9 @@
{
"path": "../../spill/spill"
},
{
"path": "../../util/timeout"
},
{
"path": "../../support/invariants"
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/README.md
README.md: 6aeb7fb1eaa9341dd72df614ca11d114f321fb83
README.zh.md: a78cb365a8e96ad44c0c930c072372f88930906c
README.md: 0a342569e66539e4987710b2e56f2946c97b1ac1
README.zh.md: 5d2f7beef478b8bfd27b4772c7a951ea62cb10ef

View File

@@ -11,6 +11,8 @@ This family lets an agent delegate work to child agents. Multiple named provider
| [`subagent-spawn/`](subagent-spawn/README.md) | Starts a fresh in-process child | registers on `ctx.subagents` |
| [`subagent-fork/`](subagent-fork/README.md) | Starts an in-process child from the parent's completed history | registers on `ctx.subagents` |
| [`subagent-acp/`](subagent-acp/README.md) | Starts an out-of-process child over ACP | registers on `ctx.subagents` |
| [`subagent-codex/`](subagent-codex/README.md) | Starts a real Codex app-server child | registers on `ctx.subagents` |
| [`subagent-claude-code/`](subagent-claude-code/README.md) | Starts a real Claude Code child through the official Claude Agent SDK | registers on `ctx.subagents` |
| [`subagent-dsh-sdk/`](subagent-dsh-sdk/README.md) | Starts an out-of-process Harness child through the TypeScript SDK | registers on `ctx.subagents` |
| [`tool-subagent/`](tool-subagent/README.md) | Exposes delegation to the model | registers on `ctx.tools` |
| [`tool-subagent-control/`](tool-subagent-control/README.md) | Exposes child messaging and listing to the model | registers on `ctx.tools` |

View File

@@ -11,6 +11,8 @@
| [`subagent-spawn/`](subagent-spawn/README.md) | 启动全新的进程内子 agent | 注册到 `ctx.subagents` |
| [`subagent-fork/`](subagent-fork/README.md) | 从父 agent 已完成的历史记录启动进程内子 agent | 注册到 `ctx.subagents` |
| [`subagent-acp/`](subagent-acp/README.md) | 通过 ACPAgent Client Protocol启动进程外子 agent | 注册到 `ctx.subagents` |
| [`subagent-codex/`](subagent-codex/README.md) | 启动真实的 Codex app-server 子 agent | 注册到 `ctx.subagents` |
| [`subagent-claude-code/`](subagent-claude-code/README.md) | 通过官方 Claude Agent SDK 启动真实的 Claude Code 子 agent | 注册到 `ctx.subagents` |
| [`subagent-dsh-sdk/`](subagent-dsh-sdk/README.md) | 通过 TypeScript SDK 启动进程外 Harness 子 agent | 注册到 `ctx.subagents` |
| [`tool-subagent/`](tool-subagent/README.md) | 向模型公开委派操作 | 注册到 `ctx.tools` |
| [`tool-subagent-control/`](tool-subagent-control/README.md) | 向模型公开子级消息发送和列举操作 | 注册到 `ctx.tools` |

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-acp/README.md
README.md: 83a5f60414528bdb768ffccd29f3091793f44b6b
README.zh.md: 4ea8daef9341897463f3dbedca86fd2c83b45514
README.md: 4fdd3a09e128d4dc7ec7395d9578803c64a33bc6
README.zh.md: 7cb1e3d18602ef839e4af316962fc2d10bc67640

View File

@@ -14,7 +14,7 @@ The returned run id is minted in the parent namespace. The child server's sessio
After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation.
`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL — Windows force-terminates directly), then a bounded whole-tree exit wait that rejects if survivors remain. Every run uses a fresh process; process pooling is not implemented.
`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then invoke the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL — Windows force-terminates directly) and await the subprocess owner's whole-tree exit proof. Every run uses a fresh process; process pooling is not implemented.
## Capabilities and context
@@ -30,8 +30,8 @@ ACP advertises no start-time capabilities because this process cannot enforce th
| `cwd` | parent session cwd | Working-directory override for the child process and its ACP session; must be non-empty, a relative value resolves against the harness launch directory at load, and the result must name a directory the harness can enter. |
| `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first allow-shaped option. |
| `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. |
| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before platform termination. |
| `disposeGraceMs` | `3000` | Exit-confirmation grace after termination; POSIX also waits this long after SIGTERM before SIGKILL. |
| `disposeEofGraceMs` | `6000` | Positive grace after stdin EOF before platform termination; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). |
| `disposeGraceMs` | `3000` | Positive POSIX grace after SIGTERM before SIGKILL (Windows force-terminates directly); it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). |
```yaml
- id: subagent-acp
@@ -57,7 +57,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th
## Process boundary
The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables and ambient `DSH_*` names are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives, and a `DSH_*` deployment fact such as `DSH_PERMISSION_MODE` reaches the child the same way — the scrub drops only its stale ambient namesake), stderr is inherited to the parent's own stream, and disposal runs the seam's cooperative stdin-EOF→SIGTERM→SIGKILL ladder with this plugin's configured graces. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned.
The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables and ambient `DSH_*` names are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives, and a `DSH_*` deployment fact such as `DSH_PERMISSION_MODE` reaches the child the same way — the scrub drops only its stale ambient namesake), stderr is inherited to the parent's own stream, and disposal applies this plugin's EOF window before the subprocess-owned SIGTERM→SIGKILL escalation and whole-tree join. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned.
The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md).

View File

@@ -14,7 +14,7 @@ ACPAgent Client Protocol提供方会在全新的子进程中运行每个 s
发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败会以 `stopReason: 'error'` 兑现;如果必需的请求信号或 dispose资源释放请求了取消则以 `aborted` 兑现。
`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后经由该 seam 的动词运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作式完全停稳,再触发句柄的 `terminate()` 升级SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),最后进行有界的整树退出等待;若仍有存活进程,则拒绝。每次运行都使用全新进程;尚未实现进程池。
`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后经由该 seam 的动词运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作式完全停稳,再触发句柄的 `terminate()` 升级SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),并等待子进程责任方给出整棵进程树的退出证明。每次运行都使用全新进程;尚未实现进程池。
## 能力与上下文
@@ -30,8 +30,8 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程
| `cwd` | 父会话 cwd | 子进程及其 ACP 会话的工作目录覆盖值;不得为空。相对值会在加载时以 harness 启动目录为基准解析,结果必须指向 harness 可以进入的目录。 |
| `permission` | `reject` | 自动回答权限请求:拒绝,或选择第一个允许形态的选项。 |
| `env` | `{}` | 显式子进程环境,叠加到已清理凭据的父进程环境之上。 |
| `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间。 |
| `disposeGraceMs` | `3000` | 终止后的退出确认宽限时间;POSIX 在 SIGTERM 后、SIGKILL 前也会等待同样时长。 |
| `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 |
| `disposeGraceMs` | `3000` | POSIX 在 SIGTERM 后、SIGKILL 前的宽限时间Windows 直接强制终止),须为正值且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 |
```yaml
- id: subagent-acp
@@ -57,7 +57,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程
## 进程边界
子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值stderr 会继承到父进程自身的流dispose 则以本插件配置的宽限期运行该 seam 的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。ACP 协议格式wire format是真正的序列化边界同进程 subagent 值不会为防御目的而克隆。
子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值stderr 会继承到父进程自身的流dispose 则先应用本插件的 EOF 时间窗,再由子进程责任方执行 SIGTERM→SIGKILL 升级并等待整棵进程树退出。ACP 协议格式wire format是真正的序列化边界同进程 subagent 值不会为防御目的而克隆。
本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘postmortem0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。

View File

@@ -31,6 +31,7 @@
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
@@ -47,6 +48,7 @@
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -17,6 +17,7 @@ import type {
SubagentProvider,
SubagentStartRequest,
} from '@deepseek-ai/dsh-subagent'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts'
export const name = 'subagent-acp'
@@ -54,10 +55,11 @@ export interface Config {
/**
* Grace period (ms) for the child's EOF-driven quiesce on dispose — its
* window to flush persistence and tear down its own nested subprocesses
* before the parent escalates to a signal.
* before the parent escalates to a signal. Must not exceed
* `MAX_TIMER_DELAY_MS`.
*/
disposeEofGraceMs?: number
/** Termination confirmation window (ms), including forced exit on every platform. */
/** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */
disposeGraceMs?: number
}
@@ -72,10 +74,10 @@ export const Config: z<Config> = z.object({
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
})
/** A dispose grace must be a positive finite number (it bounds the teardown wait). */
/** A dispose grace must fit the single Node timer that owns its teardown tier. */
function assertPositiveFinite(name: string, value: number): void {
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`subagent-acp: ${name} must be a positive finite number`)
if (!Number.isFinite(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) {
throw new Error(`subagent-acp: ${name} must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
}
}

View File

@@ -62,9 +62,9 @@ export interface AcpRunSpec {
*/
disposeEofGraceMs: number
/**
* Termination confirmation window (ms) in {@link SubagentRun.dispose}; POSIX applies it after
* `SIGTERM` and `SIGKILL`, while Windows applies it after direct forced termination. The plugin
* fills this from its `disposeGraceMs` config.
* Termination-escalation grace (ms) in {@link SubagentRun.dispose}; POSIX
* waits this long after `SIGTERM` before `SIGKILL`, while Windows
* force-terminates directly. The plugin fills it from `disposeGraceMs`.
*/
disposeGraceMs: number
/**
@@ -105,14 +105,12 @@ async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise<boo
* Cooperative teardown ladder for an out-of-process agent, over the seam's
* public verbs; resolves only at whole-tree quiescence: stdin EOF (the child's
* window to flush persistence and reap its own descendants), then the
* terminate() escalation (SIGTERM → spec grace → SIGKILL), then a bounded
* confirmation wait.
* terminate() escalation (SIGTERM → spec grace → SIGKILL) and its
* whole-tree exit proof.
* @param child - the spawned ACP child's handle.
* @param eofGraceMs - tier-1 window after stdin EOF.
* @param graceMs - confirmation window after the escalation's SIGKILL.
* @throws when the tree still has not exited `graceMs` after forced termination.
*/
export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: number, graceMs: number): Promise<void> {
export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: number): Promise<void> {
// A spawn failure has no process to tear down; observe the rejection so
// disposal in a finally block cannot surface it as unhandled.
if (child.pid <= 0) {
@@ -121,13 +119,10 @@ export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: numbe
}
child.stdin?.end()
if (await treeExitsWithin(child, eofGraceMs)) return
// terminate() sends SIGTERM now and SIGKILL after the spawn spec's grace
// (this plugin passes disposeGraceMs there), so the bound covers both the
// escalation window and an equal confirmation window after the SIGKILL.
// terminate() owns the bounded SIGTERM→SIGKILL timer. Its unbounded wait is
// the process owner's exit proof, not a second derived grace that can overflow.
child.terminate()
if (!(await treeExitsWithin(child, graceMs * 2))) {
throw new Error('ACP child process tree did not exit within its dispose windows')
}
await child.waitForExit()
}
/**
@@ -235,7 +230,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
// Startup rollback and the published handle share one process teardown.
let processDisposal: Promise<void> | undefined
const disposeProcess = (): Promise<void> => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs, spec.disposeGraceMs))
const disposeProcess = (): Promise<void> => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs))
// Accumulate the child's streamed assistant text — the SubagentResult output.
const output: string[] = []

View File

@@ -7,6 +7,7 @@ import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import * as acp from '../src/index.ts'
import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
@@ -147,7 +148,7 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)',
it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => {
const child = bash('read -r line; exit 0')
await disposeAcpChild(child, 5_000, 200)
await disposeAcpChild(child, 5_000)
const outcome = await child.done
expect(outcome.exitCode).toBe(0)
expect(outcome.signal).toBeNull()
@@ -155,7 +156,7 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)',
it('tier 2: an EOF-deaf child dies by the terminate escalation (SIGTERM)', async () => {
const child = bash('sleep 60')
await disposeAcpChild(child, 100, 5_000)
await disposeAcpChild(child, 100)
const outcome = await child.done
expect(outcome.signal).toBe('SIGTERM')
})
@@ -166,30 +167,11 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)',
while (!child.collected.stdout!.readFrom(0).text.includes('armed')) {
await new Promise(resolve => setTimeout(resolve, 10))
}
await disposeAcpChild(child, 50, 2_000)
await disposeAcpChild(child, 50)
const outcome = await child.done
expect(outcome.signal).toBe('SIGKILL')
})
it('throws when the tree survives even the escalation window', async () => {
// A handle whose tree never exits (waitForExit only ever aborts): the
// ladder must fail loud instead of resolving over survivors. Built as a
// stub because the ladder composes only public verbs.
const never: Parameters<typeof disposeAcpChild>[0] = {
pid: 1,
stdin: undefined,
stdout: undefined,
stderr: undefined,
collected: {},
done: new Promise(() => {}),
terminate: () => {},
waitForExit: (signal?: AbortSignal) => new Promise((resolve) => {
signal?.addEventListener('abort', () => { resolve(false) }, { once: true })
}),
}
await expect(disposeAcpChild(never, 20, 20)).rejects.toThrow(/did not exit within its dispose windows/)
})
it('observes a spawn-level rejection and returns without a process to reap', async () => {
const child = spawnSubprocess({
argv: ['bash', '-c', 'true'],
@@ -197,7 +179,7 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)',
stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
graceMs: 200,
})
await expect(disposeAcpChild(child, 1_000, 1_000)).resolves.toBeUndefined()
await expect(disposeAcpChild(child, 1_000)).resolves.toBeUndefined()
await expect(child.done).rejects.toThrow()
})
})
@@ -721,13 +703,20 @@ describe('dsh-subagent-acp', () => {
}
})
it('rejects a non-positive dispose grace at load', async () => {
for (const bad of [{ disposeEofGraceMs: 0 }, { disposeGraceMs: -1 }, { disposeEofGraceMs: Number.NaN }]) {
it('rejects a dispose grace outside the Node timer range at load', async () => {
for (const bad of [
{ disposeEofGraceMs: 0 },
{ disposeGraceMs: -1 },
{ disposeEofGraceMs: Number.NaN },
{ disposeGraceMs: Number.POSITIVE_INFINITY },
{ disposeEofGraceMs: MAX_TIMER_DELAY_MS + 1 },
{ disposeGraceMs: MAX_TIMER_DELAY_MS + 1 },
]) {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', args: [], permission: 'reject', env: {}, ...bad }))
.rejects.toThrow(/subagent-acp: dispose(?:Eof)?GraceMs must be a positive finite number/)
.rejects.toThrow(new RegExp(`subagent-acp: dispose(?:Eof)?GraceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`))
await ctx.fiber.dispose()
}
})

View File

@@ -29,6 +29,9 @@
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../util/timeout"
},
{
"path": "../../support/loader-smoke"
},

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md
README.md: e62f60fceea16749296a91377785b81d94d751ca
README.zh.md: e171524157b2b1696df31753816210d41637a911

View File

@@ -0,0 +1,96 @@
# @deepseek-ai/dsh-subagent-claude-code
English | [中文](README.zh.md)
This package registers the fixed `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, starts the SDK-distributed Claude Code CLI through the shared subprocess service, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract.
## Start and ownership
`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It creates one private `AbortController`, calls the official SDK `query()`, and publishes the run only after the SDK's `spawnClaudeCodeProcess` hook has supplied a live CLI handle owned by [`dsh-subprocess`](../../subprocess/subprocess/README.md). A failure or cancellation before publication closes the query, terminates any acquired process tree, waits for it to exit, and rejects `start()`.
The SDK receives the exact concatenated text task. The provider iterates the complete SDK message stream and accepts only a `result` message with `subtype: "success"`, `is_error: false`, and a nonblank `result`, followed by normal iterator completion. Every SDK error subtype, an error-marked success, a missing answer, iterator failure, protocol failure, or process failure maps to `error`; this version produces neither `max-tokens` nor `refusal`.
Local cancellation wins the result race and maps to `aborted`. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the shared process-tree termination escalation, and waits for whole-tree exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Result failure and independent teardown failure remain separate.
## Native settings and interaction
The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state.
Each query sets `persistSession: false` and disables `AskUserQuestion`. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK instead of waiting for a user interface this provider does not own.
## Capabilities and context
The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Claude Code receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. Every run has an independent SDK query, cancellation controller, CLI process, and non-persisted product session.
## Configuration
| Key | Default | Meaning |
|---|---|---|
| `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. |
| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. |
Production uses the Claude Code CLI supplied by `@anthropic-ai/claude-agent-sdk` and the host's native settings and authentication. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden.
Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_claude_code` by default.
```yaml
- id: subagent-claude-code
name: '@deepseek-ai/dsh-subagent-claude-code'
config:
env:
ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY
- id: tool-subagent-claude-code
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: claude-code
toolName: subagent_claude_code
enableRunInBackground: false
maxDepth: provider-managed
```
## Product compatibility and evidence
The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`, whose platform optional dependency supplies Claude Code 2.1.220. Required evidence exercises that official distribution through a keyless loopback product path and a credentialed DeepSeek path, while Loader composition proves that both opt-in product packages coexist without starting either product.
The project owner's identity-scoped distribution authorization covers the official SDK and the official CLI/platform payloads declared by each SDK version. [`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) discloses the current optional payload closure without classifying its declared terms as permissive; unrelated non-permissive runtime dependencies continue to fail the notices gate.
## Model Experience
### Child request
#### What the model sees
The Claude Code child receives the standalone text task as one fresh SDK query. Its workspace is the parent Session cwd, while its model, system instructions, tools, permissions, and authentication come from the host's native Claude settings and product installation.
#### Token effect
The child pays for an independent Claude Code context and query. Child tokens do not enter the parent's context.
#### KV Cache effect
Independent of the parent request cache. Reuse depends only on Claude Code's own model, instructions, tools, native settings, and fresh query.
### Parent tool result, indirectly
#### What the model sees
Through `dsh-tool-subagent`, the parent sees only the strict final Claude Code answer or the consumer's exact error for a non-completed result. Claude Code reasoning, tool activity, intermediate messages, stderr, workspace diffs, usage, and product ids are not copied into the parent Session.
#### Token effect
Parent input grows only by the final answer or error retained in the tool result. This provider adds no parent tool schema by itself.
#### KV Cache effect
Append-only: the new tool result follows the reusable parent request prefix.
## Known Limitations and Deferred Work
- **One fresh query and process per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence.
- **Host settings are intentionally authoritative** — project and user settings can change model, tools, and behavior; the provider does not provide a filtered or hermetic production mode.
- **Product installation and account state remain native** — an incompatible SDK payload, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow.
- **No human interaction path** — `AskUserQuestion` is disabled and other interactive callbacks are absent, so tasks requiring new approval or input fail instead of suspending.
- **Final text only** — reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local.
- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider.
- **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored.

View File

@@ -0,0 +1,96 @@
# @deepseek-ai/dsh-subagent-claude-code
[English](README.md) | 中文
本包package注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK通过共享子进程服务启动 SDK 分发的 Claude Code CLI提交一个自包含的文本任务并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果契约仅返回最终答案。
## 启动与所有权
`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。它会创建一个私有 `AbortController`,调用官方 SDK 的 `query()`,并仅在 SDK 的 `spawnClaudeCodeProcess` 钩子已经提供由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 管理的活动 CLI 句柄后发布此次运行。若在发布前发生失败或取消,它会关闭 query、终止所有已取得的进程树并等待其退出然后拒绝 `start()` 调用。
SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 消息流,而且只接受满足以下条件的 `result` 消息:其 `subtype: "success"``is_error: false``result` 非空白,之后迭代器还须正常结束。所有 SDK 错误子类型、标记为错误的成功消息、缺失答案、迭代器失败、协议失败或进程失败都映射为 `error`;本版本不会产生 `max-tokens``refusal`
本地取消会在结果竞态中胜出并映射为 `aborted``dispose()` 具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用共享的进程树逐级终止机制并等待整棵进程树退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。结果失败与独立的清理失败仍彼此分离。
## 原生设置与交互
提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。
每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。
## 能力与上下文
本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Claude Code 会接收独立文本任务和父会话 cwd但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出契约。每次运行都拥有独立的 SDK query、取消控制器、CLI 进程和不持久化的产品会话。
## 配置
| 配置键 | 默认值 | 含义 |
|---|---|---|
| `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 |
| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 |
生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH``HOME` 等普通环境变量仍会被继承。
请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_claude_code`
```yaml
- id: subagent-claude-code
name: '@deepseek-ai/dsh-subagent-claude-code'
config:
env:
ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY
- id: tool-subagent-claude-code
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: claude-code
toolName: subagent_claude_code
enableRunInBackground: false
maxDepth: provider-managed
```
## 产品兼容性与证据
运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其平台可选依赖提供 Claude Code 2.1.220。强制证据会通过无密钥回环产品路径与带密钥 DeepSeek 路径运行该官方发行版,而 Loader 组合则证明两个选择启用的产品包能够共存,且不会启动任一产品。
项目所有者按身份范围授权分发官方 SDK 及每个 SDK 版本声明的官方 CLI平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) 会披露当前可选载荷闭包,但不会把其声明条款归类为宽松许可证;其他无关的非宽松运行时依赖仍会使第三方声明门禁失败。
## 模型体验
### 子任务请求
#### 模型看到的内容
Claude Code 子任务会在一个全新的 SDK query 中接收独立文本任务。它的工作区是父会话 cwd其模型、系统指令、工具、权限和身份验证来自宿主机原生 Claude 设置与产品安装。
#### 对 token 的影响
子任务需为独立的 Claude Code 上下文和 query 承担 token 开销。子任务 token 不会进入父级上下文。
#### 对 KV Cache 的影响
这与父请求缓存相互独立。能否复用只取决于 Claude Code 自身的模型、指令、工具、原生设置和全新 query。
### 父级工具结果(间接)
#### 模型看到的内容
通过 `dsh-tool-subagent`,父级模型只会看到符合严格成功条件的 Claude Code 最终答案或者在结果未完成时看到消费方给出的原样错误。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息和产品标识符均不会复制到父会话。
#### 对 token 的影响
父级输入只会增加工具结果中保留的最终答案或错误内容。本提供方自身不添加父级工具 schema。
#### 对 KV Cache 的影响
仅追加:新的工具结果接在可复用的父请求前缀之后。
## 已知限制与后续工作
- **每次运行均新建一个 query 和一个进程**:不支持续接、恢复、池化、进度流或产品会话持久化。
- **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。
- **产品安装与账户状态仍由原生机制管理**:不兼容的 SDK 载荷、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。
- **没有人工交互路径**`AskUserQuestion` 被禁用,其他交互回调也不存在,因此需要新审批或输入的任务会失败而不会挂起。
- **仅返回最终文本**推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。
- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。
- **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。

View File

@@ -0,0 +1,53 @@
{
"name": "@deepseek-ai/dsh-subagent-claude-code",
"description": "One-shot Claude Code subagent provider over the official Agent SDK",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"@anthropic-ai/sdk": "0.93.0",
"@anthropic-ai/claude-agent-sdk": "0.3.220",
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,107 @@
/**
* Fixed Claude Code one-shot subagent provider. Every accepted run invokes
* the official Agent SDK in the delegating Session's workspace and places
* the SDK-spawned real CLI under the shared subprocess owner.
*
* @module @deepseek-ai/dsh-subagent-claude-code
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import {
assertPositiveFinite,
NO_START_CAPABILITIES,
resolveChildCwd,
type ResolvedSubagentStartRequest,
type SubagentCapabilities,
type SubagentProvider,
} from '@deepseek-ai/dsh-subagent'
import {
DEFAULT_DISPOSE_GRACE_MS,
startClaudeCodeRun,
type ClaudeCodeRunSpec,
} from './run.ts'
export const name = 'subagent-claude-code'
export const inject = ['subagents', 'subprocess']
/* jscpd:ignore-start -- sibling product providers intentionally expose the
* same two deployment-owned fields without adding a shared config owner. */
/** Deployment-owned environment and process-release bound. */
export interface Config {
/**
* Explicit environment entries layered over the subprocess seam's
* credential-scrubbed parent environment.
*/
env?: Record<string, string>
/** Grace in milliseconds for Claude Code process-tree termination. */
disposeGraceMs?: number
}
export const Config: z<Config> = z.object({
env: z.dict(z.string()).default({}),
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
})
type ResolvedConfig = Required<Config>
/* jscpd:ignore-end */
/* jscpd:ignore-start -- Cordis registration and shared-seam plumbing mirror
* the Codex sibling; each product's lifecycle remains package-private. */
class ClaudeCodeProvider implements SubagentProvider {
readonly name = 'claude-code'
readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES
readonly inheritsParentContext = false
constructor(
private readonly ctx: Context,
private readonly config: ResolvedConfig,
) {}
start(request: ResolvedSubagentStartRequest) {
const parentCwd = request.parent.session.header.cwd
if (parentCwd === undefined) {
throw new Error(
'subagent-claude-code: no working directory for the child — delegate from a parent session that has one',
)
}
const spec: ClaudeCodeRunSpec = {
cwd: resolveChildCwd(
'subagent-claude-code',
undefined,
parentCwd,
),
env: this.config.env,
disposeGraceMs: this.config.disposeGraceMs,
spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec),
onError: (error, stopReason) => {
this.ctx.logger.warn(
`subagent-claude-code: child run failed (${stopReason}): ${error.message}`,
)
},
}
return startClaudeCodeRun(request, spec)
}
}
/**
* Register the fixed `claude-code` provider.
* @param ctx - context carrying shared subagent and subprocess services.
* @param config - explicit child environment and disposal grace.
*/
export function apply(ctx: Context, config: Config): void {
const resolved = config as ResolvedConfig
assertPositiveFinite(
'subagent-claude-code',
'disposeGraceMs',
resolved.disposeGraceMs,
)
if (resolved.disposeGraceMs > MAX_TIMER_DELAY_MS) {
throw new Error(
`subagent-claude-code: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
ctx.subagents.registerProvider(new ClaudeCodeProvider(ctx, resolved))
}
/* jscpd:ignore-end */

View File

@@ -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 */

View File

@@ -0,0 +1,156 @@
/**
* Projection from the shared managed-process handle to the official Claude
* Agent SDK's custom-spawn process interface.
*
* @module @deepseek-ai/dsh-subagent-claude-code/process
*/
import { EventEmitter } from 'node:events'
import type {
SpawnedProcess,
SpawnOptions,
} from '@anthropic-ai/claude-agent-sdk'
import {
scrubbedParentEnv,
type SubprocessHandle,
type SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
function thrown(value: unknown): Error {
/* v8 ignore next -- the subprocess seam rejects with Error. */
return value instanceof Error ? value : new Error(String(value))
}
/**
* Encode the SDK's complete child environment as a subprocess overlay.
* @param env - SDK-composed child environment after its removals and replacements.
* @returns explicit values plus tombstones for surviving ambient names the SDK removed.
*/
export function sdkEnvironmentOverlay(
env: SpawnOptions['env'],
): NodeJS.ProcessEnv {
const overlay: NodeJS.ProcessEnv = { ...env }
for (const name of Object.keys(scrubbedParentEnv())) {
if (!(name in env)) overlay[name] = undefined
}
return overlay
}
/**
* Translate one official SDK spawn request to the shared process owner.
* @param options - command, arguments, workspace, environment, and forwarded signal from the SDK.
* @param graceMs - process-tree termination grace.
* @returns the fully explicit shared subprocess request.
*/
export function claudeSpawnSpec(
options: SpawnOptions,
graceMs: number,
): SubprocessSpawnSpec {
if (options.cwd === undefined || options.cwd.length === 0) {
throw new Error('subagent-claude-code: SDK spawn request omitted its workspace')
}
return {
argv: [options.command, ...options.args],
cwd: options.cwd,
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
graceMs,
signal: options.signal,
env: sdkEnvironmentOverlay(options.env),
}
}
/**
* SDK-facing view of one shared managed process. Protocol transport remains
* in the official SDK; this adapter only projects streams and exit events.
*/
export class ManagedClaudeCodeProcess implements SpawnedProcess {
readonly stdin
readonly stdout
private readonly events = new EventEmitter()
private exitCodeValue: number | null = null
private signalCodeValue: NodeJS.Signals | null = null
private killRequested = false
/**
* Project a managed process with piped stdin and stdout.
* @param child - shared handle that remains the process-tree authority.
*/
constructor(private readonly child: SubprocessHandle) {
this.stdin = child.stdin as NonNullable<SubprocessHandle['stdin']>
this.stdout = child.stdout as NonNullable<SubprocessHandle['stdout']>
// EventEmitter gives `error` special throw semantics without a listener.
// The SDK attaches its listener synchronously after custom spawn returns,
// while this no-op also contains an already-rejected spawn handle.
this.events.on('error', () => {})
void child.done.then(
(outcome) => {
this.exitCodeValue = outcome.exitCode
this.signalCodeValue = outcome.signal
this.events.emit('exit', outcome.exitCode, outcome.signal)
},
(error: unknown) => {
this.events.emit('error', thrown(error))
},
)
}
/** Whether the SDK has requested managed tree termination. */
get killed(): boolean {
return this.killRequested
}
/** Direct-child exit code, or null while running or after signal exit. */
get exitCode(): number | null {
return this.exitCodeValue
}
/** Direct-child terminating signal, if any. */
get signalCode(): NodeJS.Signals | null {
return this.signalCodeValue
}
/**
* Route the SDK's termination request to the tree-scoped process owner.
* @param _signal - SDK-selected signal; the shared seam owns its escalation ladder.
* @returns false only after exit or a previous termination request.
*/
kill(_signal: NodeJS.Signals): boolean {
if (
this.killRequested
|| this.exitCodeValue !== null
|| this.signalCodeValue !== null
) {
return false
}
this.killRequested = true
this.child.terminate()
return true
}
/** Register a persistent process lifecycle listener. */
on(
event: 'exit' | 'error',
listener: ((code: number | null, signal: NodeJS.Signals | null) => void)
| ((error: Error) => void),
): void {
this.events.on(event, listener)
}
/** Register a one-shot process lifecycle listener. */
once(
event: 'exit' | 'error',
listener: ((code: number | null, signal: NodeJS.Signals | null) => void)
| ((error: Error) => void),
): void {
this.events.once(event, listener)
}
/** Remove a process lifecycle listener. */
off(
event: 'exit' | 'error',
listener: ((code: number | null, signal: NodeJS.Signals | null) => void)
| ((error: Error) => void),
): void {
this.events.off(event, listener)
}
}

View File

@@ -0,0 +1,287 @@
/**
* One-shot Claude Code lifecycle: invoke the official Agent SDK, place its
* real CLI process under the shared subprocess owner, map only strict SDK
* success to completion, and dispose to whole-tree quiescence.
*
* @module @deepseek-ai/dsh-subagent-claude-code/run
*/
import { randomUUID } from 'node:crypto'
import {
query as officialQuery,
type Options,
type Query,
type SDKMessage,
type SDKResultMessage,
type SpawnOptions,
} from '@anthropic-ai/claude-agent-sdk'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
settleRunResult,
subprocessRunHandle,
type SubagentResult,
type SubagentRun,
type SubagentStartRequest,
type SubagentStopReason,
} from '@deepseek-ai/dsh-subagent'
import {
scrubbedParentEnv,
type SubprocessHandle,
type SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import {
claudeSpawnSpec,
ManagedClaudeCodeProcess,
} from './process.ts'
/** Default POSIX grace between subprocess termination tiers. */
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
/* jscpd:ignore-start -- sibling providers intentionally keep product-private
* run inputs and error normalization instead of adding a shared lifecycle owner. */
/** Fully resolved inputs for one official Claude Agent SDK query. */
export interface ClaudeCodeRunSpec {
/** Parent Session workspace supplied to the SDK and real CLI. */
readonly cwd: string
/** Explicit deployment/test environment layered after shared scrubbing. */
readonly env: Record<string, string>
/** Subprocess termination grace passed to the shared process-tree owner. */
readonly disposeGraceMs: number
/** Shared subprocess service spawn operation. */
readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
/** Diagnostic sink for a post-publication error flattened into a result. */
readonly onError?: (error: Error, stopReason: SubagentStopReason) => void
}
function thrown(value: unknown): Error {
/* v8 ignore next -- typed SDK and subprocess failures reject with Error. */
return value instanceof Error ? value : new Error(String(value))
}
/* jscpd:ignore-end */
/**
* Validate and preserve the one-shot task before crossing the SDK boundary.
* @param prompt - task content accepted from the shared subagent service.
* @returns the exact text sequence as one SDK prompt.
*/
export function textTask(prompt: readonly ContentBlock[]): string {
if (prompt.length === 0) {
throw new Error('subagent-claude-code: the one-shot task must contain only text blocks')
}
const texts: string[] = []
for (const block of prompt) {
if (block.type !== 'text') {
throw new Error('subagent-claude-code: the one-shot task must contain only text blocks')
}
texts.push(block.text)
}
if (texts.every(text => text.trim().length === 0)) {
throw new Error('subagent-claude-code: the one-shot task must not be empty')
}
return texts.join('')
}
/**
* Strictly derive the only SDK result that can complete a shared run.
* @param message - an official discriminated result union.
* @returns exact final text for a successful, non-error result.
*/
export function successfulResult(message: SDKResultMessage): string {
if (
message.subtype !== 'success'
|| message.is_error
|| message.result.trim().length === 0
) {
const detail = message.subtype === 'success'
? 'success result was marked as an error or contained no answer'
: message.errors.join('; ') || message.subtype
throw new Error(`subagent-claude-code: Claude Code failed: ${detail}`)
}
return message.result
}
/**
* Consume the complete SDK stream and require one strict success plus normal
* iterator completion.
* @param query - published official SDK query.
* @returns the completed shared result.
*/
export async function consumeClaudeQuery(
query: AsyncIterable<SDKMessage>,
): Promise<SubagentResult> {
let answer: string | undefined
for await (const message of query) {
if (message.type !== 'result') continue
answer = successfulResult(message)
}
if (answer === undefined) {
throw new Error('subagent-claude-code: Claude Code ended without a result')
}
return {
output: [{ type: 'text', text: answer }],
stopReason: 'completed',
}
}
/**
* Close the official query, terminate the managed process tree, and wait for
* the subprocess owner to prove it is gone.
* @param query - official SDK query, when creation reached that point.
* @param child - shared-service handle that owns the CLI process tree.
*/
export async function disposeClaudeCodeChild(
query: Pick<Query, 'close'> | undefined,
child: SubprocessHandle,
): Promise<void> {
const failures: Error[] = []
try {
query?.close()
} catch (error: unknown) {
failures.push(thrown(error))
}
if (child.pid > 0) {
child.terminate()
try {
await child.waitForExit()
} catch (error: unknown) {
failures.push(thrown(error))
}
}
try {
await child.done
} catch (error: unknown) {
failures.push(thrown(error))
}
const firstFailure = failures[0]
if (failures.length === 1 && firstFailure !== undefined) throw firstFailure
if (failures.length > 1) {
throw new AggregateError(
failures,
'subagent-claude-code: query and process cleanup failed',
)
}
}
/**
* Build the fixed official SDK options for one one-shot provider run.
* @param spec - workspace, environment, process seam, and disposal policy.
* @param controller - per-run cancellation owner.
* @param capture - receives the real managed child synchronously from the SDK hook.
* @returns options that inherit native settings while disabling persistence and user questions.
*/
export function claudeQueryOptions(
spec: ClaudeCodeRunSpec,
controller: AbortController,
capture: (child: SubprocessHandle) => void,
): Options {
return {
abortController: controller,
cwd: spec.cwd,
env: { ...scrubbedParentEnv(), ...spec.env },
persistSession: false,
disallowedTools: ['AskUserQuestion'],
spawnClaudeCodeProcess: (options: SpawnOptions) => {
const child = spec.spawn(claudeSpawnSpec(options, spec.disposeGraceMs))
capture(child)
return new ManagedClaudeCodeProcess(child)
},
}
}
/**
* Start one official Claude Agent SDK query and publish its one-shot run.
* @param request - resolved shared subagent request.
* @param spec - workspace, environment, process seam, and diagnostic policy.
* @returns the published run after both Query and real CLI handle exist.
*/
export async function startClaudeCodeRun(
request: SubagentStartRequest,
spec: ClaudeCodeRunSpec,
): Promise<SubagentRun> {
const prompt = textTask(request.prompt)
if (request.signal.aborted) {
throw new Error('subagent-claude-code: request was aborted before SDK startup')
}
const controller = new AbortController()
const requestCancel = (): void => {
if (!controller.signal.aborted) {
controller.abort(new Error('subagent-claude-code: run cancelled locally'))
}
}
const onAbort = (): void => { requestCancel() }
request.signal.addEventListener('abort', onAbort, { once: true })
let child: SubprocessHandle | undefined
let query: Query | undefined
try {
query = officialQuery({
prompt,
options: claudeQueryOptions(spec, controller, (captured) => {
child = captured
}),
})
if (child === undefined || child.pid <= 0) {
throw new Error(
'subagent-claude-code: official SDK did not publish a controllable Claude Code process',
)
}
if (controller.signal.aborted) {
throw new Error('subagent-claude-code: request was aborted before SDK startup')
}
} catch (error: unknown) {
request.signal.removeEventListener('abort', onAbort)
const cancelledBeforeCleanup = controller.signal.aborted
requestCancel()
if (child !== undefined) {
try {
await disposeClaudeCodeChild(query, child)
} catch (disposeError: unknown) {
throw new AggregateError(
[thrown(error), thrown(disposeError)],
'subagent-claude-code: startup failed and CLI cleanup also failed',
)
}
} else if (query !== undefined) {
try {
query.close()
} catch (disposeError: unknown) {
throw new AggregateError(
[thrown(error), thrown(disposeError)],
'subagent-claude-code: startup failed and query cleanup also failed',
)
}
}
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the request can abort while process cleanup is awaited.
if (cancelledBeforeCleanup || request.signal.aborted) {
throw new Error('subagent-claude-code: request was aborted before SDK startup')
}
throw thrown(error)
}
const publishedQuery = query
const publishedChild = child
const result = settleRunResult({
attempt: () => consumeClaudeQuery(publishedQuery),
collectOutput: () => [],
cancelled: () => controller.signal.aborted,
onError: spec.onError,
signal: request.signal,
onAbort,
})
return subprocessRunHandle({
id: SessionId(randomUUID()),
result,
signal: request.signal,
onAbort,
requestCancel,
teardown: () => disposeClaudeCodeChild(
publishedQuery,
publishedChild,
),
})
}

View File

@@ -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)
})

View File

@@ -0,0 +1,150 @@
import { createServer, type IncomingHttpHeaders, type ServerResponse } from 'node:http'
/** One deterministic response emitted by the package-private Messages server. */
export type MessagesBehavior =
| { readonly kind: 'complete'; readonly text: string }
| { readonly kind: 'hold' }
/** One recorded Anthropic Messages request. */
interface RecordedMessagesRequest {
readonly method: string
readonly path: string
readonly headers: IncomingHttpHeaders
readonly body: Record<string, unknown>
}
/** Running package-private Anthropic Messages fixture. */
export interface MessagesFixture {
readonly baseUrl: string
readonly requests: RecordedMessagesRequest[]
readonly requestStarted: Promise<void>
close(): Promise<void>
}
function event(
response: ServerResponse,
type: string,
payload: Record<string, unknown>,
): void {
response.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`)
}
function complete(
response: ServerResponse,
body: Record<string, unknown>,
text: string,
): void {
const model = typeof body.model === 'string' ? body.model : 'fixture-model'
response.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
})
event(response, 'message_start', {
type: 'message_start',
message: {
id: 'msg_dsh_fixture',
type: 'message',
role: 'assistant',
model,
content: [],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 7,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
})
event(response, 'content_block_start', {
type: 'content_block_start',
index: 0,
content_block: { type: 'text', text: '' },
})
event(response, 'content_block_delta', {
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text },
})
event(response, 'content_block_stop', {
type: 'content_block_stop',
index: 0,
})
event(response, 'message_delta', {
type: 'message_delta',
delta: { stop_reason: 'end_turn', stop_sequence: null },
usage: { output_tokens: 1 },
})
event(response, 'message_stop', { type: 'message_stop' })
response.end()
}
/**
* Start a loopback-only Anthropic Messages SSE fixture.
* @param behavior - the single response behavior for this fixture.
* @returns the bound server and its recorded requests.
*/
export async function startMessagesFixture(
behavior: MessagesBehavior,
): Promise<MessagesFixture> {
const requests: RecordedMessagesRequest[] = []
let requestStartedResolve!: () => void
const requestStarted = new Promise<void>((resolve) => {
requestStartedResolve = resolve
})
const server = createServer((request, response) => {
const chunks: Buffer[] = []
request.on('data', (chunk: Buffer) => { chunks.push(chunk) })
request.on('end', () => {
const path = request.url ?? ''
if (path !== '/v1/messages' && !path.startsWith('/v1/messages?')) {
response.writeHead(404, { 'content-type': 'application/json' })
response.end(JSON.stringify({
type: 'error',
error: { type: 'not_found_error', message: `unexpected path ${path}` },
}))
return
}
const text = Buffer.concat(chunks).toString('utf8')
const body = JSON.parse(text) as Record<string, unknown>
requests.push({
method: request.method ?? '',
path,
headers: request.headers,
body,
})
requestStartedResolve()
if (behavior.kind === 'complete') {
complete(response, body, behavior.text)
}
// A hold deliberately leaves the response pending until client abort.
})
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
server.off('error', reject)
resolve()
})
})
const address = server.address()
if (address === null || typeof address === 'string') {
throw new Error('Messages fixture did not bind a TCP port')
}
return {
baseUrl: `http://127.0.0.1:${address.port}`,
requests,
requestStarted,
async close(): Promise<void> {
server.closeAllConnections()
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error !== undefined) reject(error)
else resolve()
})
})
},
}
}

View File

@@ -0,0 +1,160 @@
import { execFile } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as claudeCode from '../src/index.ts'
const execFileAsync = promisify(execFile)
const OFFICIAL_DEEPSEEK_BASE_URL = 'https://api.deepseek.com'
const sdkRoot = dirname(fileURLToPath(
import.meta.resolve('@anthropic-ai/claude-agent-sdk'),
))
const sdkPackage = JSON.parse(readFileSync(
join(sdkRoot, 'package.json'),
'utf8',
)) as {
version: string
claudeCodeVersion: string
optionalDependencies: Record<string, string>
}
const platformPackage = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`
const platformRoot = resolve(sdkRoot, '..', platformPackage.split('/')[1]!)
const claudeBin = join(
platformRoot,
process.platform === 'win32' ? 'claude.exe' : 'claude',
)
const roots: string[] = []
const contexts: Context[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function deepSeekBaseUrl(): string {
const configured = (process.env.DEEPSEEK_BASE_URL ?? OFFICIAL_DEEPSEEK_BASE_URL)
.replace(/\/+$/, '')
if (configured !== OFFICIAL_DEEPSEEK_BASE_URL) {
throw new Error('Claude Code DeepSeek e2e requires the official DeepSeek base URL')
}
return configured
}
async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise<void> {
expect(handles.length).toBeGreaterThan(0)
for (const handle of handles) {
await expect(handle.waitForExit()).resolves.toBe(true)
await expect(handle.done).resolves.toHaveProperty('exitCode')
}
}
describe.skipIf(!process.env.DEEPSEEK_API_KEY)(
'Claude Code provider with real DeepSeek API',
() => {
it('returns one unique nonce through the production provider and real SDK/CLI', async () => {
const apiKey = process.env.DEEPSEEK_API_KEY
if (apiKey === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY')
const root = mkdtempSync(join(tmpdir(), 'dsh-claude-deepseek-e2e-'))
roots.push(root)
const workspace = join(root, 'workspace')
const claudeConfig = join(root, 'claude-config')
const xdgConfig = join(root, 'xdg-config')
const xdgCache = join(root, 'xdg-cache')
const xdgData = join(root, 'xdg-data')
const xdgState = join(root, 'xdg-state')
for (const directory of [
workspace,
claudeConfig,
xdgConfig,
xdgCache,
xdgData,
xdgState,
]) mkdirSync(directory)
const env = {
ANTHROPIC_AUTH_TOKEN: apiKey,
ANTHROPIC_BASE_URL: `${deepSeekBaseUrl()}/anthropic`,
ANTHROPIC_MODEL: 'deepseek-v4-pro[1m]',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'deepseek-v4-pro[1m]',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'deepseek-v4-pro[1m]',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'deepseek-v4-flash',
CLAUDE_CODE_SUBAGENT_MODEL: 'deepseek-v4-flash',
CLAUDE_CODE_EFFORT_LEVEL: 'max',
CLAUDE_CONFIG_DIR: claudeConfig,
HOME: root,
XDG_CONFIG_HOME: xdgConfig,
XDG_CACHE_HOME: xdgCache,
XDG_DATA_HOME: xdgData,
XDG_STATE_HOME: xdgState,
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: '1',
DISABLE_TELEMETRY: '1',
DISABLE_ERROR_REPORTING: '1',
HTTP_PROXY: '',
HTTPS_PROXY: '',
ALL_PROXY: '',
NO_PROXY: '127.0.0.1,localhost',
}
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
const handles: SubprocessHandle[] = []
const spawn = ctx.subprocess.spawn.bind(ctx.subprocess)
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
const handle = spawn(spec)
handles.push(handle)
return handle
})
await ctx.plugin(claudeCode, { env, disposeGraceMs: 3_000 })
expect(sdkPackage.version).toBe('0.3.220')
expect(sdkPackage.claudeCodeVersion).toBe('2.1.220')
expect(sdkPackage.optionalDependencies[platformPackage]).toBe('0.3.220')
const version = await execFileAsync(claudeBin, ['--version'], {
env: { ...process.env, ...env },
})
expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)')
const nonce = `DSH_CLAUDE_DEEPSEEK_${randomUUID()}`
const parent = {
id: 'deepseek-e2e-parent',
session: { header: { cwd: workspace } },
} as unknown as Agent
const run = await ctx.subagents.start('claude-code', {
prompt: [{
type: 'text',
text: `Reply with exactly ${nonce} and nothing else. Do not use tools.`,
}],
parent,
signal: new AbortController().signal,
})
const result = await run.result
await run.dispose()
expect(result.stopReason).toBe('completed')
const text = result.output
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
.trim()
expect(text).toBe(nonce)
await expectQuiescent(handles)
}, 180_000)
},
)

View File

@@ -0,0 +1,272 @@
import { execFile } from 'node:child_process'
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import type {
Query,
SDKMessage,
SDKSystemMessage,
} from '@anthropic-ai/claude-agent-sdk'
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as claudeCode from '../src/index.ts'
import {
startMessagesFixture,
type MessagesBehavior,
type MessagesFixture,
} from './messages-fixture.ts'
const observedSdkMessages = vi.hoisted((): SDKMessage[] => [])
vi.mock('@anthropic-ai/claude-agent-sdk', async (importOriginal) => {
const actual = await importOriginal<
typeof import('@anthropic-ai/claude-agent-sdk')
>()
return {
...actual,
query(options: Parameters<typeof actual.query>[0]): Query {
const query = actual.query(options)
// Observe the real SDK stream without replacing its protocol or CLI.
return new Proxy(query, {
get(target, property) {
if (property === Symbol.asyncIterator) {
return async function* (): AsyncGenerator<SDKMessage, void> {
for await (const message of target) {
observedSdkMessages.push(message)
yield message
}
}
}
const value: unknown = Reflect.get(target, property, target)
if (typeof value === 'function') {
const method = value as (...args: unknown[]) => unknown
return method.bind(target)
}
return value
},
})
},
}
})
const execFileAsync = promisify(execFile)
const sdkRoot = dirname(fileURLToPath(
import.meta.resolve('@anthropic-ai/claude-agent-sdk'),
))
const sdkPackage = JSON.parse(readFileSync(
join(sdkRoot, 'package.json'),
'utf8',
)) as {
version: string
claudeCodeVersion: string
optionalDependencies: Record<string, string>
}
const platformPackage = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`
const platformRoot = resolve(sdkRoot, '..', platformPackage.split('/')[1]!)
const claudeBin = join(
platformRoot,
process.platform === 'win32' ? 'claude.exe' : 'claude',
)
const settingsModel = 'dsh-settings-inheritance-marker'
const fakeKey = 'dsh-fake-anthropic-key'
const roots: string[] = []
const fixtures: MessagesFixture[] = []
const contexts: Context[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
await Promise.all(fixtures.splice(0).map(fixture => fixture.close()))
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
observedSdkMessages.length = 0
})
interface RealHarness {
readonly ctx: Context
readonly handles: SubprocessHandle[]
readonly parent: Agent
readonly workspace: string
readonly env: Record<string, string>
}
async function realHarness(behavior: MessagesBehavior): Promise<{
readonly harness: RealHarness
readonly fixture: MessagesFixture
}> {
const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-real-'))
roots.push(root)
const workspace = join(root, 'workspace')
const claudeConfig = join(root, 'claude-config')
const xdgConfig = join(root, 'xdg')
mkdirSync(workspace)
mkdirSync(claudeConfig)
mkdirSync(xdgConfig)
writeFileSync(
join(claudeConfig, 'settings.json'),
`${JSON.stringify({ model: settingsModel }, null, 2)}\n`,
)
const fixture = await startMessagesFixture(behavior)
fixtures.push(fixture)
const env = {
ANTHROPIC_API_KEY: fakeKey,
ANTHROPIC_BASE_URL: fixture.baseUrl,
CLAUDE_CONFIG_DIR: claudeConfig,
HOME: root,
XDG_CONFIG_HOME: xdgConfig,
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: '1',
DISABLE_TELEMETRY: '1',
DISABLE_ERROR_REPORTING: '1',
HTTP_PROXY: '',
HTTPS_PROXY: '',
ALL_PROXY: '',
NO_PROXY: '127.0.0.1,localhost',
}
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
const handles: SubprocessHandle[] = []
const spawn = ctx.subprocess.spawn.bind(ctx.subprocess)
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
const handle = spawn(spec)
handles.push(handle)
return handle
})
await ctx.plugin(claudeCode, { env, disposeGraceMs: 3_000 })
const parent = {
id: 'real-parent',
session: { header: { cwd: workspace } },
} as unknown as Agent
return {
harness: { ctx, handles, parent, workspace, env },
fixture,
}
}
async function expectQuiescent(
handles: readonly SubprocessHandle[],
): Promise<void> {
expect(handles.length).toBeGreaterThan(0)
for (const handle of handles) {
await expect(handle.waitForExit()).resolves.toBe(true)
const outcome = await handle.done
expect(outcome).toHaveProperty('exitCode')
expect(outcome).toHaveProperty('signal')
}
}
function startRequest(
harness: RealHarness,
prompt: string,
signal = new AbortController().signal,
) {
return harness.ctx.subagents.start('claude-code', {
prompt: [{ type: 'text', text: prompt }],
parent: harness.parent,
signal,
})
}
describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', {
timeout: 60_000,
}, () => {
it('inherits host settings and sends the exact task and fake key to local Messages', async () => {
const sentinel = 'REAL_CLAUDE_CODE_SENTINEL_2_1_220'
const task = 'Return the fixture sentinel exactly.'
const { harness, fixture } = await realHarness({
kind: 'complete',
text: sentinel,
})
expect(sdkPackage.version).toBe('0.3.220')
expect(sdkPackage.claudeCodeVersion).toBe('2.1.220')
expect(sdkPackage.optionalDependencies[platformPackage]).toBe('0.3.220')
const version = await execFileAsync(claudeBin, ['--version'], {
env: { ...process.env, ...harness.env },
})
expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)')
const run = await startRequest(harness, task)
await expect(run.result).resolves.toEqual({
output: [{ type: 'text', text: sentinel }],
stopReason: 'completed',
})
await run.dispose()
const initMessage = observedSdkMessages.find(
(message): message is SDKSystemMessage =>
message.type === 'system' && message.subtype === 'init',
)
expect(initMessage?.claude_code_version).toBe('2.1.220')
expect(fixture.requests).toHaveLength(1)
const recorded = fixture.requests[0]!
expect(recorded.method).toBe('POST')
expect(recorded.path).toMatch(/^\/v1\/messages(?:\?.*)?$/)
expect(recorded.headers['x-api-key']).toBe(fakeKey)
expect(recorded.body.model).toBe(settingsModel)
expect(Array.isArray(recorded.body.messages)).toBe(true)
const messageTexts = (
recorded.body.messages as Array<{ content?: unknown }>
).flatMap((message): unknown[] =>
Array.isArray(message.content) ? message.content as unknown[] : [])
.filter((block): block is { type: string; text: string } =>
typeof block === 'object'
&& block !== null
&& 'type' in block
&& block.type === 'text'
&& 'text' in block
&& typeof block.text === 'string')
.map(block => block.text)
expect(messageTexts.filter(text => text.includes(task))).toEqual([task])
await expectQuiescent(harness.handles)
})
it('maps a real CLI process failure to error', async () => {
const { harness, fixture } = await realHarness({ kind: 'hold' })
const run = await startRequest(harness, 'Exercise the failure path.')
await fixture.requestStarted
expect(harness.handles).toHaveLength(1)
harness.handles[0]!.terminate()
await expect(run.result).resolves.toEqual({
output: [],
stopReason: 'error',
})
await run.dispose()
expect(fixture.requests).toHaveLength(1)
expect(fixture.requests[0]!.headers['x-api-key']).toBe(fakeKey)
await expectQuiescent(harness.handles)
})
it('settles cancellation and leaves the real SDK-spawned CLI tree quiescent', async () => {
const { harness, fixture } = await realHarness({ kind: 'hold' })
const controller = new AbortController()
const run = await startRequest(
harness,
'Wait for cancellation.',
controller.signal,
)
await fixture.requestStarted
controller.abort(new Error('real product cancellation'))
await expect(run.result).resolves.toEqual({
output: [],
stopReason: 'aborted',
})
await run.dispose()
await expectQuiescent(harness.handles)
})
})

View File

@@ -0,0 +1,881 @@
import { PassThrough } from 'node:stream'
import type {
Options,
Query,
SDKMessage,
SDKResultMessage,
SpawnOptions,
} from '@anthropic-ai/claude-agent-sdk'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import {
afterEach,
beforeEach,
describe,
expect,
it,
type Mock,
vi,
} from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type {
SubprocessHandle,
SubprocessOutcome,
SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import * as claudeCode from '../src/index.ts'
import * as invariant from '../src/invariant.ts'
import {
claudeSpawnSpec,
ManagedClaudeCodeProcess,
sdkEnvironmentOverlay,
} from '../src/process.ts'
import {
claudeQueryOptions,
consumeClaudeQuery,
disposeClaudeCodeChild,
startClaudeCodeRun,
successfulResult,
textTask,
type ClaudeCodeRunSpec,
} from '../src/run.ts'
type QueryFactory = (params: {
prompt: string
options: Options
}) => Query
const queryMock = vi.hoisted(() => vi.fn<QueryFactory>())
vi.mock('@anthropic-ai/claude-agent-sdk', async importOriginal => ({
...await importOriginal<typeof import('@anthropic-ai/claude-agent-sdk')>(),
query: queryMock,
}))
const fakeParent = {
id: 'parent',
session: { header: { cwd: process.cwd() } },
} as unknown as Agent
function request(
prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }],
signal = new AbortController().signal,
) {
return { prompt, parent: fakeParent, signal }
}
async function nextTask(): Promise<void> {
await new Promise<void>((resolve) => { setImmediate(resolve) })
}
interface FakeChildOptions {
readonly pid?: number
readonly exitOnTerminate?: boolean
readonly waitForExitError?: Error
readonly doneError?: Error
}
interface FakeChild {
readonly handle: SubprocessHandle
readonly stdin: PassThrough
readonly stdout: PassThrough
readonly settle: (outcome?: SubprocessOutcome) => void
readonly fail: (error: Error) => void
readonly terminate: Mock<SubprocessHandle['terminate']>
readonly waitForExit: Mock<SubprocessHandle['waitForExit']>
}
function fakeChild(options: FakeChildOptions = {}): FakeChild {
const stdin = new PassThrough()
const stdout = new PassThrough()
let exited = false
let resolveDone!: (outcome: SubprocessOutcome) => void
let rejectDone!: (error: Error) => void
const done = new Promise<SubprocessOutcome>((resolve, reject) => {
resolveDone = resolve
rejectDone = reject
})
// Individual tests deliberately exercise rejected and still-pending handles.
void done.catch(() => {})
const settle = (
outcome: SubprocessOutcome = { exitCode: 0, signal: null },
): void => {
if (exited) return
exited = true
resolveDone(outcome)
}
const fail = (error: Error): void => {
if (exited) return
exited = true
rejectDone(error)
}
if (options.doneError !== undefined) fail(options.doneError)
const terminate = vi.fn<SubprocessHandle['terminate']>(() => {
if (options.exitOnTerminate !== false) settle()
})
const waitForExit = vi.fn<SubprocessHandle['waitForExit']>(async (signal?: AbortSignal): Promise<boolean> => {
if (options.waitForExitError !== undefined) {
throw options.waitForExitError
}
if (exited) return true
if (signal === undefined) {
await done.catch(() => {})
return true
}
return await new Promise<boolean>((resolve) => {
const onAbort = (): void => { resolve(false) }
signal.addEventListener('abort', onAbort, { once: true })
void done.then(
() => {
signal.removeEventListener('abort', onAbort)
resolve(true)
},
() => {
signal.removeEventListener('abort', onAbort)
resolve(true)
},
)
})
})
const handle: SubprocessHandle = {
pid: options.pid ?? 1234,
stdin,
stdout,
stderr: undefined,
collected: {},
done,
terminate,
waitForExit,
}
return {
handle,
stdin,
stdout,
settle,
fail,
terminate,
waitForExit,
}
}
function success(
result = 'answer',
isError = false,
): SDKResultMessage {
return {
type: 'result',
subtype: 'success',
is_error: isError,
result,
} as SDKResultMessage
}
type ErrorSubtype = Exclude<SDKResultMessage['subtype'], 'success'>
function failure(
subtype: ErrorSubtype,
errors: string[] = ['fixture failure'],
): SDKResultMessage {
return {
type: 'result',
subtype,
is_error: true,
errors,
} as SDKResultMessage
}
function queryFrom(
messages: readonly SDKMessage[],
after?: Error,
close = vi.fn(),
): Query {
async function* stream(): AsyncGenerator<SDKMessage, void> {
for (const message of messages) yield message
if (after !== undefined) throw after
}
return Object.assign(stream(), { close }) as unknown as Query
}
function waitingQuery(signal: AbortSignal, close = vi.fn()): Query {
async function* stream(): AsyncGenerator<SDKMessage, void> {
await new Promise<never>((_resolve, reject) => {
const fail = (): void => {
reject(signal.reason instanceof Error
? signal.reason
: new Error(String(signal.reason)))
}
if (signal.aborted) fail()
else signal.addEventListener('abort', fail, { once: true })
})
}
return Object.assign(stream(), { close }) as unknown as Query
}
function sdkSpawnOptions(
overrides: Partial<SpawnOptions> = {},
): SpawnOptions {
return {
command: '/sdk/claude',
args: ['--output-format', 'stream-json'],
cwd: '/workspace',
env: { PATH: '/bin', OMITTED: undefined },
signal: new AbortController().signal,
...overrides,
}
}
interface FakeRun {
readonly child: FakeChild
readonly close: ReturnType<typeof vi.fn>
readonly spawnSpecs: SubprocessSpawnSpec[]
readonly options: Options[]
readonly spec: ClaudeCodeRunSpec
}
function fakeRun(
messages: readonly SDKMessage[] = [success()],
after?: Error,
child = fakeChild(),
): FakeRun {
const close = vi.fn()
const query = queryFrom(messages, after, close)
const spawnSpecs: SubprocessSpawnSpec[] = []
const options: FakeRun['options'] = []
const spec: ClaudeCodeRunSpec = {
cwd: '/workspace',
env: { ANTHROPIC_API_KEY: 'fake-key' },
disposeGraceMs: 5,
spawn: (spawnSpec) => {
spawnSpecs.push(spawnSpec)
return child.handle
},
}
queryMock.mockImplementation((params) => {
options.push(params.options)
params.options.spawnClaudeCodeProcess!(sdkSpawnOptions())
return query
})
return { child, close, spawnSpecs, options, spec }
}
beforeEach(() => {
queryMock.mockImplementation(({ options }) => {
options.spawnClaudeCodeProcess!(sdkSpawnOptions({
cwd: options.cwd!,
env: options.env!,
signal: options.abortController!.signal,
}))
return queryFrom([])
})
})
afterEach(() => {
queryMock.mockReset()
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
describe('task admission and package contracts', () => {
it('preserves text sequences and rejects empty, blank, and non-text tasks', () => {
expect(textTask([
{ type: 'text', text: 'one' },
{ type: 'text', text: 'two' },
])).toBe('onetwo')
expect(() => textTask([])).toThrow('only text blocks')
expect(() => textTask([{ type: 'reasoning', text: 'hidden' }]))
.toThrow('only text blocks')
expect(() => textTask([{ type: 'text', text: ' \n ' }]))
.toThrow('must not be empty')
})
it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
const fiber = await ctx.plugin(claudeCode, {})
expect(ctx.subagents.getProvider('claude-code')).toMatchObject({
name: 'claude-code',
capabilities: {
outputSchema: false,
depthLimit: false,
toolFilter: false,
persona: false,
},
inheritsParentContext: false,
})
expect(ctx.subagents.list()).toEqual(['claude-code'])
await fiber.dispose()
expect(ctx.subagents.list()).toEqual([])
for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
await expect(ctx.plugin(claudeCode, { disposeGraceMs }))
.rejects.toThrow('disposeGraceMs must be a positive finite number')
}
await expect(ctx.plugin(claudeCode, {
disposeGraceMs: MAX_TIMER_DELAY_MS + 1,
})).rejects.toThrow(
`disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`,
)
await ctx.fiber.dispose()
})
it('starts through the registered provider with its resolved config and diagnostics', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
const child = fakeChild()
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
.mockImplementation(() => child.handle)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
await ctx.plugin(claudeCode, {
env: {
ANTHROPIC_API_KEY: 'provider-fake-key',
CLAUDE_CONFIG_DIR: '/private/tmp/dsh-claude-code-unit-config',
HOME: '/private/tmp/dsh-claude-code-unit-home',
},
disposeGraceMs: 29,
})
await expect(ctx.subagents.start('claude-code', {
...request(),
parent: {
id: 'parent-without-cwd',
session: { header: {} },
} as unknown as Agent,
})).rejects.toThrow(
'subagent-claude-code: no working directory for the child — delegate from a parent session that has one',
)
expect(queryMock).not.toHaveBeenCalled()
const run = await ctx.subagents.start('claude-code', request())
child.settle({ exitCode: 9, signal: null })
child.stdout.end()
await expect(run.result).resolves.toEqual({
output: [],
stopReason: 'error',
})
expect(warn).toHaveBeenCalledWith(expect.stringContaining(
'subagent-claude-code: child run failed (error):',
))
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
cwd: process.cwd(),
graceMs: 29,
}))
expect(spawn.mock.calls[0]?.[0].env).toMatchObject({
ANTHROPIC_API_KEY: 'provider-fake-key',
})
await run.dispose()
await ctx.fiber.dispose()
})
it('keeps the Loader namespace shape and package-owned empty invariant', async () => {
expect('default' in claudeCode).toBe(false)
expect(claudeCode.name).toBe('subagent-claude-code')
expect(claudeCode.inject).toEqual(['subagents', 'subprocess'])
const loader = Object.create(Loader.prototype) as Loader
expect(loader.unwrapExports(claudeCode)).toBe(claudeCode)
const dispose = vi.fn()
const register = vi.fn((
_packageName: string,
_installer: InvariantInstaller,
) => dispose)
const ctx = { invariants: { register } } as unknown as Context
await expect(invariant.apply(ctx)).resolves.toBe(dispose)
expect(register).toHaveBeenCalledWith(
'@deepseek-ai/dsh-subagent-claude-code',
expect.any(Function),
)
const install = register.mock.calls[0]![1]
await install(new Context(), (message) => { throw new Error(message) })
expect(invariant.name).toBe('subagent-claude-code-invariant')
expect(invariant.inject).toEqual(['invariants'])
})
})
describe('official spawn projection', () => {
it('forwards command, arguments, cwd, environment, and signal exactly', () => {
vi.stubEnv('SDK_REMOVED_AMBIENT', 'ambient-value')
const signal = new AbortController().signal
const options = sdkSpawnOptions({
command: '/official/claude',
args: ['--one', 'two'],
cwd: '/parent/workspace',
env: { A: 'one', B: undefined, C: 'three' },
signal,
})
expect(sdkEnvironmentOverlay(options.env)).toEqual(expect.objectContaining({
A: 'one',
B: undefined,
C: 'three',
SDK_REMOVED_AMBIENT: undefined,
}))
const spawnSpec = claudeSpawnSpec(options, 321)
expect(spawnSpec).toMatchObject({
argv: ['/official/claude', '--one', 'two'],
cwd: '/parent/workspace',
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
graceMs: 321,
signal,
})
expect(spawnSpec.env).toEqual(expect.objectContaining({
A: 'one',
B: undefined,
C: 'three',
SDK_REMOVED_AMBIENT: undefined,
}))
const missingCwd = sdkSpawnOptions()
delete missingCwd.cwd
expect(() => claudeSpawnSpec(
missingCwd,
321,
)).toThrow('SDK spawn request omitted its workspace')
expect(() => claudeSpawnSpec(
sdkSpawnOptions({ cwd: '' }),
321,
)).toThrow('SDK spawn request omitted its workspace')
})
it('projects streams, exit facts, listeners, and idempotent tree termination', async () => {
const child = fakeChild({ exitOnTerminate: false })
const process = new ManagedClaudeCodeProcess(child.handle)
expect(process.stdin).toBe(child.stdin)
expect(process.stdout).toBe(child.stdout)
expect(process.killed).toBe(false)
expect(process.exitCode).toBeNull()
expect(process.signalCode).toBeNull()
const exit = vi.fn()
const once = vi.fn()
const removed = vi.fn()
process.on('exit', exit)
process.once('exit', once)
process.on('exit', removed)
process.off('exit', removed)
expect(process.kill('SIGTERM')).toBe(true)
expect(process.killed).toBe(true)
expect(process.kill('SIGKILL')).toBe(false)
expect(child.terminate).toHaveBeenCalledOnce()
child.settle({ exitCode: null, signal: 'SIGTERM' })
await nextTask()
expect(exit).toHaveBeenCalledWith(null, 'SIGTERM')
expect(once).toHaveBeenCalledOnce()
expect(removed).not.toHaveBeenCalled()
expect(process.signalCode).toBe('SIGTERM')
expect(process.kill('SIGTERM')).toBe(false)
})
it('emits spawn errors', async () => {
const child = fakeChild()
const process = new ManagedClaudeCodeProcess(child.handle)
const errorListener = vi.fn()
const removed = vi.fn()
process.once('error', errorListener)
process.on('error', removed)
process.off('error', removed)
child.fail(new Error('spawn boom'))
await nextTask()
expect(errorListener).toHaveBeenCalledWith(expect.objectContaining({
message: 'spawn boom',
}))
expect(removed).not.toHaveBeenCalled()
})
it('exposes a settled direct-child exit code', async () => {
const child = fakeChild()
const process = new ManagedClaudeCodeProcess(child.handle)
child.settle({ exitCode: 7, signal: null })
await nextTask()
expect(process.exitCode).toBe(7)
expect(process.signalCode).toBeNull()
expect(process.kill('SIGTERM')).toBe(false)
})
})
describe('query options and result mapping', () => {
it('builds the fixed unattended options over the scrubbed environment', () => {
vi.stubEnv('HOST_VISIBLE', 'visible')
vi.stubEnv('HOST_SECRET_TOKEN', 'must-not-leak')
vi.stubEnv('DSH_INTERNAL', 'must-not-leak')
const child = fakeChild()
const spawn = vi.fn(() => child.handle)
const captured: SubprocessHandle[] = []
const spec: ClaudeCodeRunSpec = {
cwd: '/workspace',
env: {
HOST_VISIBLE: 'overridden',
ANTHROPIC_API_KEY: 'explicit-fake-key',
},
disposeGraceMs: 17,
spawn,
}
const controller = new AbortController()
const options = claudeQueryOptions(spec, controller, (value) => {
captured.push(value)
})
expect(options).toMatchObject({
abortController: controller,
cwd: '/workspace',
persistSession: false,
disallowedTools: ['AskUserQuestion'],
})
expect(options.env).toMatchObject({
HOST_VISIBLE: 'overridden',
ANTHROPIC_API_KEY: 'explicit-fake-key',
})
expect(options.env).not.toHaveProperty('HOST_SECRET_TOKEN')
expect(options.env).not.toHaveProperty('DSH_INTERNAL')
for (const omitted of [
'settingSources',
'canUseTool',
'onElicitation',
'onUserDialog',
'supportedDialogKinds',
]) {
expect(options).not.toHaveProperty(omitted)
}
const spawned = options.spawnClaudeCodeProcess!(sdkSpawnOptions())
expect(spawned).toBeInstanceOf(ManagedClaudeCodeProcess)
expect(captured).toEqual([child.handle])
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
argv: ['/sdk/claude', '--output-format', 'stream-json'],
cwd: '/workspace',
graceMs: 17,
}))
})
it('accepts only a non-error success with a non-blank final result', () => {
expect(successfulResult(success('exact final'))).toBe('exact final')
expect(() => successfulResult(success('answer', true)))
.toThrow('marked as an error')
expect(() => successfulResult(success(' \n ')))
.toThrow('contained no answer')
expect(() => successfulResult(failure(
'error_during_execution',
['first', 'second'],
))).toThrow('first; second')
expect(() => successfulResult(failure(
'error_max_turns',
[],
))).toThrow('error_max_turns')
})
it('consumes the complete stream and keeps the latest strict success', async () => {
const query = queryFrom([
{ type: 'system', subtype: 'init' } as SDKMessage,
success('first'),
success('last'),
])
await expect(consumeClaudeQuery(query)).resolves.toEqual({
output: [{ type: 'text', text: 'last' }],
stopReason: 'completed',
})
await expect(consumeClaudeQuery(
queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]),
)).rejects.toThrow('ended without a result')
})
})
describe('run publication, cancellation, and settlement', () => {
it('publishes only after Query and managed child exist, then disposes once', async () => {
const fixture = fakeRun([success('exact answer')])
const run = await startClaudeCodeRun(
request([
{ type: 'text', text: 'first' },
{ type: 'text', text: 'second' },
]),
fixture.spec,
)
expect(fixture.options).toHaveLength(1)
expect(fixture.spawnSpecs).toHaveLength(1)
await expect(run.result).resolves.toEqual({
output: [{ type: 'text', text: 'exact answer' }],
stopReason: 'completed',
})
const first = run.dispose()
const second = run.dispose()
expect(second).toBe(first)
await first
expect(fixture.close).toHaveBeenCalledOnce()
expect(fixture.child.terminate).toHaveBeenCalledOnce()
})
it('flattens every SDK error result without inventing shared stop reasons', async () => {
const subtypes: ErrorSubtype[] = [
'error_during_execution',
'error_max_turns',
'error_max_budget_usd',
'error_max_structured_output_retries',
]
for (const subtype of subtypes) {
const fixture = fakeRun([failure(subtype)])
const onError = vi.fn()
const run = await startClaudeCodeRun(
request(),
{ ...fixture.spec, onError },
)
await expect(run.result).resolves.toEqual({
output: [],
stopReason: 'error',
})
expect(onError).toHaveBeenCalledWith(
expect.any(Error),
'error',
)
await run.dispose()
}
})
it('fails closed when iteration rejects after a result', async () => {
const fixture = fakeRun(
[success('partial final')],
new Error('iterator boom'),
)
const run = await startClaudeCodeRun(request(), fixture.spec)
await expect(run.result).resolves.toEqual({
output: [],
stopReason: 'error',
})
await run.dispose()
})
it('maps invalid success and missing result to error', async () => {
for (const messages of [
[success('answer', true)],
[success('')],
[{ type: 'system', subtype: 'init' } as SDKMessage],
]) {
const fixture = fakeRun(messages)
const run = await startClaudeCodeRun(request(), fixture.spec)
await expect(run.result).resolves.toMatchObject({
stopReason: 'error',
})
await run.dispose()
}
})
it('gives local cancellation precedence and isolates overlapping controllers', async () => {
const firstChild = fakeChild()
const secondChild = fakeChild()
const children = [firstChild, secondChild]
const controllers: AbortController[] = []
let index = 0
const spec: ClaudeCodeRunSpec = {
cwd: '/workspace',
env: {},
disposeGraceMs: 5,
spawn: () => children[index++]!.handle,
}
queryMock.mockImplementation(({ prompt, options }) => {
controllers.push(options.abortController!)
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
return prompt === 'wait'
? waitingQuery(options.abortController!.signal)
: queryFrom([success('second answer')])
})
const firstAbort = new AbortController()
const first = await startClaudeCodeRun(
request([{ type: 'text', text: 'wait' }], firstAbort.signal),
spec,
)
const second = await startClaudeCodeRun(
request([{ type: 'text', text: 'finish' }]),
spec,
)
expect(controllers).toHaveLength(2)
expect(controllers[0]).not.toBe(controllers[1])
firstAbort.abort(new Error('parent cancelled'))
await expect(first.result).resolves.toEqual({
output: [],
stopReason: 'aborted',
})
await expect(second.result).resolves.toEqual({
output: [{ type: 'text', text: 'second answer' }],
stopReason: 'completed',
})
expect(controllers[1]!.signal.aborted).toBe(false)
await Promise.all([first.dispose(), second.dispose()])
})
it('keeps local cancellation authoritative when the SDK iterator ends normally', async () => {
const parentAbort = new AbortController()
const child = fakeChild()
async function* stream(): AsyncGenerator<SDKMessage, void> {
yield success('candidate answer')
parentAbort.abort(new Error('parent cancelled at iterator completion'))
}
queryMock.mockImplementation(({ options }) => {
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
return Object.assign(stream(), { close: vi.fn() }) as unknown as Query
})
const run = await startClaudeCodeRun(
request(undefined, parentAbort.signal),
{
cwd: '/workspace',
env: {},
disposeGraceMs: 5,
spawn: () => child.handle,
},
)
await expect(run.result).resolves.toEqual({
output: [],
stopReason: 'aborted',
})
await run.dispose()
})
it('rejects pre-abort and every incomplete startup transaction', async () => {
const preAborted = new AbortController()
preAborted.abort()
const unused = fakeRun()
await expect(startClaudeCodeRun(
request(undefined, preAborted.signal),
unused.spec,
)).rejects.toThrow('aborted before SDK startup')
expect(unused.options).toEqual([])
const noChildClose = vi.fn()
queryMock.mockImplementationOnce(
() => queryFrom([], undefined, noChildClose),
)
await expect(startClaudeCodeRun(request(), {
...unused.spec,
})).rejects.toThrow('did not publish a controllable')
expect(noChildClose).toHaveBeenCalledOnce()
const closeFailure = vi.fn(() => { throw new Error('close boom') })
queryMock.mockImplementationOnce(
() => queryFrom([], undefined, closeFailure),
)
const noChild = startClaudeCodeRun(request(), {
...unused.spec,
})
await expect(noChild).rejects.toBeInstanceOf(AggregateError)
const startupAbort = new AbortController()
const abortedChild = fakeChild()
const abortedClose = vi.fn()
queryMock.mockImplementationOnce(({ options }) => {
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
startupAbort.abort(new Error('startup cancelled'))
return queryFrom([], undefined, abortedClose)
})
const abortedDuringStartup = startClaudeCodeRun(
request(undefined, startupAbort.signal),
{
...unused.spec,
spawn: () => abortedChild.handle,
},
)
await expect(abortedDuringStartup)
.rejects.toThrow('aborted before SDK startup')
expect(abortedClose).toHaveBeenCalledOnce()
expect(abortedChild.terminate).toHaveBeenCalledOnce()
queryMock.mockImplementationOnce(() => {
throw new Error('query failed before resource creation')
})
await expect(startClaudeCodeRun(request(), {
...unused.spec,
})).rejects.toThrow('query failed before resource creation')
const spawned = fakeChild()
const spawnSpecs: SubprocessSpawnSpec[] = []
let factoryController: AbortController | undefined
queryMock.mockImplementationOnce(({ options }) => {
factoryController = options.abortController
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
throw new Error('query construction failed')
})
const factoryFailure = startClaudeCodeRun(request(), {
...unused.spec,
spawn: (spawnSpec) => {
spawnSpecs.push(spawnSpec)
return spawned.handle
},
})
await expect(factoryFailure).rejects.toThrow('query construction failed')
expect(spawnSpecs).toHaveLength(1)
expect(factoryController?.signal.aborted).toBe(true)
expect(spawned.terminate).toHaveBeenCalledOnce()
const failedSpawn = fakeChild({
pid: -1,
doneError: new Error('spawn failed'),
})
const failed = fakeRun([], undefined, failedSpawn)
await expect(startClaudeCodeRun(request(), failed.spec))
.rejects.toBeInstanceOf(AggregateError)
expect(failed.close).toHaveBeenCalledOnce()
})
})
describe('query and process disposal', () => {
it('closes the query, terminates the tree, and waits for direct-child outcome', async () => {
const child = fakeChild()
const close = vi.fn()
await disposeClaudeCodeChild({ close }, child.handle)
expect(close).toHaveBeenCalledOnce()
expect(child.terminate).toHaveBeenCalledOnce()
expect(child.waitForExit).toHaveBeenCalledOnce()
expect(child.waitForExit).toHaveBeenCalledWith()
await expect(child.handle.done).resolves.toEqual({
exitCode: 0,
signal: null,
})
})
it('does not finish disposal before the managed tree exits', async () => {
const child = fakeChild({ exitOnTerminate: false })
let disposed = false
const disposal = disposeClaudeCodeChild(
{ close: vi.fn() },
child.handle,
).then(() => {
disposed = true
})
await nextTask()
expect(disposed).toBe(false)
child.settle()
await disposal
expect(disposed).toBe(true)
})
it('reports wait, close, and direct-child failures without skipping cleanup', async () => {
const waitFailure = fakeChild({
waitForExitError: new Error('wait boom'),
})
const closeFailure = vi.fn(() => { throw new Error('close boom') })
await expect(disposeClaudeCodeChild(
{ close: closeFailure },
waitFailure.handle,
)).rejects.toBeInstanceOf(AggregateError)
expect(waitFailure.terminate).toHaveBeenCalledOnce()
const doneFailure = fakeChild({
pid: -1,
doneError: new Error('spawn boom'),
})
await expect(disposeClaudeCodeChild(
{ close: vi.fn() },
doneFailure.handle,
)).rejects.toThrow('spawn boom')
const both = fakeChild({
pid: -1,
doneError: new Error('spawn boom'),
})
await expect(disposeClaudeCodeChild(
{ close: () => { throw new Error('close boom') } },
both.handle,
)).rejects.toBeInstanceOf(AggregateError)
})
})

View File

@@ -0,0 +1,37 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/types/.tsbuildinfo"
},
"include": [
"src/**/*.ts"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../subagent"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../util/timeout"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent-codex/README.md
README.md: c25ee90edf8972da66448fe84cb659b0aec79e6f
README.zh.md: 10c8fcc47a9ab04bca983857bd44ede265c23435

View File

@@ -0,0 +1,90 @@
# @deepseek-ai/dsh-subagent-codex
English | [中文](README.zh.md)
This package registers the fixed `codex` subagent provider. Each accepted run starts the official `codex app-server --stdio` command in the delegating Session's workspace, creates one ephemeral Codex thread, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract.
## Start and ownership
`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize``initialized``thread/start { cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`.
The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error.
For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run.
Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and this version produces no `refusal`. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate.
## Capabilities and context
The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Codex receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. The ephemeral Codex thread id and turn id stay private to this run and are never persisted in the parent Session.
## Configuration
| Key | Default | Meaning |
|---|---|---|
| `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. |
| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. |
Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden.
Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_codex` by default.
```yaml
- id: subagent-codex
name: '@deepseek-ai/dsh-subagent-codex'
config:
env:
OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY
- id: tool-subagent-codex
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex
toolName: subagent_codex
enableRunInBackground: false
maxDepth: provider-managed
```
## Product compatibility and evidence
The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`: the keyless real-product spec drives the official binary against a loopback Responses service with a non-empty fake key and proves the task, authentication, exact answer, cancellation, approvals, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration with no `codex` command available, verifies the fixed provider and foreground-only tool schema, and records zero child starts. A credentialed e2e starts the production provider and real Codex, then obtains a unique answer from the fixed official DeepSeek service through a loopback-only test bridge from Responses to Chat Completions; that bridge is not production functionality or native Codex support for DeepSeek's Chat Completions API. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`.
## Model Experience
### Child request
#### What the model sees
The Codex child receives the standalone text blocks as one turn in a fresh ephemeral thread. Its workspace is the parent Session cwd, and its model, system instructions, tools, sandbox, and authentication come from the native Codex installation and configuration.
#### Token effect
The child pays for an independent Codex context and turn. Child tokens do not enter the parent's context.
#### KV Cache effect
Independent of the parent request cache. Reuse depends only on Codex's own provider, model, instructions, tools, and ephemeral-thread request.
### Parent tool result, indirectly
#### What the model sees
Through `dsh-tool-subagent`, the parent sees only the selected final Codex answer or the consumer's exact error for a non-completed result. Codex commentary, reasoning, tool activity, stderr, workspace diffs, and product ids are not copied into the parent Session.
#### Token effect
Parent input grows only by the final answer or error retained in the tool result. This provider adds no parent tool schema by itself.
#### KV Cache effect
Append-only: the new tool result follows the reusable parent request prefix.
## Known Limitations and Deferred Work
- **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence.
- **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate.
- **Compatibility is pinned by development evidence** — upgrading from the verified 0.146.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests.
- **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; deployments cannot configure an allow policy through this package.
- **Final text only** — reasoning, commentary, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local.
- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider.
- **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored.

View File

@@ -0,0 +1,90 @@
# @deepseek-ai/dsh-subagent-codex
[English](README.md) | 中文
本包package注册固定的 `codex` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中启动官方 `codex app-server --stdio` 命令,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果契约仅返回最终答案。
## 启动与所有权
`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize``initialized``thread/start { cwd, ephemeral: true }`,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。
已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"``agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。
对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应或是未知服务器请求此次运行就会失败。
本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次的 `codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且本版本不会产生 `refusal``dispose()` 具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。
## 能力与上下文
本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Codex 会接收独立文本任务和父会话 cwd但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出契约。临时 Codex 线程 ID 与轮次 ID 仅在此次运行内部可见,绝不会持久化到父会话。
## 配置
| 配置键 | 默认值 | 含义 |
|---|---|---|
| `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 |
| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 |
生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH``HOME` 等普通环境变量值仍然可用。
请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_codex`
```yaml
- id: subagent-codex
name: '@deepseek-ai/dsh-subagent-codex'
config:
env:
OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY
- id: tool-subagent-codex
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex
toolName: subagent_codex
enableRunInBackground: false
maxDepth: provider-managed
```
## 产品兼容性与证据
生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:无密钥真实产品测试使用非空的伪密钥,驱动官方二进制程序连接回环 Responses 服务,并证明任务、身份验证、精确回答、取消、审批与进程树退出。独立的 Loader 装配 e2e 会在没有可用 `codex` 命令时启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的工具 schema并记录零次子级启动。带密钥 e2e 会启动生产提供方和真实 Codex再通过一个仅限回环、将 Responses 转为 Chat Completions 的测试桥接层,从固定的 DeepSeek 官方服务获得唯一答案;该桥接层既不属于生产功能,也不代表 Codex 原生支持 DeepSeek 的 Chat Completions API。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`
## 模型体验
### 子任务请求
#### 模型看到的内容
Codex 子任务会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd其模型、系统指令、工具、沙箱和身份验证来自原生 Codex 安装与配置。
#### 对 token 的影响
子任务需为独立的 Codex 上下文和轮次承担 token 开销。子任务 token 不会进入父级上下文。
#### 对 KV Cache 的影响
这与父请求缓存相互独立。能否复用只取决于 Codex 自身的提供方、模型、指令、工具和临时线程请求。
### 父级工具结果(间接)
#### 模型看到的内容
通过 `dsh-tool-subagent`,父级模型只会看到选定的 Codex 最终答案或者在结果未完成时看到消费方给出的原样错误。Codex 的过程说明、推理reasoning、工具活动、stderr、工作区差异和产品标识符均不会复制到父会话。
#### 对 token 的影响
父级输入只会增加工具结果中保留的最终答案或错误内容。本提供方自身不添加父级工具 schema。
#### 对 KV Cache 的影响
仅追加:新的工具结果接在可复用的父请求前缀之后。
## 已知限制与后续工作
- **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。
- **产品安装和账户状态由宿主管理**`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。
- **兼容性由开发证据锁定**:若要从已验证的 0.146.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。
- **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;部署方无法通过本包配置允许策略。
- **仅返回最终文本**推理、过程说明、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。
- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。
- **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。

View File

@@ -0,0 +1,55 @@
{
"name": "@deepseek-ai/dsh-subagent-codex",
"description": "One-shot Codex subagent provider over the official app-server protocol",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-sdk-protocol": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-sdk-protocol": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@openai/codex": "0.146.0",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,101 @@
/**
* Fixed Codex one-shot subagent provider. Every accepted run starts a fresh
* official `codex app-server --stdio` process in the delegating Session's
* workspace and publishes only after an ephemeral thread exists.
*
* @module @deepseek-ai/dsh-subagent-codex
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import {
assertPositiveFinite,
NO_START_CAPABILITIES,
resolveChildCwd,
type ResolvedSubagentStartRequest,
type SubagentCapabilities,
type SubagentProvider,
} from '@deepseek-ai/dsh-subagent'
import {
DEFAULT_DISPOSE_GRACE_MS,
startCodexRun,
type CodexRunSpec,
} from './run.ts'
export const name = 'subagent-codex'
export const inject = ['subagents', 'subprocess']
/** Deployment-owned environment and process-release bound. */
export interface Config {
/**
* Explicit environment entries layered over the subprocess seam's
* credential-scrubbed parent environment.
*/
env?: Record<string, string>
/** Grace in milliseconds for app-server process-tree termination. */
disposeGraceMs?: number
}
export const Config: z<Config> = z.object({
env: z.dict(z.string()).default({}),
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
})
type ResolvedConfig = Required<Config>
class CodexProvider implements SubagentProvider {
readonly name = 'codex'
readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES
readonly inheritsParentContext = false
constructor(
private readonly ctx: Context,
private readonly config: ResolvedConfig,
) {}
start(request: ResolvedSubagentStartRequest) {
const parentCwd = request.parent.session.header.cwd
if (parentCwd === undefined) {
throw new Error(
'subagent-codex: no working directory for the child — delegate from a parent session that has one',
)
}
const spec: CodexRunSpec = {
cwd: resolveChildCwd(
'subagent-codex',
undefined,
parentCwd,
),
env: this.config.env,
disposeGraceMs: this.config.disposeGraceMs,
spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec),
onError: (error, stopReason) => {
this.ctx.logger.warn(
`subagent-codex: child run failed (${stopReason}): ${error.message}`,
)
},
}
return startCodexRun(request, spec)
}
}
/**
* Register the fixed `codex` provider.
* @param ctx - context carrying shared subagent and subprocess services.
* @param config - explicit child environment and disposal grace.
*/
export function apply(ctx: Context, config: Config): void {
const resolved = config as ResolvedConfig
assertPositiveFinite(
'subagent-codex',
'disposeGraceMs',
resolved.disposeGraceMs,
)
if (resolved.disposeGraceMs > MAX_TIMER_DELAY_MS) {
throw new Error(
`subagent-codex: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
ctx.subagents.registerProvider(new CodexProvider(ctx, resolved))
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-codex`.
* @module @deepseek-ai/dsh-subagent-codex/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-codex'
/** Cordis companion plugin name. */
export const name = 'subagent-codex-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: lifecycle pairing belongs to the shared subagent
* service and process-tree ownership belongs to the subprocess service.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - plugin context carrying the invariant registry.
* @returns the installed registration's disposer.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,200 @@
/**
* One-shot Codex child lifecycle: spawn the real app-server through the
* subprocess seam, publish only after initialization and ephemeral thread
* creation, flatten post-publication failures, and dispose to whole-tree
* quiescence.
*
* @module @deepseek-ai/dsh-subagent-codex/run
*/
import { randomUUID } from 'node:crypto'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
settleRunResult,
subprocessRunHandle,
type SubagentResult,
type SubagentRun,
type SubagentStartRequest,
type SubagentStopReason,
} from '@deepseek-ai/dsh-subagent'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { CodexAppServerWire } from './wire.ts'
/** Default POSIX grace between subprocess termination tiers. */
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
/**
* Resolve the fixed app-server command for a platform.
*
* Windows npm and pnpm installs expose `codex.cmd`, which requires `cmd.exe`;
* the argv is constant so no task or configuration text enters the
* shell boundary.
* @param platform - host platform used to select the executable boundary.
* @returns argv for the fixed Codex app-server command.
*/
export function codexAppServerArgv(
platform: NodeJS.Platform = process.platform,
): string[] {
return platform === 'win32'
? ['cmd.exe', '/d', '/s', '/c', 'codex', 'app-server', '--stdio']
: ['codex', 'app-server', '--stdio']
}
/** Fully resolved inputs for one Codex app-server run. */
export interface CodexRunSpec {
/** Parent Session workspace, also supplied to `thread/start`. */
readonly cwd: string
/** Explicit deployment/test environment layered after the shared scrub. */
readonly env: Record<string, string>
/** Subprocess termination grace passed to the shared process-tree owner. */
readonly disposeGraceMs: number
/** Shared subprocess service spawn operation. */
readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
/** Diagnostic sink for a post-publication error flattened into a result. */
readonly onError?: (error: Error, stopReason: SubagentStopReason) => void
}
function thrown(value: unknown): Error {
/* v8 ignore next -- typed subprocess/wire failures reject with Error. */
return value instanceof Error ? value : new Error(String(value))
}
/**
* Validate and preserve the one-shot task before crossing the process seam.
* @param prompt - task content accepted from the shared subagent service.
* @returns the exact non-empty text block sequence.
*/
export function textTask(prompt: readonly ContentBlock[]): string[] {
if (prompt.length === 0) {
throw new Error('subagent-codex: the one-shot task must contain only text blocks')
}
const texts: string[] = []
for (const block of prompt) {
if (block.type !== 'text') {
throw new Error('subagent-codex: the one-shot task must contain only text blocks')
}
texts.push(block.text)
}
if (texts.every(text => text.trim().length === 0)) {
throw new Error('subagent-codex: the one-shot task must not be empty')
}
return texts
}
/**
* Close the private wire, terminate the managed process tree, and wait for the
* subprocess owner to prove it is gone.
* @param wire - private app-server protocol connection.
* @param child - shared-service handle that owns the process tree.
*/
export async function disposeCodexChild(
wire: CodexAppServerWire,
child: SubprocessHandle,
): Promise<void> {
wire.close()
if (child.pid <= 0) {
await child.done.catch(() => {})
return
}
try {
child.stdin?.end()
} catch {
// A concurrently closed stdin does not change tree ownership below.
}
child.terminate()
await child.waitForExit()
await child.done
}
/**
* Start the real `codex app-server --stdio` child and publish its one-shot run.
* @param request - resolved shared subagent request.
* @param spec - workspace, environment, process seam, and diagnostic policy.
* @returns the published run after initialization and ephemeral thread creation.
*/
export async function startCodexRun(
request: SubagentStartRequest,
spec: CodexRunSpec,
): Promise<SubagentRun> {
const texts = textTask(request.prompt)
if (request.signal.aborted) {
throw new Error('subagent-codex: request was aborted before app-server startup')
}
const child = spec.spawn({
argv: codexAppServerArgv(),
cwd: spec.cwd,
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
graceMs: spec.disposeGraceMs,
env: spec.env,
})
const wire = new CodexAppServerWire(
child.stdout as NonNullable<SubprocessHandle['stdout']>,
child.stdin as NonNullable<SubprocessHandle['stdin']>,
)
const disposeProcess = (): Promise<void> => disposeCodexChild(wire, child)
const processFailure: Promise<never> = child.done.then(
outcome => Promise.reject(new Error(
'subagent-codex: app-server exited before the run settled '
+ `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`,
)),
(error: unknown) => Promise.reject(thrown(error)),
)
// A normal post-result dispose also closes the process. Keep that expected
// late rejection observed after the result race has already settled.
processFailure.catch(() => {})
const runAbort = new AbortController()
const requestCancel = (): void => {
if (runAbort.signal.aborted) return
runAbort.abort(new Error('subagent-codex: run cancelled locally'))
wire.interrupt()
}
const onAbort = (): void => { requestCancel() }
request.signal.addEventListener('abort', onAbort, { once: true })
try {
wire.start()
await Promise.race([wire.initialize(request.signal), processFailure])
await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure])
} catch (error: unknown) {
request.signal.removeEventListener('abort', onAbort)
try {
await disposeProcess()
} catch (disposeError: unknown) {
throw new AggregateError(
[thrown(error), thrown(disposeError)],
'subagent-codex: startup failed and app-server cleanup also failed',
)
}
if (runAbort.signal.aborted) {
throw new Error('subagent-codex: request was aborted before run publication')
}
throw thrown(error)
}
const collectOutput = (): ContentBlock[] => wire.collectOutput()
const result: Promise<SubagentResult> = settleRunResult({
attempt: () => Promise.race([
wire.runTurn(texts, runAbort.signal),
processFailure,
]),
collectOutput,
cancelled: () => runAbort.signal.aborted,
onError: spec.onError,
signal: request.signal,
onAbort,
})
return subprocessRunHandle({
id: SessionId(randomUUID()),
result,
signal: request.signal,
onAbort,
requestCancel,
teardown: disposeProcess,
})
}

View File

@@ -0,0 +1,374 @@
/**
* Minimal Codex app-server 0.146.0 protocol adapter. The shared JSON-RPC
* transport owns framing and request correlation; this module owns only the
* product methods, current thread/turn association, unattended approval
* responses, and terminal-answer selection.
*
* @module @deepseek-ai/dsh-subagent-codex/wire
*/
import type { Readable, Writable } from 'node:stream'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SubagentResult } from '@deepseek-ai/dsh-subagent'
import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol'
type JsonObject = Record<string, unknown>
function object(value: unknown, label: string): JsonObject {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`subagent-codex: app-server returned invalid ${label}`)
}
return value as JsonObject
}
function string(value: unknown, label: string): string {
if (typeof value !== 'string' || value.length === 0) {
throw new Error(`subagent-codex: app-server returned invalid ${label}`)
}
return value
}
function unattendedDecision(params: JsonObject): 'cancel' | 'decline' {
const available = params.availableDecisions
if (available === undefined || available === null) return 'decline'
if (Array.isArray(available)) {
if (available.includes('cancel')) return 'cancel'
if (available.includes('decline')) return 'decline'
}
throw new Error('subagent-codex: app-server offered no unattended approval decision')
}
function isContextWindowExceeded(turn: JsonObject): boolean {
if (turn.status !== 'failed') return false
const error = turn.error
return error !== null
&& typeof error === 'object'
&& !Array.isArray(error)
&& (error as JsonObject).codexErrorInfo === 'contextWindowExceeded'
}
function thrown(value: unknown): Error {
/* v8 ignore next -- typed protocol and stream failures reject with Error. */
return value instanceof Error ? value : new Error(String(value))
}
function abortError(signal: AbortSignal): Error {
return signal.reason instanceof Error
? signal.reason
: new Error(`subagent-codex: app-server request aborted: ${String(signal.reason)}`)
}
async function raceAbort<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) {
void pending.catch(() => {})
throw abortError(signal)
}
let rejectAbort!: (error: Error) => void
const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
const onAbort = (): void => { rejectAbort(abortError(signal)) }
signal.addEventListener('abort', onAbort, { once: true })
try {
return await Promise.race([pending, aborted])
} finally {
signal.removeEventListener('abort', onAbort)
}
}
/**
* One app-server connection and its single ephemeral thread/turn.
*
* The class deliberately exposes no generic request surface. Supporting
* another product method must first become part of the provider contract.
*/
export class CodexAppServerWire {
private readonly transport: JsonRpcLineTransport
private readonly fatal = Promise.withResolvers<never>()
private threadId: string | undefined
private turnId: string | undefined
private pendingTurnId: string | undefined
private turnCompleted: PromiseWithResolvers<JsonObject> | undefined
private readonly earlyTurnNotifications: Array<{
readonly method: string
readonly params: JsonObject
}> = []
private lastFinalAnswer: string | undefined
private lastUnphasedAnswer: string | undefined
private closed = false
constructor(
private readonly input: Readable,
output: Writable,
) {
this.transport = new JsonRpcLineTransport(input, output)
// Fatal protocol state can arrive after the current guarded operation has
// already settled. Keep the shared rejection observed without inserting
// another promise-adoption hop into active races.
void this.fatal.promise.catch(() => {})
this.transport.onRequest((method, params) => this.handleServerRequest(method, params))
this.transport.onNotification((method, params) => {
try {
this.handleNotification(method, params)
} catch (error: unknown) {
this.fail(thrown(error))
}
})
this.input.on('error', this.onInputError)
this.input.on('end', this.onInputEnd)
// Pipe errors can race protocol closure and process teardown. Retain both
// error listeners for the lifetime of their per-run streams so no late
// EPIPE or read failure becomes an unhandled EventEmitter error.
output.on('error', this.onOutputError)
}
/** Start reading app-server frames. */
start(): void {
this.transport.start()
}
/**
* Perform the required app-server initialize/initialized handshake.
* @param signal - unpublished-start cancellation.
*/
async initialize(signal: AbortSignal): Promise<void> {
object(await this.guarded(this.transport.request('initialize', {
clientInfo: {
name: 'deepseek-harness',
title: 'DeepSeek Harness',
version: '0.0.1',
},
capabilities: {
experimentalApi: false,
requestAttestation: false,
},
}, signal), signal), 'initialize response')
this.transport.notify('initialized')
await this.guarded(this.transport.flush(), signal)
}
/**
* Create the run's private ephemeral thread and retain its identity.
* @param cwd - parent Session workspace.
* @param signal - unpublished-start cancellation.
*/
async startThread(cwd: string, signal: AbortSignal): Promise<void> {
const response = object(await this.guarded(this.transport.request('thread/start', {
cwd,
ephemeral: true,
}, signal), signal), 'thread/start response')
const thread = object(response.thread, 'thread/start thread')
const id = string(thread.id, 'thread/start thread id')
if (thread.ephemeral !== true) {
throw new Error('subagent-codex: app-server did not create an ephemeral thread')
}
this.threadId = id
}
/**
* Submit the one text-only task and wait for this thread/turn's authoritative
* terminal notification.
* @param texts - already validated task text blocks.
* @param signal - local cancellation for the published run.
* @returns the shared subagent result.
*/
async runTurn(
texts: readonly string[],
signal: AbortSignal,
): Promise<SubagentResult> {
const completion = Promise.withResolvers<JsonObject>()
this.turnCompleted = completion
const threadId = this.threadId as string
const response = object(await this.guarded(this.transport.request('turn/start', {
threadId,
input: texts.map(text => ({ type: 'text', text, text_elements: [] })),
}, signal), signal), 'turn/start response')
const turn = object(response.turn, 'turn/start turn')
this.commitTurnId(string(turn.id, 'turn/start turn id'))
const completed = await this.guarded(completion.promise, signal)
const terminal = object(completed.turn, 'turn/completed turn')
const status = terminal.status
if (isContextWindowExceeded(terminal)) {
return { output: this.collectOutput(), stopReason: 'max-tokens' }
}
if (status !== 'completed') {
const detail = status === 'failed'
? `: ${JSON.stringify(terminal.error)}`
: ''
throw new Error(`subagent-codex: Codex turn ended with status ${String(status)}${detail}`)
}
const output = this.collectOutput()
if (output.length === 0) {
throw new Error('subagent-codex: Codex completed without a final answer')
}
return { output, stopReason: 'completed' }
}
/**
* Best-effort remote cancellation. Local settlement and process teardown
* remain authoritative when the child no longer accepts protocol requests.
*/
interrupt(): void {
if (this.threadId === undefined || this.turnId === undefined || this.closed) return
void this.transport.request('turn/interrupt', {
threadId: this.threadId,
turnId: this.turnId,
}).catch(() => {})
}
/**
* The best non-commentary answer observed so far, preserving exact bytes.
* @returns the selected final or nullable-phase text block, if any.
*/
collectOutput(): ContentBlock[] {
const selected = this.lastFinalAnswer ?? this.lastUnphasedAnswer
return selected !== undefined && selected.trim().length > 0
? [{ type: 'text', text: selected }]
: []
}
/** Detach JSON-RPC listeners and reject outstanding requests. Idempotent. */
close(): void {
if (this.closed) return
this.closed = true
this.input.off('end', this.onInputEnd)
this.transport.close()
}
private async guarded<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
const withFatal = Promise.race([this.fatal.promise, pending])
return raceAbort(withFatal, signal)
}
private fail(error: Error): void {
this.fatal.reject(error)
}
private readonly onInputError = (error: Error): void => {
this.fail(error)
}
private readonly onOutputError = (error: Error): void => {
this.fail(error)
}
private readonly onInputEnd = (): void => {
this.fail(new Error('subagent-codex: app-server protocol stream closed'))
}
private observePendingTurnId(id: string): void {
if (this.turnCompleted === undefined) {
throw new Error('subagent-codex: app-server referenced a turn before turn/start')
}
if (this.pendingTurnId !== undefined && this.pendingTurnId !== id) {
throw new Error('subagent-codex: app-server referenced conflicting turns')
}
this.pendingTurnId = id
}
private commitTurnId(id: string): void {
if (this.pendingTurnId !== undefined && this.pendingTurnId !== id) {
throw new Error('subagent-codex: turn/start response did not match the active turn')
}
this.turnId = id
const notifications = this.earlyTurnNotifications.splice(0)
for (const notification of notifications) {
this.handleNotification(notification.method, notification.params)
}
}
private validateRunIds(params: JsonObject, nullableTurn = false): void {
if (params.threadId !== this.threadId) {
throw new Error('subagent-codex: app-server request referenced another thread')
}
if (nullableTurn && params.turnId === null) return
const id = string(params.turnId, 'server request turn id')
if (this.turnId === undefined) {
this.observePendingTurnId(id)
return
}
if (id !== this.turnId) {
throw new Error('subagent-codex: app-server request referenced another turn')
}
}
private handleServerRequest(method: string, params: JsonObject): Promise<unknown> {
try {
switch (method) {
case 'item/commandExecution/requestApproval':
case 'item/fileChange/requestApproval':
this.validateRunIds(params)
return Promise.resolve({ decision: unattendedDecision(params) })
case 'item/permissions/requestApproval':
this.validateRunIds(params)
return Promise.resolve({ permissions: {}, scope: 'turn' })
case 'item/tool/requestUserInput':
this.validateRunIds(params)
return Promise.resolve({ answers: {} })
case 'mcpServer/elicitation/request':
this.validateRunIds(params, true)
return Promise.resolve({ action: 'decline', content: null, _meta: null })
default:
throw new Error(`subagent-codex: unsupported app-server request ${JSON.stringify(method)}`)
}
} catch (error: unknown) {
const normalized = thrown(error)
this.fail(normalized)
return Promise.reject(normalized)
}
}
private handleNotification(method: string, params: JsonObject): void {
if (method === 'turn/started') {
const threadId = string(params.threadId, 'turn/started thread id')
if (threadId !== this.threadId) return
const turn = object(params.turn, 'turn/started turn')
if (this.turnCompleted !== undefined && this.turnId === undefined) {
this.observePendingTurnId(string(turn.id, 'turn/started turn id'))
}
return
}
if (method === 'item/completed') {
const threadId = string(params.threadId, 'item/completed thread id')
if (threadId !== this.threadId) return
const id = string(params.turnId, 'item/completed turn id')
if (this.turnId === undefined) {
if (this.turnCompleted !== undefined) {
this.observePendingTurnId(id)
this.earlyTurnNotifications.push({ method, params })
}
return
}
if (id !== this.turnId) return
const item = object(params.item, 'item/completed item')
if (item.type !== 'agentMessage') return
const text = typeof item.text === 'string'
? item.text
: (() => { throw new Error('subagent-codex: app-server returned an invalid agent message') })()
if (item.phase === 'final_answer') {
this.lastFinalAnswer = text
} else if (item.phase === null) {
this.lastUnphasedAnswer = text
} else if (item.phase !== 'commentary') {
throw new Error(`subagent-codex: app-server returned an unknown agent message phase ${JSON.stringify(item.phase)}`)
}
return
}
if (method !== 'turn/completed') return
const threadId = string(params.threadId, 'turn/completed thread id')
if (threadId !== this.threadId) return
const turn = object(params.turn, 'turn/completed turn')
const id = string(turn.id, 'turn/completed turn id')
const turnCompleted = this.turnCompleted
if (turnCompleted === undefined) return
if (this.turnId === undefined) {
this.observePendingTurnId(id)
this.earlyTurnNotifications.push({ method, params })
return
}
if (id !== this.turnId) return
if (!['completed', 'interrupted', 'failed'].includes(String(turn.status))) {
throw new Error(`subagent-codex: app-server returned invalid terminal turn status ${String(turn.status)}`)
}
turnCompleted.resolve(params)
}
}

View File

@@ -0,0 +1,190 @@
import { createServer } from 'node:http'
import type {
IncomingMessage,
Server,
ServerResponse,
} from 'node:http'
import { completeResponsesEvents } from './responses-fixture.ts'
const OFFICIAL_DEEPSEEK_BASE_URL = 'https://api.deepseek.com'
const MAX_REQUEST_BYTES = 1_048_576
/** One running test-only Responses-to-DeepSeek bridge. */
export interface DeepSeekResponsesBridge {
readonly baseUrl: string
readonly completedRequests: number
close(): Promise<void>
}
function readRequest(request: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let body = ''
request.setEncoding('utf8')
request.on('data', (chunk: string) => {
body += chunk
if (Buffer.byteLength(body) > MAX_REQUEST_BYTES) {
request.destroy(new Error('DeepSeek bridge request exceeded its byte limit'))
}
})
request.on('end', () => { resolve(body) })
request.on('error', reject)
})
}
function responseInputTexts(body: Record<string, unknown>): string[] {
if (!Array.isArray(body.input)) return []
return body.input.flatMap((item): string[] => {
if (item === null || typeof item !== 'object') return []
const content = (item as Record<string, unknown>).content
if (!Array.isArray(content)) return []
return content.flatMap((part): string[] => (
part !== null
&& typeof part === 'object'
&& typeof (part as Record<string, unknown>).text === 'string'
? [(part as Record<string, unknown>).text as string]
: []
))
})
}
function taskText(body: Record<string, unknown>): string {
const input = responseInputTexts(body).join('\n')
if (input.trim().length > 0) return input
return typeof body.instructions === 'string' ? body.instructions : ''
}
function deepSeekBaseUrl(): string {
const configured = (process.env.DEEPSEEK_BASE_URL ?? OFFICIAL_DEEPSEEK_BASE_URL)
.replace(/\/+$/, '')
if (configured !== OFFICIAL_DEEPSEEK_BASE_URL) {
throw new Error('Codex DeepSeek e2e requires the official DeepSeek base URL')
}
return configured
}
async function completeWithDeepSeek(
authorization: string,
task: string,
): Promise<string> {
const response = await fetch(`${deepSeekBaseUrl()}/chat/completions`, {
method: 'POST',
headers: {
authorization,
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [
{
role: 'system',
content: 'Follow the user instruction and return only the requested nonce.',
},
{ role: 'user', content: task },
],
temperature: 0,
max_tokens: 64,
stream: false,
}),
})
if (!response.ok) {
void response.body?.cancel()
throw new Error(`DeepSeek bridge upstream returned HTTP ${response.status}`)
}
const payload = await response.json() as {
choices?: Array<{ message?: { content?: unknown } }>
}
const content = payload.choices?.[0]?.message?.content
if (typeof content !== 'string' || content.trim().length === 0) {
throw new Error('DeepSeek bridge upstream returned no text')
}
return content
}
function closeServer(server: Server): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => {
if (error !== undefined) reject(error)
else resolve()
})
server.closeAllConnections()
})
}
/**
* Start the single-purpose loopback bridge used by the Codex credentialed e2e.
* @param nonce - unique answer the incoming Responses task must request.
* @returns loopback endpoint, completion count, and close operation.
*/
export async function startDeepSeekResponsesBridge(
nonce: string,
): Promise<DeepSeekResponsesBridge> {
let seenRequests = 0
let completedRequests = 0
const openResponses = new Set<ServerResponse>()
const server = createServer((request, response) => {
openResponses.add(response)
response.on('close', () => { openResponses.delete(response) })
void (async () => {
if (request.method !== 'POST' || request.url !== '/v1/responses') {
response.writeHead(404)
response.end()
return
}
if (seenRequests !== 0) {
response.writeHead(409)
response.end()
return
}
seenRequests += 1
const authorization = request.headers.authorization
if (
typeof authorization !== 'string'
|| !authorization.startsWith('Bearer ')
|| authorization.length === 'Bearer '.length
) {
throw new Error('Codex DeepSeek bridge received no bearer credential')
}
const body = JSON.parse(await readRequest(request)) as Record<string, unknown>
const task = taskText(body)
if (!task.includes(nonce)) {
throw new Error('Codex DeepSeek bridge request omitted the expected nonce')
}
const text = await completeWithDeepSeek(authorization, task)
completedRequests += 1
response.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
'x-request-id': 'req_deepseek_e2e',
})
for (const event of completeResponsesEvents(text)) {
response.write(`data: ${JSON.stringify(event)}\n\n`)
}
response.end('data: [DONE]\n\n')
})().catch(() => {
if (!response.headersSent) {
response.writeHead(502, { 'content-type': 'application/json' })
}
response.end(JSON.stringify({ error: { message: 'DeepSeek bridge request failed' } }))
})
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
server.off('error', reject)
resolve()
})
})
const address = server.address()
if (address === null || typeof address === 'string') {
throw new Error('DeepSeek bridge did not acquire a TCP port')
}
return {
baseUrl: `http://127.0.0.1:${address.port}/v1`,
get completedRequests(): number { return completedRequests },
async close(): Promise<void> {
for (const response of openResponses) response.destroy()
await closeServer(server)
},
}
}

View File

@@ -0,0 +1,53 @@
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import {
LOADER_SMOKE_TEST_TIMEOUT_MS,
runLoaderSmoke,
} from '@deepseek-ai/dsh-loader-smoke'
const fixtureDir = fileURLToPath(new URL(
'../../../../examples/acp-agent/tests/fixtures/subagent/subagent-codex/',
import.meta.url,
))
const driver = join(fixtureDir, 'driver.ts')
const configPath = join(fixtureDir, 'cordis.yml')
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
describe('Codex provider public Loader composition', () => {
it('loads the opt-in package and foreground tool without starting Codex', async () => {
const { stdout, stderr } = await runLoaderSmoke({
label: 'subagent-codex Loader composition',
tempDirPrefix: 'dsh-subagent-codex-loader-',
binScript: driver,
libBinScript: driver,
configPath,
tsconfigPath: repoTsconfig,
env: {
// Loading the optional package must not probe or start a Codex binary.
PATH: '',
},
})
expect(stderr).toBe('')
expect(JSON.parse(stdout)).toEqual({
providers: ['codex'],
provider: {
name: 'codex',
capabilities: {
outputSchema: false,
depthLimit: false,
toolFilter: false,
persona: false,
},
inheritsParentContext: false,
},
tool: {
name: 'subagent_codex',
parameterNames: ['description', 'prompt'],
required: ['description', 'prompt'],
},
starts: 0,
})
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -0,0 +1,141 @@
import { execFile } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as codex from '../src/index.ts'
import {
startDeepSeekResponsesBridge,
type DeepSeekResponsesBridge,
} from './deepseek-responses-bridge.ts'
const execFileAsync = promisify(execFile)
const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
const codexBinDir = join(packageRoot, 'node_modules', '.bin')
const codexPackage = JSON.parse(readFileSync(
join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'),
'utf8',
)) as { version: string }
const roots: string[] = []
const contexts: Context[] = []
const bridges: DeepSeekResponsesBridge[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
await Promise.all(bridges.splice(0).map(bridge => bridge.close()))
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise<void> {
expect(handles.length).toBeGreaterThan(0)
for (const handle of handles) {
await expect(handle.waitForExit()).resolves.toBe(true)
await expect(handle.done).resolves.toHaveProperty('exitCode')
}
}
describe.skipIf(!process.env.DEEPSEEK_API_KEY)(
'Codex provider with real DeepSeek API',
() => {
it('returns one unique nonce through the production provider and real Codex', async () => {
const apiKey = process.env.DEEPSEEK_API_KEY
if (apiKey === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY')
const root = mkdtempSync(join(tmpdir(), 'dsh-codex-deepseek-e2e-'))
roots.push(root)
const workspace = join(root, 'workspace')
const codexHome = join(root, 'codex-home')
mkdirSync(workspace)
mkdirSync(codexHome)
const nonce = `DSH_CODEX_DEEPSEEK_${randomUUID()}`
const bridge = await startDeepSeekResponsesBridge(nonce)
bridges.push(bridge)
writeFileSync(join(codexHome, 'config.toml'), [
'model = "deepseek-v4-flash"',
'model_provider = "deepseek-e2e"',
'approval_policy = "never"',
'sandbox_mode = "read-only"',
'disable_response_storage = true',
'check_for_update_on_startup = false',
'',
'[model_providers.deepseek-e2e]',
'name = "DeepSeek E2E bridge"',
`base_url = "${bridge.baseUrl}"`,
'env_key = "DEEPSEEK_API_KEY"',
'wire_api = "responses"',
'requires_openai_auth = false',
'',
'[analytics]',
'enabled = false',
'',
].join('\n'))
const env = {
DEEPSEEK_API_KEY: apiKey,
CODEX_HOME: codexHome,
HOME: root,
XDG_CONFIG_HOME: join(root, 'xdg-config'),
PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`,
HTTP_PROXY: '',
HTTPS_PROXY: '',
ALL_PROXY: '',
NO_PROXY: '127.0.0.1,localhost',
}
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
const handles: SubprocessHandle[] = []
const spawn = ctx.subprocess.spawn.bind(ctx.subprocess)
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
const handle = spawn(spec)
handles.push(handle)
return handle
})
await ctx.plugin(codex, { env, disposeGraceMs: 2_000 })
const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], {
env: { ...process.env, ...env },
})
expect(codexPackage.version).toBe('0.146.0')
expect(version.stdout.trim()).toBe('codex-cli 0.146.0')
const parent = {
id: 'deepseek-e2e-parent',
session: { header: { cwd: workspace } },
} as unknown as Agent
const run = await ctx.subagents.start('codex', {
prompt: [{
type: 'text',
text: `Reply with exactly ${nonce} and nothing else. Do not use tools.`,
}],
parent,
signal: new AbortController().signal,
})
const result = await run.result
await run.dispose()
expect(result.stopReason).toBe('completed')
const text = result.output
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
.trim()
expect(text).toBe(nonce)
expect(bridge.completedRequests).toBe(1)
await expectQuiescent(handles)
}, 180_000)
},
)

View File

@@ -0,0 +1,225 @@
import { execFile } from 'node:child_process'
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentService from '@deepseek-ai/dsh-subagent'
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as codex from '../src/index.ts'
import {
startResponsesFixture,
type ResponsesBehavior,
type ResponsesFixture,
} from './responses-fixture.ts'
const execFileAsync = promisify(execFile)
const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
const codexBinDir = join(packageRoot, 'node_modules', '.bin')
const codexPackage = JSON.parse(readFileSync(
join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'),
'utf8',
)) as { version: string }
const roots: string[] = []
const fixtures: ResponsesFixture[] = []
const contexts: Context[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
await Promise.all(fixtures.splice(0).map(fixture => fixture.close()))
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
interface RealHarness {
readonly ctx: Context
readonly handles: SubprocessHandle[]
readonly parent: Agent
readonly env: Record<string, string>
readonly workspace: string
}
async function realHarness(script: readonly ResponsesBehavior[]): Promise<{
readonly harness: RealHarness
readonly fixture: ResponsesFixture
}> {
const root = mkdtempSync(join(tmpdir(), 'dsh-codex-real-'))
roots.push(root)
const workspace = join(root, 'workspace')
const codexHome = join(root, 'codex-home')
const fixture = await startResponsesFixture(script)
fixtures.push(fixture)
mkdirSync(workspace)
mkdirSync(codexHome)
writeFileSync(join(codexHome, 'config.toml'), [
'model = "fixture-model"',
'model_provider = "fixture"',
'approval_policy = "on-request"',
'sandbox_mode = "read-only"',
'disable_response_storage = true',
'check_for_update_on_startup = false',
'',
'[model_providers.fixture]',
'name = "Fixture Responses"',
`base_url = "${fixture.baseUrl}"`,
'env_key = "OPENAI_API_KEY"',
'wire_api = "responses"',
'requires_openai_auth = false',
'',
'[analytics]',
'enabled = false',
'',
].join('\n'))
const env = {
OPENAI_API_KEY: 'dsh-fake-openai-key',
CODEX_HOME: codexHome,
HOME: root,
XDG_CONFIG_HOME: join(root, 'xdg'),
PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`,
HTTP_PROXY: '',
HTTPS_PROXY: '',
ALL_PROXY: '',
NO_PROXY: '127.0.0.1,localhost',
}
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SubagentService)
await ctx.plugin(LocalSubprocessService)
const handles: SubprocessHandle[] = []
const spawn = ctx.subprocess.spawn.bind(ctx.subprocess)
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
const handle = spawn(spec)
handles.push(handle)
return handle
})
await ctx.plugin(codex, { env, disposeGraceMs: 2_000 })
const parent = {
id: 'real-parent',
session: { header: { cwd: workspace } },
} as unknown as Agent
return { harness: { ctx, handles, parent, env, workspace }, fixture }
}
async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise<void> {
expect(handles.length).toBeGreaterThan(0)
for (const handle of handles) {
await expect(handle.waitForExit()).resolves.toBe(true)
const outcome = await handle.done
expect(outcome).toHaveProperty('exitCode')
expect(outcome).toHaveProperty('signal')
}
}
function responseInputTexts(body: Record<string, unknown>): string[] {
if (!Array.isArray(body.input)) return []
return body.input.flatMap((item): string[] => {
if (item === null || typeof item !== 'object') return []
const content = (item as Record<string, unknown>).content
if (!Array.isArray(content)) return []
return content.flatMap((part): string[] => (
part !== null
&& typeof part === 'object'
&& typeof (part as Record<string, unknown>).text === 'string'
? [(part as Record<string, unknown>).text as string]
: []
))
})
}
describe('real @openai/codex 0.146.0 product', () => {
it('passes the exact task and fake authentication to local Responses and returns exact text', async () => {
const sentinel = 'REAL_CODEX_SENTINEL_0_146_0'
const task = 'Return the fixture sentinel exactly.'
const { harness, fixture } = await realHarness([
{ kind: 'complete', text: sentinel },
])
expect(codexPackage.version).toBe('0.146.0')
const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], {
env: { ...process.env, ...harness.env },
})
expect(version.stdout.trim()).toBe('codex-cli 0.146.0')
const run = await harness.ctx.subagents.start('codex', {
prompt: [{ type: 'text', text: task }],
parent: harness.parent,
signal: new AbortController().signal,
})
await expect(run.result).resolves.toEqual({
output: [{ type: 'text', text: sentinel }],
stopReason: 'completed',
})
await run.dispose()
expect(fixture.requests).toHaveLength(1)
const recorded = fixture.requests[0]!
expect(recorded.method).toBe('POST')
expect(recorded.path).toBe('/v1/responses')
expect(recorded.headers.authorization).toBe('Bearer dsh-fake-openai-key')
expect(responseInputTexts(recorded.body)).toContain(task)
await expectQuiescent(harness.handles)
}, 60_000)
it('cancels a real app-server command approval without executing the command', async () => {
const { harness, fixture } = await realHarness([
{
kind: 'functionCall',
name: 'exec_command',
arguments: {
cmd: 'touch approval-side-effect',
sandbox_permissions: 'require_escalated',
justification: 'exercise the unattended approval boundary',
},
},
])
const sideEffect = join(harness.workspace, 'approval-side-effect')
const run = await harness.ctx.subagents.start('codex', {
prompt: [{ type: 'text', text: 'Attempt the fixture command.' }],
parent: harness.parent,
signal: new AbortController().signal,
})
await expect(run.result).resolves.toEqual({
output: [],
stopReason: 'error',
})
await run.dispose()
expect(existsSync(sideEffect)).toBe(false)
expect(fixture.requests).toHaveLength(1)
const tools = fixture.requests[0]!.body.tools as Array<Record<string, unknown>>
expect(tools).toEqual(expect.arrayContaining([
expect.objectContaining({ type: 'function', name: 'exec_command' }),
]))
expect(fixture.requests.every(requestEntry =>
requestEntry.headers.authorization === 'Bearer dsh-fake-openai-key',
)).toBe(true)
await expectQuiescent(harness.handles)
}, 60_000)
it('settles cancellation locally and leaves the real app-server tree quiescent', async () => {
const { harness, fixture } = await realHarness([{ kind: 'hold' }])
const controller = new AbortController()
const run = await harness.ctx.subagents.start('codex', {
prompt: [{ type: 'text', text: 'Wait for cancellation.' }],
parent: harness.parent,
signal: controller.signal,
})
await fixture.requestStarted
controller.abort(new Error('real product cancellation'))
await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' })
await run.dispose()
await expectQuiescent(harness.handles)
}, 60_000)
})

View File

@@ -0,0 +1,288 @@
import { createServer } from 'node:http'
import type {
IncomingHttpHeaders,
IncomingMessage,
Server,
ServerResponse,
} from 'node:http'
/** One request observed by the package-private Responses fixture. */
interface RecordedResponsesRequest {
readonly method: string | undefined
readonly path: string | undefined
readonly headers: IncomingHttpHeaders
readonly body: Record<string, unknown>
}
/** Behavior consumed by one Responses request. */
export type ResponsesBehavior =
| { readonly kind: 'complete'; readonly text: string }
| {
readonly kind: 'functionCall'
readonly name: string
readonly arguments: Record<string, unknown>
}
| { readonly kind: 'hold' }
/** Running package-private Responses fixture. */
export interface ResponsesFixture {
readonly baseUrl: string
readonly requests: RecordedResponsesRequest[]
readonly requestStarted: Promise<void>
close(): Promise<void>
}
function responseObject(text: string): Record<string, unknown> {
const message = {
id: 'msg_fixture',
type: 'message',
status: 'completed',
role: 'assistant',
content: [{
type: 'output_text',
annotations: [],
logprobs: [],
text,
}],
}
return {
id: 'resp_fixture',
object: 'response',
created_at: 1,
status: 'completed',
background: false,
error: null,
incomplete_details: null,
instructions: null,
max_output_tokens: null,
max_tool_calls: null,
model: 'fixture-model',
output: [message],
parallel_tool_calls: true,
previous_response_id: null,
prompt_cache_key: null,
prompt_cache_retention: null,
reasoning: { effort: null, summary: null },
safety_identifier: null,
service_tier: 'default',
store: false,
temperature: null,
text: { format: { type: 'text' }, verbosity: 'medium' },
tool_choice: 'auto',
tools: [],
top_logprobs: 0,
top_p: null,
truncation: 'disabled',
usage: {
input_tokens: 10,
input_tokens_details: { cached_tokens: 0 },
output_tokens: 1,
output_tokens_details: { reasoning_tokens: 0 },
total_tokens: 11,
},
user: null,
metadata: {},
}
}
/**
* Build the minimal Responses SSE event sequence consumed by Codex 0.146.0.
* @param text - exact assistant answer.
* @returns ordered response lifecycle events.
*/
export function completeResponsesEvents(text: string): Record<string, unknown>[] {
const completed = responseObject(text)
const message = (completed.output as Record<string, unknown>[])[0]!
const part = (message.content as Record<string, unknown>[])[0]!
return [
{
type: 'response.created',
response: { ...completed, status: 'in_progress', output: [] },
},
{
type: 'response.output_item.added',
output_index: 0,
item: { ...message, status: 'in_progress', content: [] },
},
{
type: 'response.content_part.added',
item_id: message.id,
output_index: 0,
content_index: 0,
part: { ...part, text: '' },
},
{
type: 'response.output_text.delta',
item_id: message.id,
output_index: 0,
content_index: 0,
delta: text,
logprobs: [],
},
{
type: 'response.output_text.done',
item_id: message.id,
output_index: 0,
content_index: 0,
text,
logprobs: [],
},
{
type: 'response.content_part.done',
item_id: message.id,
output_index: 0,
content_index: 0,
part,
},
{
type: 'response.output_item.done',
output_index: 0,
item: message,
},
{ type: 'response.completed', response: completed },
]
}
function functionCallEvents(
name: string,
argumentsValue: Record<string, unknown>,
): Record<string, unknown>[] {
const argumentsText = JSON.stringify(argumentsValue)
const item = {
id: 'fc_fixture',
type: 'function_call',
status: 'completed',
name,
arguments: argumentsText,
call_id: 'call_fixture',
}
const completed = {
...responseObject(''),
output: [item],
usage: {
input_tokens: 10,
input_tokens_details: { cached_tokens: 0 },
output_tokens: 5,
output_tokens_details: { reasoning_tokens: 0 },
total_tokens: 15,
},
}
return [
{
type: 'response.created',
response: { ...completed, status: 'in_progress', output: [] },
},
{
type: 'response.output_item.added',
output_index: 0,
item: { ...item, status: 'in_progress', arguments: '' },
},
{
type: 'response.function_call_arguments.delta',
item_id: item.id,
output_index: 0,
delta: argumentsText,
},
{
type: 'response.function_call_arguments.done',
item_id: item.id,
output_index: 0,
arguments: argumentsText,
},
{
type: 'response.output_item.done',
output_index: 0,
item,
},
{ type: 'response.completed', response: completed },
]
}
function readRequest(request: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let body = ''
request.setEncoding('utf8')
request.on('data', (chunk: string) => { body += chunk })
request.on('end', () => { resolve(body) })
request.on('error', reject)
})
}
function closeServer(server: Server): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => {
if (error !== undefined) reject(error)
else resolve()
})
server.closeAllConnections()
})
}
/**
* Start a loopback-only Responses SSE fixture.
* @param script - one behavior per expected Responses request.
* @returns the running fixture and its observed requests.
*/
export async function startResponsesFixture(
script: readonly ResponsesBehavior[],
): Promise<ResponsesFixture> {
const behaviors = [...script]
const requests: RecordedResponsesRequest[] = []
const started = Promise.withResolvers<undefined>()
const openResponses = new Set<ServerResponse>()
const server = createServer((request, response) => {
openResponses.add(response)
response.on('close', () => { openResponses.delete(response) })
void readRequest(request).then((body) => {
requests.push({
method: request.method,
path: request.url,
headers: request.headers,
body: JSON.parse(body) as Record<string, unknown>,
})
started.resolve(undefined)
const behavior = behaviors.shift()
if (behavior === undefined) {
response.writeHead(500, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: { message: 'fixture script exhausted' } }))
return
}
response.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
'x-request-id': 'req_fixture',
})
if (behavior.kind === 'hold') return
const events = behavior.kind === 'complete'
? completeResponsesEvents(behavior.text)
: functionCallEvents(behavior.name, behavior.arguments)
for (const event of events) {
response.write(`data: ${JSON.stringify(event)}\n\n`)
}
response.end('data: [DONE]\n\n')
}).catch((error: unknown) => {
response.destroy(error instanceof Error ? error : new Error(String(error)))
})
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
server.off('error', reject)
resolve()
})
})
const address = server.address()
if (address === null || typeof address === 'string') {
throw new Error('responses fixture did not acquire a TCP port')
}
return {
baseUrl: `http://127.0.0.1:${address.port}/v1`,
requests,
requestStarted: started.promise,
async close(): Promise<void> {
for (const response of openResponses) response.destroy()
await closeServer(server)
},
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/agent"
},
{
"path": "../../llm/llm"
},
{
"path": "../../sdk/sdk-protocol"
},
{
"path": "../../core/session"
},
{
"path": "../subagent"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../util/timeout"
},
{
"path": "../../support/invariants"
}
]
}

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: a21aa6ae2822d68d513fd9409d77b3f3bf74a7a3
README.zh.md: 3caa612aefcdac4f1dcdbcf4a3c1b81adc52c3d3
README.md: 08b6175e018db072b99490a25b8df887bb89eb47
README.zh.md: 435be7660b3f004a1f0bcb59a8d9a74ac8e8aae3

View File

@@ -48,7 +48,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

View File

@@ -48,7 +48,7 @@ subagent seam 允许一个 agent智能体通过具名提供方把工作委
该 seam 拥有实现和消费方共享的深度词汇:`AgentOptions.subagentDepth` 声明、`assertSubagentMaxDepth``delegationDepthOf(agent)`。持久化的 `SessionHeader.delegationDepth` 具有权威性且单调:运行时选项可以加深计数,但绝不能降低它,因此恢复后的子 agent 不会被重新计为顶层。
`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` ACPAgent Client Protocol不可以),不表示是否继承工具、服务或权限。
`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn`各进程外一次性提供方不可以),不表示是否继承工具、服务或权限。
## 一次性所有权与生命周期

View File

@@ -132,9 +132,9 @@ function toError(value: unknown): Error {
export interface RunResultSettlement {
/** The turn attempt (typically racing local cancellation); returns the terminal result. */
attempt: () => Promise<SubagentResult>
/** Snapshot of the child output streamed so far (a partial answer survives failure). */
/** Snapshot the provider exposes when cancellation or failure wins settlement. */
collectOutput: () => ContentBlock[]
/** Whether local cancellation settled (an in-flight rejection then reads as `aborted`). */
/** Whether local cancellation settled before the attempt's outcome is observed. */
cancelled: () => boolean
/** Diagnostic sink for a failure flattened to a stop reason; a throw from it is contained. */
onError?: ((error: Error, stopReason: SubagentStopReason) => void) | undefined
@@ -146,16 +146,19 @@ export interface RunResultSettlement {
/**
* Settle an out-of-process run result under the seam contract: `result` never
* rejects after publication. A rejection from the attempt resolves as
* `aborted` when cancellation already settled locally, else it is flattened
* to `stopReason: 'error'` through the contained diagnostic sink; the abort
* listener is removed on every path.
* rejects after publication. A normally completed or rejected attempt resolves
* as `aborted` when cancellation already settled locally; another rejection is
* flattened to `stopReason: 'error'` through the contained diagnostic sink.
* The abort listener is removed on every path.
* @param parts - the attempt, output snapshot, cancellation state, sink, and signal wiring.
* @returns the terminal result (never a rejection).
*/
export async function settleRunResult(parts: RunResultSettlement): Promise<SubagentResult> {
try {
return await parts.attempt()
const result = await parts.attempt()
return parts.cancelled()
? { output: parts.collectOutput(), stopReason: 'aborted' }
: result
} catch (error: unknown) {
// Cover a rejection already queued when cancellation arrives.
if (parts.cancelled()) return { output: parts.collectOutput(), stopReason: 'aborted' }

View File

@@ -67,8 +67,8 @@ export interface Config {
* requires the provider's `depthLimit` capability (mount fails loud
* otherwise). The provider checks the calling agent's current depth at every
* start; the tool remains model-visible so runtime policy owns rejection.
* `'provider-managed'` is for an out-of-process provider (ACP) whose
* recursion budget belongs to the child harness's own deployment.
* `'provider-managed'` is for an out-of-process provider whose recursion
* budget belongs to the child runtime or its own deployment.
*/
maxDepth?: number | 'provider-managed'
}

View File

@@ -27,11 +27,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"
}
}

View File

@@ -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,
@@ -26,12 +27,12 @@ import type {
/**
* Build a child environment: explicit caller entries merge after the scrubbed
* parent base, so a deliberately supplied credential or current `DSH_*` fact
* wins over the scrub that dropped its ambient namesake.
* @param extra - explicit caller entries, merged verbatim after the scrub.
* parent base. A string deliberately restores or overrides an entry; an
* explicit `undefined` tombstone removes an ordinary ambient entry.
* @param extra - explicit caller entries and tombstones, merged after the scrub.
* @returns the environment to hand to `spawn` for the child process.
*/
export function childEnv(extra?: Readonly<Record<string, string>>): NodeJS.ProcessEnv {
export function childEnv(extra?: Readonly<NodeJS.ProcessEnv>): NodeJS.ProcessEnv {
return { ...scrubbedParentEnv(), ...extra }
}
@@ -298,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
@@ -341,7 +346,9 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
const stdoutCollector = collectStream(outMode, child.stdout, 'stdout')
const stderrCollector = collectStream(errMode, child.stderr, 'stderr')
let graceTimer: NodeJS.Timeout | undefined
let graceTimer: ReturnType<typeof setTimeout> | undefined
let treeExitObserved = false
let treeExitObservation: Promise<void> | undefined
let settled = false
// Failed spawns use pid -1 so signalling remains a no-op.
@@ -349,6 +356,9 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
/** Whether the detached tree's root (or POSIX group) is still alive. */
const treeAlive = (): boolean => {
/* v8 ignore next -- only a timer callback already queued when the observer settles can enter here;
the guard is the final defense against probing an id after its tree was confirmed absent. */
if (treeExitObserved) return false
if (pid <= 0) return false
if (platform === 'win32') {
// Windows has no group-liveness probe; the direct child's exit is the
@@ -371,19 +381,40 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
}
}
/**
* Start or reuse the handle's single whole-tree exit observer. The first
* confirmed absence is a permanent no-more-signals boundary: it cancels a
* pending escalation before this process-group id can be reused.
*/
const observeTreeExit = (): Promise<void> => {
treeExitObservation ??= (async () => {
while (treeAlive()) await sleepTick()
treeExitObserved = true
if (graceTimer !== undefined) clearTimeout(graceTimer)
graceTimer = undefined
})()
return treeExitObservation
}
// The escalation's tier primitive (not on the handle — terminate() is the
// only consumer-facing termination verb). Guards on TREE liveness, not
// outcome settlement: a TERM-trapping helper can outlive the settled direct
// child and must stay signalable, while a fully-dead tree (possible pid
// reuse) must not be re-signalled by a later tier.
const kill = (sig: NodeJS.Signals): void => {
/* v8 ignore next -- the shared exit observer cancels the ordinary dead-tree timer;
this remains the timer/death race guard and cannot be staged deterministically. */
if (!treeAlive()) return
signalTree(platform, pid, sig, child, taskkill)
}
const terminate = (): void => {
if (graceTimer !== undefined) return // escalation already in flight
if (!treeAlive()) return
if (treeExitObserved || graceTimer !== undefined) return
// Observe from the first termination tier onward, even when inherited
// pipes delay `done` and no consumer has begun its own teardown wait.
void observeTreeExit()
// oxlint-disable-next-line typescript/no-unnecessary-condition -- observer can record absence before its first await.
if (treeExitObserved) return
kill('SIGTERM')
// The escalation must survive direct-child settlement — the leader dying
// does not mean the tree died — so settle does not clear this timer, and
@@ -405,7 +436,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
}
const done = new Promise<SubprocessOutcome>((resolve, reject) => {
let pipeDrainTimer: NodeJS.Timeout | undefined
let pipeDrainTimer: ReturnType<typeof setTimeout> | undefined
const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
if (settled) return
settled = true
@@ -428,7 +459,9 @@ 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 = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs)
pipeDrainTimer = setTimeout(() => {
settle(exitCode, signal)
}, spec.graceMs)
})
child.on('close', settle)
function cleanup(): void {
@@ -440,11 +473,23 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
})
const waitForExit = async (signal?: AbortSignal): Promise<boolean> => {
while (treeAlive()) {
if (signal?.aborted) return false
await sleepTick()
const observed = observeTreeExit()
if (treeExitObserved) return true
if (signal?.aborted) return false
if (signal === undefined) {
await observed
return true
}
const aborted = Promise.withResolvers<boolean>()
const onAbort = (): void => { aborted.resolve(false) }
signal.addEventListener('abort', onAbort, { once: true })
/* v8 ignore next -- closes the event-loop race between the preceding aborted check and listener registration. */
if (signal.aborted) onAbort()
try {
return await Promise.race([observed.then(() => true), aborted.promise])
} finally {
signal.removeEventListener('abort', onAbort)
}
return true
}
return {

View File

@@ -2,8 +2,14 @@ import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { killGroup, OutputCollector, spawnSubprocess, taskkillProcessTree } from '../src/spawn.ts'
import {
killGroup,
OutputCollector,
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 },
@@ -102,6 +108,14 @@ async function waitForPidFile(path: string, timeoutMs = 5_000): Promise<number>
}
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)
@@ -164,6 +178,54 @@ describe('spawnSubprocess', () => {
expect(result.signal).toBe('SIGKILL')
})
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
const childScript = `
const { spawn } = require('node:child_process')
const { writeFileSync } = require('node:fs')
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
detached: true,
stdio: ['ignore', 1, 2],
})
writeFileSync(${JSON.stringify(pidFile)}, String(helper.pid))
helper.unref()
setInterval(() => {}, 1000)
`
const running = spawnSubprocess({
...spec('unused', { graceMs }),
argv: [process.execPath, '-e', childScript],
})
const helper = await waitForPidFile(pidFile)
const realKill: typeof process.kill = process.kill.bind(process)
let termAt = 0
let forceSignals = 0
const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => {
if (target !== -running.pid) return realKill(target, signal)
if (signal === 'SIGTERM') {
termAt = Date.now()
return realKill(target, signal)
}
if (signal === 'SIGKILL') {
forceSignals += 1
return true
}
if (signal === 0 && termAt !== 0 && Date.now() - termAt < graceMs / 2) {
throw Object.assign(new Error('simulated vanished process group'), { code: 'ESRCH' })
}
return true // Before TERM the original group is live; later its pgid is reused.
})
try {
running.terminate()
await running.done
expect(forceSignals).toBe(0)
} finally {
killSpy.mockRestore()
process.kill(helper, 'SIGKILL')
await waitGone(helper)
}
})
it('terminates the whole process group (grandchildren die too)', async () => {
// The subshell writes the sleep's pid then waits on it; terminating the
// group must take the sleep down with bash.
@@ -254,6 +316,19 @@ describe('stdin and extra env (set by in-process plugins)', () => {
expect(result.stdout.text).toBe('alpha/beta\n')
})
it('lets an explicit tombstone remove an ordinary ambient env entry', async () => {
process.env.SUBPROCESS_TOMBSTONE_PROBE = 'ambient-value'
try {
const result = await finish(spawnSubprocess(spec(
'echo "${SUBPROCESS_TOMBSTONE_PROBE:-absent}"',
{ env: { SUBPROCESS_TOMBSTONE_PROBE: undefined } },
)))
expect(result.stdout.text).toBe('absent\n')
} finally {
delete process.env.SUBPROCESS_TOMBSTONE_PROBE
}
})
it('an explicit extra env entry overrides the credential scrub', async () => {
// EXPLICIT_OVERRIDE_PASSWORD matches the credential scrub pattern, yet an explicit
// entry is still honored — the scrub only drops AMBIENT process.env creds.
@@ -627,7 +702,6 @@ describe('coverage seams', () => {
it('terminate() after the tree died delivers no termination signal', async () => {
const running = spawnSubprocess(spec('true'))
await running.done
await running.waitForExit()
const spy = vi.spyOn(process, 'kill')
try {
running.terminate()
@@ -636,6 +710,21 @@ describe('coverage seams', () => {
} finally {
spy.mockRestore()
}
await running.waitForExit()
})
it('repeated terminate after exit never probes or signals a reused process group', async () => {
const running = spawnSubprocess(spec('sleep 60'))
running.terminate()
await running.done
await running.waitForExit()
const spy = vi.spyOn(process, 'kill').mockImplementation(() => true)
try {
running.terminate()
expect(spy).not.toHaveBeenCalled()
} finally {
spy.mockRestore()
}
})
it('waitForExit on a failed spawn reports exited immediately', async () => {

View File

@@ -17,6 +17,9 @@
{
"path": "../subprocess"
},
{
"path": "../../util/timeout"
},
{
"path": "../../support/invariants"
}

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/subprocess/subprocess/README.md
README.md: c360437bf2b2b95734f55f6aec46b0cecffb9260
README.zh.md: 914ab16b40c688867eb20afc3de60a38fd88d42b
README.md: e59dd96df036826f36bd0286c977438d2d87d1cf
README.zh.md: e8fb89dfd1f8c41a0caefc96469c13d9ae7d415d

View File

@@ -7,10 +7,10 @@ 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 deliberately forwarded credential or a current `DSH_*` fact survives precisely because it is an explicit caller opt-in, while the stale ambient namesake never reaches the child. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub.
- `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.
- Disposal of the service terminates all still-running managed processes and awaits their exit.
See the [subprocess data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md).

View File

@@ -7,10 +7,10 @@
## 契约
- `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` 在清除之后合并且不做命名空间校验——有意转发的凭据或当前 `DSH_*` 事实之所以能保留下来,正因为它是调用方的显式选择,而陈旧的同名环境值永远到不了子进程。无法把 spawn 路由到该服务的进程启动方node-pty 后端、由 SDK 管理的传输层)改为导入环境清理函数
- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃spec 的显式 `env` 在清后合并且不做命名空间校验——字符串会有意转发或覆盖某个值,而 `undefined` tombstone 则会删除普通的环境条目。无法把 spawn 路由到该服务的进程启动方node-pty 后端、由 SDK 管理的传输层)导入环境清理定义
- 服务自身的 dispose资源释放会终止所有仍在运行的受管进程并等待其退出。
参见[子进程数据结构目录](../../../docs/core-data-structures/subprocess.md)与[seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。

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
/**
@@ -94,13 +95,12 @@ export interface SubprocessSpawnSpec {
signal?: AbortSignal | undefined
/**
* Explicit environment entries merged onto the implementation's scrubbed
* parent base (see `scrubbedParentEnv`), with no namespace validation:
* every entry is a deliberate caller opt-in, so a forwarded
* credential-shaped entry or a current `DSH_*` fact survives precisely
* because this layer merges after the scrub that drops its ambient
* namesake.
* parent base (see `scrubbedParentEnv`), with no namespace validation. A
* string is a deliberate caller opt-in, so a forwarded credential-shaped
* entry or current `DSH_*` fact survives the scrub; `undefined` is a
* tombstone that removes an ordinary ambient entry from the child.
*/
env?: Record<string, string> | undefined
env?: NodeJS.ProcessEnv | undefined
}
/**