diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml index b9b20c4ad9..c8e0a64d5d 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md -2026-08-05-profile-plugin-bundles.md: 94e3ddad0cfea0a1aaf2efe8d15f26f471bb2bd3 -2026-08-05-profile-plugin-bundles.zh.md: 96d22c57f68ecc72e1afd34fbbd3cfbe3ac24c61 +2026-08-05-profile-plugin-bundles.md: c9f685eecddcd4ea8d8580becef3c32a9693a329 +2026-08-05-profile-plugin-bundles.zh.md: 43680862a13171071e236d56c336b790bbe50432 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md index 94e3ddad0c..c9f685eecd 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -12,7 +12,7 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer and `--patch` overlays — one `applyEntryPatches` call shared by boot and `--dump-config`. App invocation values later moved from launcher-derived patches to startup services in the [app-owned command-line decision](2026-08-06-app-owned-command-line.md). -The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). Generic `dsh --profile ` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, while headless owns its task positional. Patch overlays use launcher-owned `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. +The default Profile templates use `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). Generic `dsh --profile ` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, while headless owns its task positional. Patch overlays use launcher-owned `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md index 96d22c57f6..43680862a1 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -12,7 +12,7 @@ Status: implemented 一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层与 `--patch` overlay——启动与 `--dump-config` 共享同一条 `applyEntryPatches` 路径。随后,[应用持有命令行的决策](2026-08-06-app-owned-command-line.md)又把调用期取值从启动器派生的 patch 迁移到了启动服务。 -随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。通用的 `dsh --profile ` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag ,headless 则持有任务位置参数。patch overlay 使用启动器持有的 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。 +默认 Profile 模板使用的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。通用的 `dsh --profile ` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 则持有任务位置参数。patch overlay 使用启动器持有的 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。 解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml index a5f5f28166..6f4bfe2b5d 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md -2026-08-10-product-subagent-providers-in-shared-host.md: 8bc08ddb57f07b76d3f90ce7c375e79c666ce86d -2026-08-10-product-subagent-providers-in-shared-host.zh.md: f3d2053c78f52deda5130eee908e7c2eff98b89a +2026-08-10-product-subagent-providers-in-shared-host.md: a11cf1a6a6831dcae2258ca16cf7a9f6e7f32396 +2026-08-10-product-subagent-providers-in-shared-host.zh.md: 46d72c63b2d6765fa87904d43f359e9febed8692 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md index 8bc08ddb57..a11cf1a6a6 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md @@ -8,21 +8,19 @@ English | [中文](2026-08-10-product-subagent-providers-in-shared-host.zh.md) The [Codex and Claude Code provider contracts](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md) were first shipped as independently installable packages that a deployment loaded beside the common subagent tool. Agent Presets later became the ordinary owner of one agent's model-visible tools, but a preset cannot safely own these product providers: `ctx.subagents` is a process registry, provider names are unique within the Host, and host consumers resolve the same registry across sessions. Repeated preset composition would therefore contend for the same configured names. Requiring a person to edit both a Profile and a Preset would also make a generic preset row incomplete by itself. -The placement decision must preserve two independent facts. Loading a provider must not start or authenticate a product, while enabling a tool must remain per preset so two sessions can expose different products. A global product switch, a provider instance per agent, or pre-enumerated combination presets would each create a second owner for one of those facts. +The placement decision must preserve two independent facts. Loading a provider must not start or authenticate a product, while granting a tool must remain per preset so two sessions can expose different products. A global product switch, a provider instance per agent, or pre-enumerated combination presets would each create a second owner for one of those facts. ## Decision -Product providers remain process-scoped host-plane registrations. The [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) supersedes only this note's former base-bundle installation choice: production `dsh-base` neither depends on nor mounts them. A Profile that opts in installs the selected provider package and mounts the required instances on the host plane. The [named-instance decision](../feature/2026-08-18-product-subagent-named-instances.md) owns each row's registry identity: both products accept multiple unique `providerName` values while preserving `codex` and `claude-code` as their defaults. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows whose `provider` and `toolName` values expose exactly the configured instances needed by one agent without changing the Host registry. +Product providers remain process-scoped host-plane registrations. The [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) supersedes only this note's former base-bundle installation choice: production `dsh-base` neither depends on nor mounts them. A Profile that opts in installs the selected provider Bundle; its patch mounts the default instance, and the Profile may mount additional named instances on the host plane. The [named-instance decision](../feature/2026-08-18-product-subagent-named-instances.md) owns each row's registry identity: both products accept multiple unique `providerName` values while preserving `codex` and `claude-code` as their defaults. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows whose `provider` and `toolName` values expose exactly the configured instances needed by one agent without changing the Host registry. -This note continues to own why a mounted product provider belongs on the host plane while its model-facing tool belongs to an Agent Preset. The production-install exclusion decision owns which Profiles install those optional packages. The provider-contract note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions. +Each provider package owns its directly installable Bundle patch and private product runtime. This note continues to own process-wide Host placement whenever either provider is installed. The provider-contract note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions. -The providers use products already selected by the host environment. Codex starts `codex` from `PATH`; Claude Code resolves `claude` through the shared subprocess execution world and passes the exact path to the official SDK. Profile loading does not install a product, create product state, probe a version, or test authentication. It may supply each mounted Provider instance's deployment configuration, including the product-specific `permissionMode` values owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md), without moving those choices into an Agent Preset or model-facing tool. Missing commands and product failures remain local to the attempted delegation. - -Only a Profile that selects the Claude Code provider carries the Claude Agent SDK's optional platform CLI payload. Production still resolves the host `claude`; the SDK payload remains provider-package installation cost rather than the production executable. +Each Bundle delegates executable selection to its package-owned product runtime: the Codex package runs its declared wrapper, while the Claude Code package lets its pinned Agent SDK select the private native executable. Neither provider consults or falls back to a host product command. Profile loading creates no product state, probes no version or authentication, and may supply each mounted Provider instance's deployment configuration, including the product-specific `permissionMode` values owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md), without moving those choices into an Agent Preset or model-facing tool. Missing platform payloads and product failures remain local to the attempted delegation. ## Verification -The base bundle test proves production `dsh-base` contains neither product provider dependency nor provider row. The Web composition explicitly mounts both optional providers and covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Package-owned Loader compositions prove two named instances of each product register without starting a product process. Keyless ACP snapshots pin the Codex two-tool roster and the final four-tool combination, while provider tests separately prove native executable resolution, configuration isolation, failure, cancellation, and process-tree quiescence. +The base bundle test proves production `dsh-base` contains neither product provider dependency nor provider row. The Web composition installs both optional Bundles and covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Package-owned Loader compositions prove each Bundle default and additional named instances register without starting a product process. Keyless ACP snapshots pin the Codex two-tool roster and the final four-tool combination, while provider tests separately prove private platform-payload selection without host fallback, configuration isolation, failure, cancellation, and process-tree quiescence. ## Alternatives considered @@ -38,4 +36,4 @@ The base bundle test proves production `dsh-base` contains neither product provi A user installs each selected product provider in a Profile, mounts the required named instances, and exposes their tools through the same Agent Preset authoring path as other plugins. Each new session receives exactly the tools its chosen preset contributes. Profiles that do not select a product provider carry no corresponding package or module-loading footprint; loading selected instances still starts no product process, login, model call, or product home. -The Host registry remains the single provider authority and each Preset remains the single model-tool authority. The trade-off is a two-layer opt-in: the Profile owns installation and host-plane registration, while the Preset owns per-agent exposure. Selecting the Claude provider also accepts its current SDK optional-payload installation cost. +The Host registry remains the single provider authority, the Profile Bundle or explicit Host composition remains the deployment availability authority, and each Preset remains the model-tool authority. This explicit two-gate lifecycle avoids a global enable switch and keeps package removal independent from per-session authoring. diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md index f3d2053c78..46d72c63b2 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md @@ -8,21 +8,19 @@ Status: implemented [Codex 与 Claude Code 提供方约定](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md)最初以可独立安装的包交付,由部署环境在通用 subagent 工具旁加载。Agent Preset 后来成为单个 agent(智能体)的模型可见工具的常规责任方,但 preset 不能安全地拥有这些产品提供方:`ctx.subagents` 是进程级注册表,提供方名称在 Host 内唯一,而宿主消费方会跨会话解析同一个注册表。因此,重复组装 preset 会争用同一组已配置名称。如果要求用户同时编辑 Profile 和 Preset,也会使通用 preset 配置项本身不完整。 -归属决策必须同时保留两个彼此独立的事实:加载提供方不得启动产品,也不得对产品执行身份验证;而工具是否启用仍须按 preset 决定,这样两个会话才能暴露不同的产品。全局产品开关、按 agent 创建提供方实例或预先枚举的组合 preset,都会为其中一个事实另设第二责任方。 +归属决策必须同时保留两个彼此独立的事实:加载提供方不得启动产品,也不得对产品执行身份验证;而工具授权仍须按 preset 决定,这样两个会话才能暴露不同的产品。全局产品开关、按 agent 创建提供方实例或预先枚举的组合 preset,都会为其中一个事实另设第二责任方。 ## 决策 -产品提供方仍是进程级的 host plane(宿主平面)注册。[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)只取代本说明原先由 base bundle 安装提供方的选择:生产 `dsh-base` 既不依赖也不挂载它们。选择产品集成的 Profile 会安装目标提供方包,并在 host plane 挂载所需实例。[命名实例决策](../feature/2026-08-18-product-subagent-named-instances.md)负责每个配置项的注册身份:两个产品都接受多个唯一的 `providerName`,同时保留 `codex` 与 `claude-code` 作为默认值。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 通过普通 `dsh-tool-subagent` 配置项的 `provider` 与 `toolName` 准确公开单个 agent 所需的已配置实例,而无需更改 Host 注册表。 +产品提供方仍是进程级的 host plane(宿主平面)注册。[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)只取代本说明原先由 base bundle 安装提供方的选择:生产 `dsh-base` 既不依赖也不挂载它们。选择产品集成的 Profile 会安装目标提供方 Bundle;其 patch 挂载默认实例,而 Profile 可以在 host plane 挂载更多命名实例。[命名实例决策](../feature/2026-08-18-product-subagent-named-instances.md)负责每个配置项的注册身份:两个产品都接受多个唯一的 `providerName`,同时保留 `codex` 与 `claude-code` 作为默认值。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 通过普通 `dsh-tool-subagent` 配置项的 `provider` 与 `toolName` 准确公开单个 agent 所需的已配置实例,而无需更改 Host 注册表。 -本说明继续负责解释为什么已经挂载的产品提供方属于 host plane,而面向模型的工具属于 Agent Preset。生产安装排除决策负责哪些 Profile 安装这些可选包。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)仍负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 +每个提供方包都拥有可直接安装的 Bundle patch 与私有产品运行时。本说明继续负责每个已安装提供方的进程级 Host 放置。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)继续负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 -这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Profile 不会安装产品、创建产品状态、探测版本或测试身份验证。它可以提供每个已挂载 Provider 实例的部署配置,包括由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.md)负责的产品专属 `permissionMode` 值,但不会把这些选择移入 Agent Preset 或面向模型的工具。命令缺失和产品故障仍局限于发生问题的那次委派。 - -只有选择 Claude Code 提供方的 Profile 才会携带 Claude Agent SDK 的可选平台 CLI(命令行界面)载荷。生产环境仍解析宿主提供的 `claude`;这份 SDK 载荷是提供方包的安装成本,而不是生产可执行文件。 +每个 Bundle 都把可执行文件选择交给包自有的产品运行时:Codex 包运行自身声明的 wrapper,Claude Code 包则让锁定的 Agent SDK 选择私有原生可执行文件。两个提供方都不会查询或回退宿主产品命令。加载 Profile 不会创建产品状态、探测版本或测试身份验证;它可以提供每个已挂载 Provider 实例的部署配置,包括由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.md)负责的产品专属 `permissionMode` 值,但不会把这些选择移入 Agent Preset 或面向模型的工具。平台载荷缺失和产品故障仍局限于发生问题的那次委派。 ## 验证 -base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖,也不包含提供方配置项。Web 组装显式挂载两个可选提供方,并覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。由包负责的 Loader 组装证明每个产品的两个命名实例都会完成注册,而不会启动产品进程。无密钥 ACP(Agent Client Protocol)快照固定 Codex 双工具集合与最终四工具组合,提供方测试则另行证明原生可执行文件解析、配置隔离、失败、取消和进程树完全停稳。 +base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖,也不包含提供方配置项。Web 组装会安装两个可选 Bundle,并覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。由包负责的 Loader 组装证明每个 Bundle 默认实例与额外命名实例都会完成注册,而不会启动产品进程。无密钥 ACP(Agent Client Protocol)快照固定 Codex 双工具集合与最终四工具组合,提供方测试则另行证明私有平台载荷选择与无宿主回退、配置隔离、失败、取消和进程树完全停稳。 ## 考虑过的替代方案 @@ -38,4 +36,4 @@ base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖, 用户在 Profile 中安装每个被选中的产品提供方,挂载所需命名实例,再通过与其他插件相同的 Agent Preset 创作路径公开这些实例的工具。每个新会话只会获得其所选 preset 所贡献的工具。没有选择产品提供方的 Profile 不承担对应包或模块的加载开销;加载已选择的实例仍不会启动产品进程、登录、调用模型或创建产品主目录。 -宿主注册表仍是提供方的唯一权威,每个 Preset 仍是模型工具的唯一权威。代价是两层按需启用:Profile 负责安装与 host plane 注册,Preset 负责按 agent 暴露。选择 Claude 提供方还会接受当前 SDK 可选载荷的安装成本。 +Host 注册表仍是提供方的唯一权威,Profile Bundle 或显式 Host 组装仍是部署可用性的权威,每个 Preset 仍是模型工具的权威。这个显式的双门生命周期避免全局启用开关,并让包移除与按会话创作保持独立。 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index e5bf1cc2eb..1e0f0bd164 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: b478a97d78cc7aaa9dad452bc5cd4cbb5fdf361e -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 761fe5df5a87a2c87af2e8a8dd6cb593af0d5ba2 +2026-08-04-claude-code-and-codex-subagent-backends.md: aa1c845eb2c6b0832c1f4139e7e5150b807115c5 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 8ad7b6d704d100e2bb7884f1d7fea281e3a590fc diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index b478a97d78..aa1c845eb2 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -12,7 +12,7 @@ The product integrations must not become second owners for task text, cwd, cance ## Decision -The harness publishes two sibling one-shot provider packages whose default registry names are `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [named-instance decision](2026-08-18-product-subagent-named-instances.md) owns Profile-selected provider identity and static tool binding, the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, and the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and diagnostic production. Both packages accept multiple named instances. Loading either provider starts no product process, and each tool accepts only a standalone text task; product and instance selection remain deployment configuration. +The harness publishes two sibling one-shot provider packages whose default registry names are `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [named-instance decision](2026-08-18-product-subagent-named-instances.md) owns Profile-selected provider identity and static tool binding, the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their independent optional Bundles and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, and the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and diagnostic production. Both packages accept multiple named instances. Loading either provider starts no product process, and each tool accepts only a standalone text task; product and instance selection remain deployment configuration. Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, process-tree termination, and whole-tree exit observation. @@ -48,7 +48,7 @@ Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp ## Claude Code provider -`@deepseek-ai/dsh-subagent-claude-code` registers a Profile-selected provider name that defaults to `claude-code` and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. Before each run, the provider resolves the fixed `claude` executable name through the host subprocess execution world and passes that exact path as `pathToClaudeCodeExecutable`; the SDK therefore uses the native product that launched DSH rather than selecting its platform `optionalDependency`. A Windows `.cmd` or `.bat` path crosses `cmd.exe /v:off` as a quoted per-spawn environment expansion, so percent, ampersand, and exclamation path components remain data without changing the shared subprocess contract. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` arguments, cwd, environment, and forwarded signal to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. +`@deepseek-ai/dsh-subagent-claude-code` registers a Profile-selected provider name that defaults to `claude-code` and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. The provider omits `pathToClaudeCodeExecutable`, so the SDK selects Claude Code 2.1.220 from the matching OS, CPU, and Linux-libc platform package in its own optional dependency closure. The provider does not resolve or fall back to a host `claude`; an omitted, unsupported, missing, or damaged platform payload fails the first delegation at the SDK startup boundary. The provider uses the official `query()` entrypoint and passes the SDK's native `claude` or `claude.exe` command, arguments, cwd, environment, and forwarded signal from `spawnClaudeCodeProcess` to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. The public configuration contains a non-empty `providerName`, an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a five-value native `permissionMode` that defaults to `dontAsk`. Each named instance retains those resolved values for its own runs. Each run creates its own `AbortController`, sets `persistSession: false`, disables `AskUserQuestion`, and passes the resolved mode to the SDK; only `bypassPermissions` receives the SDK's explicit dangerous confirmation. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. Remaining permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of waiting for a user interface the provider does not own. @@ -66,7 +66,7 @@ The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its rea The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. -The Claude Code evidence pins Agent SDK 0.3.220 and uses its platform-distributed Claude Code 2.1.220 CLI as the deterministic compatibility fixture, routed through the same native executable-resolution path production uses. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, an inherited interactive host setting overridden by the safe Provider mode, denied and bypassed writes in suite-owned temporary directories, safe permission diagnostics, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing percent, ampersand, and exclamation metacharacters. This evidence proves the official SDK/CLI integration path, not compatibility with every independently installed product version. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. +The Claude Code evidence pins Agent SDK 0.3.220, Claude Code 2.1.220, and the identities and versions of all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and observes the exact `x-api-key`, original task, byte-exact final answer, an inherited interactive host setting overridden by the safe Provider mode, denied and bypassed writes in suite-owned temporary directories, safe permission diagnostics, process failure, local cancellation, and whole-tree exit. Unit coverage proves that production never resolves host `PATH`, omits the executable override, forwards the SDK-selected Windows `claude.exe` without a batch shim, and surfaces the SDK's missing-payload error without host fallback. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions. Loader coverage resolves Codex through explicit Host composition and Claude Code through its optional Bundle while starting neither product. The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test. @@ -90,6 +90,6 @@ The project owner's distribution authorization is scoped to the official `@anthr Users delegate through Profile-configured one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); named instance identity and tool binding are owned by the [named-instance decision](2026-08-18-product-subagent-named-instances.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and process-tree quiescence. -Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed product run may separately expose the shared safe diagnostic. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Product-native configuration makes behavior depend on the deployment's installed product, account state, workspace settings, and selected Provider mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. +Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed product run may separately expose the shared safe diagnostic. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Codex behavior depends on the deployment's host CLI, while Claude Code behavior depends on the Bundle-pinned platform CLI; both retain native account and workspace settings plus the selected Provider permission mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. Compatibility is pinned by package-level unit coverage, keyless real-product loopback tests, credentialed DeepSeek nonce tests, public Loader composition, built-package and NodeNext consumer checks, generated documentation and notices, and the repository CI matrix. A supported product or DeepSeek endpoint/model baseline change must refresh those facts; production performs no separate runtime version probe. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 761fe5df5a..8ad7b6d704 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -harness 交付两个同级的一次性提供方包,其默认注册名称分别为 `codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[命名实例决策](2026-08-18-product-subagent-named-instances.md)负责 Profile 选择的提供方身份与静态工具绑定,[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)则负责各产品提供方的 Profile 模式选择与诊断生产。两个包都接受多个命名实例。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品与实例选择仍属于部署配置。 +harness 交付两个同级的一次性提供方包,其默认注册名称分别为 `codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[命名实例决策](2026-08-18-product-subagent-named-instances.md)负责 Profile 选择的提供方身份与静态工具绑定,[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责各自独立的可选 Bundle 与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)则负责各产品提供方的 Profile 模式选择与诊断生产。两个包都接受多个命名实例。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品与实例选择仍属于部署配置。 这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'` 与 `maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs` 和 `dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、进程树终止以及整棵进程树的退出观测。 @@ -48,7 +48,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 ## Claude Code 提供方 -`@deepseek-ai/dsh-subagent-claude-code` 注册由 Profile 选择、默认值为 `claude-code` 的提供方名称,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。每次运行前,提供方经宿主 subprocess 执行世界解析固定的 `claude` 可执行文件名称,并把准确路径作为 `pathToClaudeCodeExecutable` 交给 SDK;SDK 因此使用启动 DSH 的原生产品,而不是选择自身的 platform `optionalDependency`。Windows `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境展开值穿过 `cmd.exe /v:off`,因此路径中的百分号、与号和感叹号仍只是数据,且无需改变共享子进程约定。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 +`@deepseek-ai/dsh-subagent-claude-code` 注册由 Profile 选择、默认值为 `claude-code` 的提供方名称,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。提供方会省略 `pathToClaudeCodeExecutable`,因此 SDK 会从自己的 optional dependency 闭包中,按操作系统、CPU 与 Linux libc 选择携带 Claude Code 2.1.220 的匹配平台包。提供方既不会解析也不会回退宿主 `claude`;省略 optional dependency、不受支持的平台,以及缺失或损坏的平台载荷,都会在第一次委派的 SDK 启动边界失败。提供方使用官方 `query()` 入口点,并把 SDK 的 `spawnClaudeCodeProcess` 给出的原生 `claude` 或 `claude.exe` 命令、参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 公开配置包含非空的 `providerName`、显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `dontAsk` 的五值原生 `permissionMode`。每个命名实例会为自己的运行保留这些已解析值。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false`、禁用 `AskUserQuestion`,并把已解析模式传给 SDK;只有 `bypassPermissions` 会取得 SDK 的显式危险确认。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。其余权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败,而不会等待本提供方不负责的用户界面。 @@ -66,7 +66,7 @@ Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Claude Code 2.1.220 CLI 作为确定性兼容性 fixture(测试前置数据),且该 fixture 经生产环境所用的同一原生可执行文件解析路径运行。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、安全提供方模式对继承的交互式宿主设置的覆盖、测试所拥有临时目录中的拒绝写入与 bypass 写入、安全权限诊断、进程失败、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。这项证据证明官方 SDK/CLI 集成路径,而不证明它与每个独立安装的产品版本兼容。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 +Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220,以及八个 SDK 平台包的身份与版本。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、安全提供方模式对继承的交互式宿主设置的覆盖、测试所拥有临时目录中的拒绝写入与 bypass 写入、安全权限诊断、进程失败、本地取消和整棵进程树退出。单元覆盖会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖、直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim,并且在载荷缺失时原样暴露 SDK 错误且不回退宿主 CLI。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容。Loader 覆盖会通过显式 Host 组装解析 Codex,并通过可选 Bundle 解析 Claude Code,且不会启动任一产品。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 @@ -90,6 +90,6 @@ Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Cl 用户通过由 Profile 配置、并由官方产品集成支持的一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责;命名实例身份与工具绑定由[命名实例决策](2026-08-18-product-subagent-named-instances.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与进程树完全停稳的责任。 -每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的产品运行可以另行公开共享安全诊断。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。产品原生配置使行为取决于部署环境中安装的产品、账户状态、工作区设置和所选提供方模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 +每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的产品运行可以另行公开共享安全诊断。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。Codex 行为取决于部署环境的宿主 CLI,Claude Code 行为取决于 Bundle 锁定的平台 CLI;两者都保留原生账户与工作区设置以及所选提供方权限模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 兼容性由包级单元测试覆盖率、无密钥真实产品回环测试、带密钥 DeepSeek 随机数测试、公开 Loader 组合、已构建包与 NodeNext 消费方检查、生成的文档与声明以及仓库 CI 矩阵共同锁定。更改受支持的产品基线或 DeepSeek 端点/模型基线时必须刷新这些事实;生产环境不会另行执行运行时版本探测。 diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml index 44f197e39d..f842aecafe 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md -2026-08-12-production-dsh-excludes-product-subagent-providers.md: 3e3e4fbefb31932a637bfe05ff0d90916e202a79 -2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md: 166964675bf7084b62f5500969e5756c9bd9f644 +2026-08-12-production-dsh-excludes-product-subagent-providers.md: 779bff6f668c086722f817a28a224182d0fbac09 +2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md: 27106668f8b81491d43e2033c1468799783c0584 diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md index 3e3e4fbefb..779bff6f66 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md @@ -6,20 +6,24 @@ English | [中文](2026-08-12-production-dsh-excludes-product-subagent-providers ## Problem -`@deepseek-ai/dsh` receives the `@deepseek-ai/dsh-base` dependency closure. Including the Codex and Claude Code subagent providers there makes every production install download optional product integration code, including the Claude Agent SDK, even when neither integration is used. +`@deepseek-ai/dsh` receives the `@deepseek-ai/dsh-base` dependency closure. Including the Codex and Claude Code subagent providers there makes every production install download optional product integration code, including the Claude Agent SDK and its roughly 250 MB unpacked platform CLI payload, even when neither integration is used. ## Decision -This decision supersedes the [shared-host placement](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md): `@deepseek-ai/dsh-base` does not depend on or mount the Codex and Claude Code subagent providers. Their packages remain available for Profiles that install and mount them explicitly. Repository examples keep direct development dependencies so their explicit provider configurations continue to resolve. +This decision partially supersedes only the default-inclusion part of the [shared-host placement](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md): `@deepseek-ai/dsh-base` does not depend on or mount the Codex and Claude Code subagent providers. The Claude Code provider package is a directly installable Profile Bundle whose `dsh.bundle.patch` points to one package-owned `cordis.patch.yml`. That patch contributes exactly one self-provider Host row and no Agent tool row. The Codex package remains available for deployments that mount it explicitly. + +The two optional integrations remain independent. Codex continues to use a host `codex` from `PATH`. The Claude Code Bundle owns the pinned Agent SDK and the matching platform CLI selected from the SDK's optional dependencies; production uses that private CLI and never falls back to a host `claude`. Installing the Claude Code Bundle does not pull in the Codex package, and the default `@deepseek-ai/dsh` production closure contains neither provider, the Claude Agent SDK, nor its platform payloads. The Bundle registers a dormant provider on the next Profile start, while an Agent Preset independently decides whether a new Session receives its tool. Installation brings only the Claude Code package closure onto disk; it does not start a product, authenticate an account, rewrite native settings, or grant model access. ## Verification -The base bundle test rejects both provider dependencies and configuration rows. Cordis configuration validation requires explicit examples to declare the provider packages they name. +Package tests pin the Claude Code Bundle manifest, published patch, exact self-provider row, and runtime closure. Claude coverage pins Agent SDK 0.3.220, Claude Code 2.1.220, all eight platform package identities and versions, the SDK-selected executable entering the shared subprocess owner, and first-delegation failure without host fallback when the payload is missing. Workspace validation derives each published patch from its Bundle declaration rather than a package catalog. Production-closure tests prove the default and Claude-only dependency boundaries, while real Bundle-patch and Agent-Preset composition covers absent and installed Host states, disabled and enabled tool grants, later-Session adoption, and zero product processes. Existing Codex package tests continue to cover explicit Host composition and host executable resolution. The base bundle test continues to reject both provider dependencies and configuration rows. ## Alternatives considered **Keep dormant providers in the base bundle.** Dormant providers start no product processes, but their packages still enter every production npm install. +**Add a wrapper or meta Bundle.** A third package would duplicate installation ownership and make independent removal less direct without contributing another runtime capability. + ## Consequences -Installing `@deepseek-ai/dsh` does not download either product provider through the base bundle. Using either integration requires explicit Profile configuration. +Installing `@deepseek-ai/dsh` does not download either product provider through the base bundle. A Profile can add or remove the Claude Code provider Bundle directly; the changed Host availability takes effect on the next Profile start and explicitly accepts its SDK plus one large platform CLI payload. A Codex deployment still mounts that provider explicitly and supplies its product CLI through `PATH`. A separately authored Agent Preset grants either model-visible tool only to newly composed Sessions. No wrapper package, meta Bundle, dynamic installer, or persisted product-enable state is introduced. diff --git a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md index 166964675b..27106668f8 100644 --- a/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md @@ -6,20 +6,24 @@ Status: implemented ## 问题 -`@deepseek-ai/dsh` 会获得 `@deepseek-ai/dsh-base` 的依赖闭包。如果 base 包含 Codex 与 Claude Code subagent 提供方,每次生产安装都会下载可选的产品集成代码,包括 Claude Agent SDK,即使用户并未使用任一集成。 +`@deepseek-ai/dsh` 会获得 `@deepseek-ai/dsh-base` 的依赖闭包。如果 base 包含 Codex 与 Claude Code subagent 提供方,每次生产安装都会下载可选的产品集成代码,包括 Claude Agent SDK 及其解包后约 250 MB 的平台 CLI 载荷,即使用户并未使用任一集成。 ## 决策 -本决策取代[共享 host 放置决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md):`@deepseek-ai/dsh-base` 不依赖也不挂载 Codex 与 Claude Code subagent 提供方。需要这些集成的 Profile 仍可显式安装并挂载对应包。仓库 examples 保留直接开发依赖,使其显式提供方配置可以继续解析。 +本决策只部分取代[共享 host 放置决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)中关于默认包含提供方的部分:`@deepseek-ai/dsh-base` 不依赖也不挂载 Codex 与 Claude Code subagent 提供方。Claude Code 提供方包是可直接安装的 Profile Bundle,其 `dsh.bundle.patch` 指向包自身拥有的 `cordis.patch.yml`。该 patch 恰好贡献一条挂载自身提供方的 Host 行,不包含 Agent 工具行。Codex 包仍供部署环境显式挂载。 + +两个可选集成彼此独立。Codex 继续使用 `PATH` 中的宿主 `codex`。Claude Code Bundle 自己负责锁定的 Agent SDK,以及从 SDK optional dependencies 中选出的匹配平台 CLI;生产运行只使用该私有 CLI,绝不会回退到宿主 `claude`。安装 Claude Code Bundle 不会带入 Codex 包,默认的 `@deepseek-ai/dsh` 生产依赖闭包既不包含任一提供方,也不包含 Claude Agent SDK 或其平台载荷。该 Bundle 会在下次 Profile 启动时注册一个休眠提供方,而 Agent Preset 独立决定新 Session 是否获得对应工具。安装只会把 Claude Code 包闭包放到磁盘上;它不会启动产品、验证账户、改写原生设置或向模型授予访问权。 ## 验证 -base 组合包测试会拒绝这两个提供方依赖与配置行。Cordis 配置验证要求显式 examples 声明其引用的提供方包。 +包测试会固定 Claude Code Bundle 的 manifest、发布 patch、准确的自身提供方行以及运行时闭包。Claude 覆盖会固定 Agent SDK 0.3.220、Claude Code 2.1.220、八个平台包的身份与版本、SDK 所选可执行文件进入共享子进程责任方的路径,以及载荷缺失时第一次委派失败且不回退宿主 CLI。工作区验证会从 Bundle 声明派生每个发布 patch,而非维护包目录。生产闭包测试证明默认与仅 Claude 两种依赖边界;真实 Bundle patch 与 Agent Preset 组装会覆盖 Host 中缺席和已安装两种状态、禁用和启用两种工具授权、后续 Session 采纳以及零产品进程。现有 Codex 包测试继续覆盖显式 Host 组装和宿主可执行文件解析。base 组合包测试仍会拒绝这两个提供方依赖与配置行。 ## 考虑过的替代方案 **在 base 组合包中保留休眠提供方。** 休眠提供方不会启动产品进程,但其包仍会进入每次生产 NPM 安装。 +**新增 wrapper 或 meta Bundle。** 第三个包会重复安装责任,使独立移除变得更间接,却不会贡献新的运行时能力。 + ## 后果 -安装 `@deepseek-ai/dsh` 时,不会通过 base 组合包下载任一产品提供方。使用任一集成都需要显式 Profile 配置。 +安装 `@deepseek-ai/dsh` 时,不会通过 base 组合包下载任一产品提供方。Profile 可以直接添加或移除 Claude Code provider Bundle;Host 可用性的变化会在下次 Profile 启动时生效,并代表明确接受其 SDK 与一个大型平台 CLI 载荷。Codex 部署仍须显式挂载该 provider,并通过 `PATH` 提供产品 CLI。单独创作的 Agent Preset 仍只会向新组装的 Session 授予任一模型可见工具。本决策不引入 wrapper 包、meta Bundle、动态安装程序或持久化的产品启用状态。 diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index 407938e5c8..581f51773e 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -198,9 +198,10 @@ toolName: subagent_fork backgroundMode: continuable - # Production dsh does not install these optional providers. An opting-in - # Profile mounts each provider once on the host plane; copy this preset, - # then remove `disabled` from the matching tool row. + # Production dsh does not install these optional providers. A deployment + # mounts Codex explicitly; the Claude Code Bundle mounts its provider once + # on the host plane. Copy this preset, then remove `disabled` from the + # matching tool row; Host availability alone grants no tool. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index e808250c00..db5f01021f 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -185,9 +185,10 @@ toolName: subagent_fork backgroundMode: continuable - # Production dsh does not install these optional providers. An opting-in - # Profile mounts each provider once on the host plane; copy this preset, - # then remove `disabled` from the matching tool row. + # Production dsh does not install these optional providers. A deployment + # mounts Codex explicitly; the Claude Code Bundle mounts its provider once + # on the host plane. Copy this preset, then remove `disabled` from the + # matching tool row; Host availability alone grants no tool. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md index 9cd6d70109..4be1db1142 100644 --- a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -123,7 +123,16 @@ After a clean mount-validation, ask the user to start a session on the new prese ## Native product subagents -Codex and Claude Code providers belong on the host plane but are not installed by production `dsh`. The active Profile must install and mount the selected provider before a preset can expose its ordinary delegation-tool row; never move a product provider into the preset and never add a product-specific settings field. +The Claude Code provider is an optional Profile Bundle. Install it only in Profiles that need it, then restart the Profile so its Host registers the provider: + +```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code +dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code +``` + +The Bundle owns Claude Code Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing the package withdraws the provider on the next Profile start. + +Codex remains an explicitly mounted Host plugin rather than a directly installable Bundle. A deployment that uses it must install and mount the package once on the Host plane before a preset can expose its tool. Copy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested: @@ -149,7 +158,7 @@ Copy these disabled templates from a shipped full preset and remove `disabled` o For additional named Codex or Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings. -The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install or mount either optional provider: before enabling a row, the Profile must install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` package and mount the required provider instances on the host plane. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. The host must also provide `codex` or `claude` on `PATH`; the preset does not install, authenticate, select a model for, or probe either product. +The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install either optional provider: before enabling a row, install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` Bundle in the Profile and restart it. Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI; additional named instances use extra host-plane rows from the same installed package. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Installing a Bundle or composing a preset row does not start a product, authenticate an account, select a model, probe credentials, or manage native product settings. ## What not to move into a preset diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index b8559cfd74..4637293576 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -197,9 +197,10 @@ toolName: subagent_fork backgroundMode: continuable - # Production dsh does not install these optional providers. An opting-in - # Profile mounts each provider once on the host plane; copy this preset, - # then remove `disabled` from the matching tool row. + # Production dsh does not install these optional providers. A deployment + # mounts Codex explicitly; the Claude Code Bundle mounts its provider once + # on the host plane. Copy this preset, then remove `disabled` from the + # matching tool row; Host availability alone grants no tool. - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index b62de584e2..cb2090c1f2 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -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 apps/cli/reference/README.md -README.md: 0be64fdfc0ad4e81d23f25a26881fa89f37565b0 -README.zh.md: 649bf15df814abf4875fed794c2e76c494bfcc25 +README.md: f95973c05401d73a70db07b6ea4c76cd16f406f3 +README.zh.md: aa8a877bc53e430c867d384a2b0653255db5079d diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 0be64fdfc0..f95973c054 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -42,6 +42,15 @@ dsh --profile web --patch ./extra.yml --dump-config `dsh plugin --profile ` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `` to `pnpm` with the profile directory as working directory — `add`, `remove`, `why`, `update`, and every other pnpm verb work unchanged; pnpm must be on PATH. Relative path specs (`.`, `../plugin`, and their `file:`/`link:` forms) are anchored to the invoking directory first, so `add .` from a plugin checkout installs that checkout, not the profile. After every successful run, `dsh.profile.bundles` is reconciled against the installed state: each dependency resolving to a package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` joins the layer stack (so an `update` that gains the declaration activates it), a bundle-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack. +The Claude Code subagent provider is an optional Bundle. Add or remove it independently: + +```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code +dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code +``` + +The successful pnpm operation changes the Profile manifest and Bundle list on disk; a running Profile keeps the Bundle set from its current start. Restart that Profile after adding, removing, or updating the Bundle. This startup boundary applies to Bundle membership, while ordinary edits to the Profile or home `cordis.patch.yml` take effect through hot reload. On the next start, the Bundle registers its dormant Host provider; a copied Preset must separately enable the matching tool row for new Agents. The [Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md) owns executable, authentication, payload, and failure details; the [subagent package reference](../../../packages/subagent/README.md) owns the current Codex deployment path; and the [base Bundle reference](../../../packages/bundle/base/README.md) owns the default dependency closure. + ```sh dsh plugin --profile tui add github:deepseek-harness/turtle-ui dsh plugin --profile tui remove turtle-ui diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 649bf15df8..aa8a877bc5 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -42,6 +42,15 @@ dsh --profile web --patch ./extra.yml --dump-config `dsh plugin --profile ` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `` 转发给 `pnpm`:`add`、`remove`、`why`、`update` 及其他所有 pnpm 子命令都照常可用;pnpm 必须在 PATH 上。相对路径 spec(`.`、`../plugin` 及其 `file:`/`link:` 形式)会先锚定到调用目录,因此在插件 checkout 中执行 `add .` 安装的是该 checkout,而不是 profile。每次成功运行后,系统都会根据当前安装状态更新 `dsh.profile.bundles`:如果某项依赖解析到的包在 manifest 中声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`,该依赖就会加入配置层栈;如果某项依赖在 `update` 后获得该声明,也会随即激活。没有组合包声明的依赖仍作为普通依赖保留,并显示一次性警告;已移除的依赖则从配置层栈中删除。 +Claude Code subagent provider 是一个可选 Bundle,可以独立添加或移除: + +```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code +dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code +``` + +pnpm 操作成功后只会改变磁盘上的 Profile manifest 与 Bundle 列表;正在运行的 Profile 会保留本次启动时的 Bundle 集合。添加、移除或更新 Bundle 后须重启该 Profile。这个启动边界只适用于 Bundle 成员变化,Profile 或 home 中普通 `cordis.patch.yml` 的编辑通过热重载生效。下一次启动时,Bundle 会注册休眠的 Host provider;还须在复制出的 Preset 中单独启用对应工具行,新 Agent 才能看到该工具。[Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md)负责可执行文件、身份验证、载荷与失败细节;[subagent 包参考](../../../packages/subagent/README.md)负责当前 Codex 部署路径;[base Bundle 参考](../../../packages/bundle/base/README.md)负责默认依赖闭包。 + ```sh dsh plugin --profile tui add github:deepseek-harness/turtle-ui dsh plugin --profile tui remove turtle-ui diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index f887c170d3..c8e551306f 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -650,6 +650,21 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } expect(Object.keys(manifest.dependencies)).toEqual(['anchored-bundle']) expect(manifest.dsh.profile.bundles).toContain('anchored-bundle') + + const removed = await runBuiltBin( + ['plugin', '--profile', 'anchor', 'remove', 'anchored-bundle'], + { DSH_HOME: home }, + checkout, + ) + expect(removed.code).toBe(0) + const afterRemove = JSON.parse( + readFileSync(join(home, 'profiles', 'anchor', 'package.json'), 'utf8'), + ) as { + dependencies?: Record + dsh: { profile: { bundles: string[] } } + } + expect(Object.keys(afterRemove.dependencies ?? {})).toEqual([]) + expect(afterRemove.dsh.profile.bundles).not.toContain('anchored-bundle') } finally { rmSync(home, { recursive: true, force: true }) rmSync(checkout, { recursive: true, force: true }) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 7b347353fd..b4e875c07d 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto' -import { mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' @@ -9,7 +9,7 @@ import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent' @@ -26,9 +26,9 @@ const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) /** The shipped Web surface: the dsh-base and dsh-web-app bundle patches over an empty preset root. */ const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') const WEB_PATCH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml') +const CLAUDE_CODE_PATCH = join(REPO_ROOT, 'packages/subagent/subagent-claude-code/cordis.patch.yml') /** The installation anchor whose dependency surface the preset module fallback mirrors. */ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') -const EXAMPLES_INSTALL_ANCHOR = join(REPO_ROOT, 'examples/package.json') const MINIMAL_PROMPT = 'You are a helpful software engineer assistant.' const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. @@ -47,7 +47,7 @@ const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell async function bootWeb( settingsFile: string, extra: PatchOptions[] = [], - extraInstallAnchor?: string, + profilePackages: readonly string[] = [], ): Promise { const storageRoot = join(dirname(settingsFile), 'storages') const patches: PatchOptions[] = [ @@ -115,9 +115,18 @@ async function bootWeb( // them resolvable — the same mechanism, not a test-only shim. const home = dirname(settingsFile) healProfilesModuleFallback(INSTALL_ANCHOR, home) - if (extraInstallAnchor !== undefined) healProfilesModuleFallback(extraInstallAnchor, home) const profileDir = join(home, 'profiles', 'spec') await mkdir(profileDir, { recursive: true }) + // Product Bundles are installed into the Profile, not the dsh app. Model + // pnpm's package link for only the selected products; their own production + // dependencies resolve from the linked workspace packages, while shared + // peers still resolve through the installation fallback above. + for (const packageDir of profilePackages) { + const manifest = JSON.parse(await readFile(join(packageDir, 'package.json'), 'utf8')) as { name: string } + const link = join(profileDir, 'node_modules', manifest.name) + await mkdir(dirname(link), { recursive: true }) + await symlink(packageDir, link, 'junction') + } const rootConfig = join(profileDir, 'cordis.yml') await writeFile(rootConfig, '[]\n') return await boot('dsh-test', rootConfig, patches, (bootCtx) => { @@ -434,33 +443,28 @@ describe('the shipped Web composition', () => { }) }) -describe('product subagent rows in user presets', () => { - let productCtx: Context - const ids = ['products-none', 'products-codex', 'products-claude', 'products-both'] as const +describe('Claude Code Bundle and user-preset intersection', () => { + const presetIds = ['products-none', 'products-claude'] as const + type PresetId = typeof presetIds[number] - beforeAll(async () => { + async function bootProducts(installed: boolean): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-')) const userRoot = join(root, 'presets') const settingsFile = join(root, 'settings.yaml') const standard = await readFile(join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml'), 'utf8') await writeFile(settingsFile, '{}\n') - for (const id of ids) { + for (const id of presetIds) { let composition = standard - if (id === 'products-codex' || id === 'products-both') { - composition = enablePresetTool(composition, 'tool-subagent-codex') - } - if (id === 'products-claude' || id === 'products-both') { + if (id === 'products-claude') { composition = enablePresetTool(composition, 'tool-subagent-claude-code') } const directory = join(userRoot, id) await mkdir(directory, { recursive: true }) await writeFile(join(directory, 'agent.cordis.yml'), composition) } - productCtx = await bootWeb(settingsFile, [ - { insert: [ - { id: 'subagent-codex', name: '@deepseek-ai/dsh-subagent-codex' }, - { id: 'subagent-claude-code', name: '@deepseek-ai/dsh-subagent-claude-code' }, - ] }, + const productPatches = installed ? loadOverlayPatches('dsh-test', CLAUDE_CODE_PATCH) : [] + return await bootWeb(settingsFile, [ + ...productPatches, { id: 'agent-presets', config: { @@ -472,46 +476,55 @@ describe('product subagent rows in user presets', () => { includeUserRoot: false, }, }, - ], EXAMPLES_INSTALL_ANCHOR) - }, 120_000) + ], installed ? [dirname(CLAUDE_CODE_PATCH)] : []) + } - afterAll(async () => { - await productCtx.fiber.dispose() - }) + it('composes the intersection of the installed Bundle and enabled preset row', async () => { + const scenarios: Array<{ installed: boolean; presets: readonly PresetId[] }> = [ + { installed: false, presets: presetIds }, + { installed: true, presets: presetIds }, + ] - it('composes none, either product, or both without changing the shared host registry', async () => { - const expected = new Map([ - ['products-none', []], - ['products-codex', ['subagent_codex']], - ['products-claude', ['subagent_claude_code']], - ['products-both', ['subagent_claude_code', 'subagent_codex']], - ]) - expect(productCtx.subagents.list()).toEqual(expect.arrayContaining([ - 'spawn', 'fork', 'codex', 'claude-code', - ])) - - for (const [id, productTools] of expected) { - const handle = await productCtx.agents.create({ - sessionId: SessionId(`preset-${id}`), - setup: agentCtx => productCtx.agentPresets.mount(agentCtx, id).then(() => undefined), - }) + for (const { installed, presets } of scenarios) { + const productCtx = await bootProducts(installed) + const spawn = vi.spyOn(productCtx.subprocess, 'spawn') try { - const tools = toolNames(productCtx, handle.agent) - expect(tools.filter(name => name === 'subagent_codex' || name === 'subagent_claude_code')) - .toEqual(productTools) - expect(tools).toEqual(expect.arrayContaining(['job_kill', 'job_list', 'job_output'])) - for (const productTool of productTools) { - expect(toolParameterNames(productCtx, handle.agent, productTool)).toEqual([ - 'description', 'prompt', 'run_in_background', - ]) + expect(productCtx.subagents.list() + .filter(name => name === 'codex' || name === 'claude-code') + .sort()) + .toEqual(installed ? ['claude-code'] : []) + for (const id of presets) { + const handle = await productCtx.agents.create({ + sessionId: SessionId(`preset-${id}-${installed ? 'claude' : 'none'}-${randomUUID()}`), + setup: agentCtx => productCtx.agentPresets.mount(agentCtx, id).then(() => undefined), + }) + try { + const productTools = installed && id === 'products-claude' + ? ['subagent_claude_code'] + : [] + const tools = toolNames(productCtx, handle.agent) + expect(tools.filter(name => name === 'subagent_codex' || name === 'subagent_claude_code')) + .toEqual(productTools) + expect(tools).toEqual(expect.arrayContaining(['job_kill', 'job_list', 'job_output'])) + for (const productTool of productTools) { + expect(toolParameterNames(productCtx, handle.agent, productTool)).toEqual([ + 'description', 'prompt', 'run_in_background', + ]) + } + } finally { + await handle.dispose() + } } + expect(spawn).not.toHaveBeenCalled() } finally { - await handle.dispose() + spawn.mockRestore() + await productCtx.fiber.dispose() } } - }) + }, 120_000) it('applies a product-row edit only to later sessions on the preset', async () => { + const productCtx = await bootProducts(true) const preset = await productCtx.agentPresets.resolve('products-none') const original = await readFile(preset.path, 'utf8') const existing = await productCtx.agents.create({ @@ -519,24 +532,25 @@ describe('product subagent rows in user presets', () => { setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined), }) try { - expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex') - await writeFile(preset.path, enablePresetTool(original, 'tool-subagent-codex')) + expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_claude_code') + await writeFile(preset.path, enablePresetTool(original, 'tool-subagent-claude-code')) const later = await productCtx.agents.create({ sessionId: SessionId('preset-product-generation-later'), setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined), }) try { - expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex') - expect(toolNames(productCtx, later.agent)).toContain('subagent_codex') + expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_claude_code') + expect(toolNames(productCtx, later.agent)).toContain('subagent_claude_code') } finally { await later.dispose() } } finally { await existing.dispose() await writeFile(preset.path, original) + await productCtx.fiber.dispose() } - }) + }, 120_000) }) describe('a switch survives the session', () => { diff --git a/apps/web/tests/skill-tool-row.e2e.ts b/apps/web/tests/skill-tool-row.e2e.ts index af6c941bcd..5c23f99935 100644 --- a/apps/web/tests/skill-tool-row.e2e.ts +++ b/apps/web/tests/skill-tool-row.e2e.ts @@ -17,7 +17,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-tool-row', import. const UI_EXPECTED = fileURLToPath(new URL('./snapshots/skill-tool-row/ui.expected.md', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'skill-tool-row-web-e2e' -const PROMPT = 'Load the snapshot-skill skill with the skill tool, then reply DONE.' +const PROMPT = 'Load the editing-cordis-compositions skill with the skill tool, then reply DONE.' describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => { let scaffold: WebScaffold @@ -53,17 +53,17 @@ describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => { it('expands the loaded skill to its exact recorded instructions', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-tool-row')) const call = page.locator('[data-tool="skill"]') - const row = call.getByRole('button', { name: 'Skill snapshot-skill' }) + const row = call.getByRole('button', { name: 'Skill editing-cordis-compositions' }) await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false') - expect(await call.getByText('snapshot-skill', { exact: true }).count()).toBe(1) + expect(await call.getByText('editing-cordis-compositions', { exact: true }).count()).toBe(1) await row.click() await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true') await call.getByText('Instructions', { exact: true }).waitFor() const output = call.locator('pre') await output.waitFor() - expect(await output.textContent()).toContain('') - expect(await output.textContent()).toContain('Follow these snapshot-only instructions.') + expect(await output.textContent()).toContain('') + expect(await output.textContent()).toContain('Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI') expect(await output.evaluate(element => getComputedStyle(element.parentElement!).maxHeight)).toBe('260px') const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) diff --git a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md index 6dfa55d454..8c7f593914 100644 --- a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md +++ b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md @@ -1,13 +1,13 @@ - banner: - navigation "Session hierarchy": - - button "Load the snapshot-skill skill with" [disabled] + - button "Load the editing-cordis-compositions ski" [disabled] - button "Session log": - text: Session log - img - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: Load the snapshot-skill skill with the skill tool, then reply DONE. {{date}} {{clock}} +- text: Load the editing-cordis-compositions skill with the skill tool, then reply DONE. {{date}} {{clock}} - button "Copy": - img - button "Context injection @deepseek-ai/dsh-system-prompt": @@ -22,10 +22,10 @@ - img - img - text: Think Load the requested skill. -- button "Skill snapshot-skill" [expanded]: +- button "Skill editing-cordis-compositions" [expanded]: - img - - text: Skill snapshot-skill -- region "Instructions": "Instructions Base directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. Follow these snapshot-only instructions. Resolve referenced resources relative to this skill directory. " + - text: Skill editing-cordis-compositions +- region "Instructions": "Instructions Base directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. # Editing Cordis compositions Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it. ## Off-limits **Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation. To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete. ## Decide the plane first Two planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared. **Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process. **Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it. **A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side. A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created. ## The roster service `ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step. Read `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on: - `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent. - `read(id)` — one preset's composition text, without a file tool or a path. - `copy(from, id, name?)` — the only authoring write (see below). - `standingKeyFor(id)` — mount-validate one preset (see below). ```js return { name: 'preset-tools', inject: ['agentPresets', 'tools'], apply(ctx) { harness.registerTool(ctx, harness.defineTool({ name: 'preset_check', description: 'Mount-validate one preset by id.', parameters: { id: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } }, async execute(args) { try { await ctx.agentPresets.standingKeyFor(args.id) return 'mounted OK' } catch (error) { return error.message } }, })) }, } ``` Unmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind. ## Authoring a preset 1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source. 2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do. 3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`. 4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule. 5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*. A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable. ## The rule that catches people **A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later. Whether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service. When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here: ```yaml - id: delegation name: cordis:group group: true isolate: workflows: true config: - id: workflow-worker-thread name: '@deepseek-ai/dsh-workflow-worker-thread' config: provider: spawn - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' ``` `true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs. A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated. Realms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm. ## Verifying a change **`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails: - a row whose package does not resolve (`Cannot find package …`); - a row whose config is invalid (`invalid config: $. missing required value`); - a row that never activated (`N row(s) did not activate: : waiting for `); - a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service. It returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind. **Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition. `cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do. After a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces. `cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file. ## Native product subagents The Claude Code provider is an optional Profile Bundle. Install it only in Profiles that need it, then restart the Profile so its Host registers the provider: ```sh dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` The Bundle owns Claude Code Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing the package withdraws the provider on the next Profile start. Codex remains an explicitly mounted Host plugin rather than a directly installable Bundle. A deployment that uses it must install and mount the package once on the Host plane before a preset can expose its tool. Copy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested: ```yaml - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true config: provider: codex toolName: subagent_codex backgroundMode: one-shot maxDepth: provider-managed - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' disabled: true config: provider: claude-code toolName: subagent_claude_code backgroundMode: one-shot maxDepth: provider-managed ``` For additional named Codex or Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings. The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install either optional provider: before enabling a row, install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` Bundle in the Profile and restart it. Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI; additional named instances use extra host-plane rows from the same installed package. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Installing a Bundle or composing a preset row does not start a product, authenticate an account, select a model, probe credentials, or manage native product settings. ## What not to move into a preset `agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement. " - button "Inspect" - button "Think The skill is loaded.": - img diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 099cd1c3d0..4742906b8a 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse } from 'node:http' -import { mkdir, utimes, writeFile } from 'node:fs/promises' +import { copyFile, mkdir, utimes, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { homedir } from 'node:os' import { expect, it } from 'vitest' @@ -36,6 +36,10 @@ const AGENT = { configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), } +const EDITING_CORDIS_SKILL = fileURLToPath(new URL( + '../../../apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md', + import.meta.url, +)) // The Code Mode overlay configs (include-patched variants of cordis.yml; the // replay swap resolves each one's sibling `*cordis.snapshot.yml`). @@ -82,6 +86,12 @@ const FS_DIFF_BOUND_CONFIG = fileURLToPath(new URL('./fs-diff-bound.cordis.yml', const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' +async function prepareEditingCordisSkillWorkspace(cwd: string): Promise { + const target = join(cwd, '.dsh', 'skills', 'editing-cordis-compositions', 'SKILL.md') + await mkdir(dirname(target), { recursive: true }) + await copyFile(EDITING_CORDIS_SKILL, target) +} + async function prepareDelimiterPathWorkspace(cwd: string): Promise { const dir = join(cwd, 'scope') await mkdir(dir, { recursive: true }) @@ -321,6 +331,7 @@ const SCENARIOS: Scenario[] = [ headerClass: 'skill', systemPromptSource: 'text-turn', toolSchemasSource: 'text-turn', + prepareWorkspace: prepareEditingCordisSkillWorkspace, }, { name: 'lsp-definition', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'lsp', configPath: LSP_CONFIG }, // web_fetch markdown rendering end to end: the overlay's loopback fixture diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml index fb2d08678b..3ac0dbbf00 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml @@ -1,5 +1,5 @@ -# Test-only composition of Codex plus two named Claude instances and their tools. -# The owning e2e boots this tree but never invokes a model or product process. +# Test-only composition of Codex, the Bundle-supplied default Claude provider, +# and two named Claude instances. It never invokes a model or product process. - id: fixture name: './fixture.ts' @@ -30,6 +30,14 @@ backgroundMode: one-shot maxDepth: 'provider-managed' +- id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + backgroundMode: one-shot + maxDepth: 'provider-managed' + - id: tool-subagent-claude-primary name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts index 018550468b..3f662268ac 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts @@ -1,20 +1,21 @@ #!/usr/bin/env node -/** Inspect both public product-provider compositions without invoking them. */ +/** Inspect the public Claude Code Bundle composition without invoking the product. */ -import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { boot, loadOverlayPatches, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-subagent' import type {} from '@deepseek-ai/dsh-tools' const configPath = process.argv[2] -if (configPath === undefined) { - throw new Error('product-provider Loader composition driver requires a config path') +const bundlePatchPath = process.argv[3] +if (configPath === undefined || bundlePatchPath === undefined) { + throw new Error('Claude Code Loader composition driver requires config and Bundle patch paths') } let starts = 0 const ctx = await boot( - 'product-provider-loader-composition', + 'subagent-claude-code-loader-composition', resolveConfigPath(configPath, undefined), - undefined, + loadOverlayPatches('subagent-claude-code-loader-composition', bundlePatchPath), (hostCtx) => { hostCtx.on('subagent/start', () => { starts += 1 @@ -23,9 +24,15 @@ const ctx = await boot( ) try { - const providerNames = ['codex', 'claude-primary', 'claude-secondary'] as const + const providerNames = [ + 'codex', + 'claude-code', + 'claude-primary', + 'claude-secondary', + ] as const const toolNames = [ 'subagent_codex', + 'subagent_claude_code', 'subagent_claude_primary', 'subagent_claude_secondary', ] as const diff --git a/examples/acp-agent/tests/snapshots/skill-load/input.json b/examples/acp-agent/tests/snapshots/skill-load/input.json index a5ee78bff6..48235fc667 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/input.json +++ b/examples/acp-agent/tests/snapshots/skill-load/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "text": "Load the snapshot-skill skill with the skill tool, then reply DONE." } + { "op": "prompt", "text": "Load the editing-cordis-compositions skill with the skill tool, then reply DONE." } ] } diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index ec369b492a..2ab00a9939 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -1,25 +1,25 @@ {"type":"session","version":0,"id":"9eb4181f-2d05-49d3-98fc-3711fe2f5664","createdAt":1783654655599,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498773710,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"0ca31b92-27ac-451d-98d3-d1e5f605454b"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498773710,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Load the editing-cordis-compositions skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"0ca31b92-27ac-451d-98d3-d1e5f605454b"}]}} {"type":"turn/start","seq":1,"time":1785821378605,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821378605,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1785498773754,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498773754,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"0ca31b92-27ac-451d-98d3-d1e5f605454b"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785498773754,"data":{"content":[{"type":"text","text":"Load the editing-cordis-compositions skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"0ca31b92-27ac-451d-98d3-d1e5f605454b"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1785498773755,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"3fc7e2f8-90fc-496c-b516-700cef1d86f1"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730426818,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"60880315-9799-44c8-8a99-e6fe9ee5bdc5"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730426818,"data":{"title":"Load the snapshot-skill skill with","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":6,"time":1785730426818,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `editing-cordis-compositions`: Use when creating, changing, or validating a Cordis composition for this harness — writing or editing an agent preset, adding or removing a plugin row, deciding whether something belongs to the host composition or to one session, checking whether a preset you authored actually mounts, or diagnosing a row that mounted but contributed nothing.\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"editing-cordis-compositions","description":"Use when creating, changing, or validating a Cordis composition for this harness — writing or editing an agent preset, adding or removing a plugin row, deciding whether something belongs to the host composition or to one session, checking whether a preset you authored actually mounts, or diagnosing a row that mounted but contributed nothing."},{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"59831057-0914-4e8b-967d-ef7dc850a62a"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1785730426818,"data":{"title":"Load the editing-cordis-compositions ski","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":8,"time":1785498773756,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":9,"time":1785730426819,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":10,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":11,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Load the requested skill."}}} {"type":"assistant/chunk","seq":12,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":13,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_skill_load","name":"skill","argumentsDelta":"{\"name\":\"snapshot-skill\"}"}}} +{"type":"assistant/chunk","seq":13,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_skill_load","name":"skill","argumentsDelta":"{\"name\":\"editing-cordis-compositions\"}"}}} {"type":"assistant/chunk","seq":14,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}}} -{"type":"assistant/chunk","seq":15,"time":1784903324935,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}}}} +{"type":"assistant/chunk","seq":15,"time":1784903324935,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}}}} {"type":"assistant/chunk","seq":16,"time":1785498773765,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}}} {"type":"assistant/chunk","seq":17,"time":1785730426828,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":18,"time":1785730426828,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3fd7a47e-84c9-4d31-aa95-9939671ba0a5"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}},"sourceEventSeqs":[10,11,12,13,14,15,16,17],"surfaceOp":"append"} -{"type":"tool/call","seq":19,"time":1785730426828,"data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}} -{"type":"tool/result","seq":20,"time":1785730426838,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\nFollow these snapshot-only instructions.\nResolve referenced resources relative to this skill directory.\n\n"}],"isError":false}],"role":"user","id":"1609c2f6-3bc5-4ade-95dd-29e7f7565987"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"assistant/message","seq":18,"time":1785730426828,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3fd7a47e-84c9-4d31-aa95-9939671ba0a5"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}},"sourceEventSeqs":[10,11,12,13,14,15,16,17],"surfaceOp":"append"} +{"type":"tool/call","seq":19,"time":1785730426828,"data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}} +{"type":"tool/result","seq":20,"time":1785730426838,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Editing Cordis compositions\n\nEvery capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it.\n\n## Off-limits\n\n**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation.\n\nTo change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete.\n\n## Decide the plane first\n\nTwo planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared.\n\n**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process.\n\n**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it.\n\n**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.\n\nA preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name.\n\nLocally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created.\n\n## The roster service\n\n`ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step.\n\nRead `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on:\n\n- `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent.\n- `read(id)` — one preset's composition text, without a file tool or a path.\n- `copy(from, id, name?)` — the only authoring write (see below).\n- `standingKeyFor(id)` — mount-validate one preset (see below).\n\n```js\nreturn {\n name: 'preset-tools',\n inject: ['agentPresets', 'tools'],\n apply(ctx) {\n harness.registerTool(ctx, harness.defineTool({\n name: 'preset_check',\n description: 'Mount-validate one preset by id.',\n parameters: { id: { type: 'string', required: true } },\n output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } },\n async execute(args) {\n try {\n await ctx.agentPresets.standingKeyFor(args.id)\n return 'mounted OK'\n } catch (error) {\n return error.message\n }\n },\n }))\n },\n}\n```\n\nUnmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind.\n\n## Authoring a preset\n\n1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source.\n2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do.\n3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`.\n4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule.\n5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*.\n\nA composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable.\n\n## The rule that catches people\n\n**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later.\n\nWhether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service.\n\nWhen a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here:\n\n```yaml\n- id: delegation\n name: cordis:group\n group: true\n isolate:\n workflows: true\n config:\n - id: workflow-worker-thread\n name: '@deepseek-ai/dsh-workflow-worker-thread'\n config:\n provider: spawn\n - id: tool-workflow\n name: '@deepseek-ai/dsh-tool-workflow'\n```\n\n`true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs.\n\nA consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated.\n\nRealms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm.\n\n## Verifying a change\n\n**`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails:\n\n- a row whose package does not resolve (`Cannot find package …`);\n- a row whose config is invalid (`invalid config: $. missing required value`);\n- a row that never activated (`N row(s) did not activate: : waiting for `);\n- a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service.\n\nIt returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind.\n\n**Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition.\n\n`cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do.\n\nAfter a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces.\n\n`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.\n\n## Native product subagents\n\nThe Claude Code provider is an optional Profile Bundle. Install it only in Profiles that need it, then restart the Profile so its Host registers the provider:\n\n```sh\ndsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code\ndsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code\n```\n\nThe Bundle owns Claude Code Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing the package withdraws the provider on the next Profile start.\n\nCodex remains an explicitly mounted Host plugin rather than a directly installable Bundle. A deployment that uses it must install and mount the package once on the Host plane before a preset can expose its tool.\n\nCopy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested:\n\n```yaml\n- id: tool-subagent-codex\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: codex\n toolName: subagent_codex\n backgroundMode: one-shot\n maxDepth: provider-managed\n\n- id: tool-subagent-claude-code\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: claude-code\n toolName: subagent_claude_code\n backgroundMode: one-shot\n maxDepth: provider-managed\n```\n\nFor additional named Codex or Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings.\n\nThe two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install either optional provider: before enabling a row, install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` Bundle in the Profile and restart it. Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI; additional named instances use extra host-plane rows from the same installed package. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Installing a Bundle or composing a preset row does not start a product, authenticate an account, select a model, probe credentials, or manage native product settings.\n\n## What not to move into a preset\n\n`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement.\n\n"}],"isError":false}],"role":"user","id":"a423b3fb-a703-4494-b186-5861ab00cc03"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","seq":21,"time":1785730426838,"data":{"turn":1,"step":1}} {"type":"step/start","seq":22,"time":1785730426848,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":23,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/packages/bundle/README.i18n.yaml b/packages/bundle/README.i18n.yaml index eafbe0b0ab..b42a5e5d52 100644 --- a/packages/bundle/README.i18n.yaml +++ b/packages/bundle/README.i18n.yaml @@ -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/README.md -README.md: 696aa9ef7bbed23774f2b9ab2648ca83edcf0978 -README.zh.md: 2bb22c7949d759f404288548ea0eccfa0aac866b +README.md: 3afa53c1444b43c38b7a71f3e9e00d271078be54 +README.zh.md: 740b3579ce1555f2b1b26ca79e8a3d915a338193 diff --git a/packages/bundle/README.md b/packages/bundle/README.md index 696aa9ef7b..3afa53c144 100644 --- a/packages/bundle/README.md +++ b/packages/bundle/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) Profile bundles: npm packages whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`, making them installable patch layers for `dsh --profile` compositions ([profile contract](../boot/app-boot/README.md#profiles)). A bundle's substance is its patch list; some also ship runtime glue plugins their patch mounts. +The manifest declaration, not this directory, defines Bundle identity. Domain packages can carry their own optional Profile layer; the [Claude Code subagent package](../subagent/subagent-claude-code/README.md) is a directly installable example. + | Package | Role | ctx key | |---|---|---| | [`base/`](base/README.md) | The shared dsh core every profile applies first | — (patch only) | diff --git a/packages/bundle/README.zh.md b/packages/bundle/README.zh.md index 2bb22c7949..740b3579ce 100644 --- a/packages/bundle/README.zh.md +++ b/packages/bundle/README.zh.md @@ -4,6 +4,8 @@ Profile 组合包:在 manifest(元数据清单)中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包,因此可作为 patch 层安装进 `dsh --profile` 组合([profile 约定](../boot/app-boot/README.md#profiles))。组合包的实体是它的 patch 列表;有些组合包还附带由其 patch 挂载的运行时粘合插件。 +Bundle 身份由 manifest 声明决定,而不是由本目录决定。领域包可以携带自己的可选 Profile 层;[Claude Code subagent 包](../subagent/subagent-claude-code/README.md)就是可直接安装的例子。 + | 包 | 职责 | ctx key | |---|---|---| | [`base/`](base/README.md) | 每个 profile 最先应用的共享 dsh 核心 | —(仅 patch) | diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 483784e547..ea1fb9b03a 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -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/base/README.md -README.md: 00a177d4dd65de8706aa0e27a79d06a9a50eb55a -README.zh.md: ad70fc16035d3dd70b04e78c31aea90acf61119b +README.md: 5fdd642ecc03fea77b2b00fc6428525c1a40d891 +README.zh.md: c2a07ec15816e41eadf682bcd8631c93bce77ae0 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 00a177d4dd..5fdd642ecc 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, telemetry, and host-level subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. This bundle neither depends on nor mounts the optional Codex and Claude Code providers; an opting-in Profile installs and mounts the selected provider once on the host plane, while Agent Presets decide whether their agents receive the corresponding model-facing delegation tools. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. +The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, telemetry, and the core spawn/fork subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. The optional Codex and Claude Code providers stay outside this package and its production dependency closure; a Profile can install the [Claude Code provider Bundle](../../subagent/subagent-claude-code/README.md) only when needed, while a deployment that uses Codex still mounts that provider explicitly. The default `@deepseek-ai/dsh` production closure therefore includes neither product provider nor the Claude Agent SDK. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. The patch gates both shell stacks by platform on its own rows: `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'` (bash has no Windows runner), and their twins `pwsh-sandbox`/`tool-pwsh` mount on win32 only with the inverted expression — one shared patch file, exactly one shell stack per host. The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. A Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts see the pwsh rows disabled. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index ad70fc1603..c2a07ec158 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials、遥测与宿主级 subagent provider——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。这个 bundle 既不依赖也不挂载可选的 Codex 与 Claude Code provider;选择产品集成的 Profile 会安装目标 provider 并在 host plane(宿主平面)挂载一次,Agent Preset 则决定自己的 agent 是否获得对应的面向模型委派工具。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 +以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials、遥测与核心 spawn/fork subagent provider——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。可选的 Codex 与 Claude Code provider 不属于本包及其生产依赖闭包;Profile 可以仅在需要时安装 [Claude Code provider Bundle](../../subagent/subagent-claude-code/README.md),使用 Codex 的部署仍须显式挂载该 provider。因此,默认的 `@deepseek-ai/dsh` 生产依赖闭包既不包含任一产品 provider,也不包含 Claude Agent SDK。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 patch 在自身上按平台门控两个 shell 栈:`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`(bash 没有 Windows runner),它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载——同一份 patch 文件,每个宿主恰好挂载一个 shell 栈。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。偏好不受沙盒约束的本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时直接报错)。POSIX 主机看到的是被禁用的 pwsh 行。 diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 5a5dc2c181..ed6dd44e77 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/README.i18n.yaml @@ -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: a863ed3f5ef864b6eb6eb9a7a0c1ee2f40f247d6 -README.zh.md: 1c9bf8ba0814a74c5774d81a34a3266daa9c375b +README.md: e6503c0f9d9861b41a39ad2790b7c8a171fd9bc0 +README.zh.md: 489577b58446d1ccd1ec7393f46d19f94609d73e diff --git a/packages/subagent/README.md b/packages/subagent/README.md index a863ed3f5e..e6503c0f9d 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -18,6 +18,8 @@ This family lets an agent delegate work to child agents. Multiple named provider | [`tool-subagent-control/`](tool-subagent-control/README.md) | Exposes child messaging and listing to the model | registers on `ctx.tools` | | [`tool-subagent-report/`](tool-subagent-report/README.md) | Provides the child-to-parent report channel | registers in child scopes | +The Claude Code package is also an optional Profile Bundle. Install it with `dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code`, then restart that Profile; the package registers only its dormant Host provider. To grant the tool, copy a complete Agent Preset, remove `disabled` from the matching tool row, and start a new Session. Removing the package withdraws that provider on the next Profile start. The Codex package remains an explicitly mounted Host plugin and uses a host `codex` from `PATH`. + See the decisions for the [capability family](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [continuable children](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [control tools](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). The subsystem reference — start requests, results, live runs, the provider contract, continuable background children — is [docs/subsystems/subagent.md](../../docs/subsystems/subagent.md); design rationale in the [subagent capability seam](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [continuable background subagents](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [merged subagent control service](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md) Agent Notes. diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 1c9bf8ba08..489577b584 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -18,6 +18,8 @@ | [`tool-subagent-control/`](tool-subagent-control/README.md) | 向模型公开子级消息发送和列举操作 | 注册到 `ctx.tools` | | [`tool-subagent-report/`](tool-subagent-report/README.md) | 提供从子级到父级的报告通道 | 注册到子级作用域 | +Claude Code 包也是一个可选的 Profile Bundle。使用 `dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code` 安装后重启该 Profile;该包只注册休眠的 Host provider。要授予工具,请复制一份完整 Agent Preset,删除对应工具行的 `disabled`,再启动新 Session。移除该包后,下一次 Profile 启动会撤回对应 provider。Codex 包仍须作为 Host 插件显式挂载,并使用 `PATH` 中的宿主 `codex`。 + 参见有关[能力家族](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续执行的子级](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)和[控制工具](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)的决策。 子系统参考——启动请求、结果、实时运行、提供方约定、可续跑后台子 agent——见 [docs/subsystems/subagent.md](../../docs/subsystems/subagent.md);设计依据见 [subagent 能力 seam](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可续跑后台 subagent](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)与[合并 subagent 控制服务](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md) Agent Note。 diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 8f08087709..70d7ffab83 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md -README.md: bc33d97fb6d7224138e01fa86c3ce28b00df08b8 -README.zh.md: ad7cca3e9da654ae7d4d13739ff81992c7670e04 +README.md: 67a8cf199a2e85d975a4df90f56d5dbafb9f1e56 +README.zh.md: 0b8f2f285924de161cefd21c422fd40ac3597ad7 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index bc33d97fb6..67a8cf199a 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This package registers a Profile-named Claude Code subagent provider whose default name is `claude-code`. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, resolves the native `claude` executable through the shared subprocess service, submits one self-contained text task, and returns either the strict final answer or safe failure detail through the shared [`dsh-subagent`](../subagent/README.md) result contract. +This package registers a Profile-named Claude Code subagent provider whose default name is `claude-code`. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, lets the pinned SDK select its installed platform CLI, submits one self-contained text task, and returns either the strict final answer or a separate safe failure diagnostic through the shared [`dsh-subagent`](../subagent/README.md) result contract. ## Start and ownership @@ -39,9 +39,17 @@ The provider advertises no optional start-time capabilities and reports `inherit | `plan` | Run in native planning mode, deny execution approval, and return the completed plan as the final answer. | | `bypassPermissions` | Explicitly set the SDK's dangerous confirmation and bypass permission checks. | -Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe /v:off` expands once, so valid path metacharacters remain data. The pinned SDK's fixed flags then occupy cmd's command tail and contain no cmd metacharacters; they are not ordinary Windows argv. Native settings and authentication remain authoritative. 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. +Production omits `pathToClaudeCodeExecutable`, so Agent SDK 0.3.220 selects the matching native `claude` or `claude.exe` from its own platform package and passes that absolute command through the custom-spawn hook to `dsh-subprocess`. The provider does not inspect `PATH`, implement platform selection, or fall back to a host `claude`. Native settings and authentication remain authoritative, while `permissionMode` is the only query-level policy override. The plugin does not 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; `PATH` does not choose the Claude executable. -Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-claude-code` and may mount one or more host-plane rows with distinct `providerName`, `permissionMode`, and `env` values; omitting `providerName` keeps the `claude-code` default. Loading an instance starts no Claude process until a bound tool calls it. Each `dsh-tool-subagent` row names one provider and needs its own `toolName`, so the model sees static tools rather than a dynamic provider selector. Full Agent Presets carry a matching default product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls. +This package is an optional Profile Bundle. Install it into the target Profile, then restart that Profile; installation brings the pinned Agent SDK and one compatible platform CLI payload into that Profile, while the declared `cordis.patch.yml` layer registers only the dormant `claude-code` Host provider and starts no Claude process. Removing the package withdraws that provider and its private runtime closure on the next Profile start. + +```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code +dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code +dsh --profile +``` + +Installation controls Host availability, not model permission. The Bundle supplies the dormant default `claude-code` row; the Profile may replace that row's complete config or mount additional rows with distinct `providerName`, `permissionMode`, and `env` values. Loading an instance starts no Claude process until a bound tool calls it. Each `dsh-tool-subagent` row names one provider and needs its own `toolName`, so the model sees static tools rather than a dynamic provider selector. Full Agent Presets carry a matching default product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls. The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows, adds the product provider and tool rows, and does not mount duplicate Job services. @@ -61,7 +69,9 @@ The standalone composition below shows the complete explicit capability. A Profi permissionMode: bypassPermissions env: ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY +``` +```yaml - id: jobs name: '@deepseek-ai/dsh-jobs-local' @@ -70,6 +80,7 @@ The standalone composition below shows the complete explicit capability. A Profi - id: tool-subagent-claude-safe name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: claude-safe toolName: subagent_claude_safe @@ -87,7 +98,11 @@ The standalone composition below shows the complete explicit capability. A Profi ## Product compatibility and evidence -The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`. Production runs the native `claude` installation. The keyless real-product test uses the SDK-distributed Claude Code 2.1.220 CLI as a deterministic fixture, routed through the same native executable-resolution and Windows batch-shim path; it does not claim compatibility with every independently installed version. Loader composition proves that two named Claude instances and the Codex package coexist without starting either product. +The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`, whose eight platform packages carry Claude Code 2.1.220. A normal install selects one payload for the current OS, CPU, and Linux libc. For the current darwin-arm64 payload, `npm pack --dry-run --json` reports 74,858,812 packed bytes and 256,908,856 unpacked bytes; other platforms may differ, and these values are disclosure rather than an installation threshold. The keyless real-product test runs the SDK-selected CLI against a loopback Messages fixture and asserts that the shared subprocess argv begins with that platform package's native executable. Loader composition proves that installing the Bundle registers only the dormant Claude Code provider and starts no product process. + +Installing with optional dependencies omitted, using an unsupported platform, or losing the selected payload leaves provider registration dormant but makes the first delegation fail with the SDK's native-payload startup error. The provider neither probes a host CLI nor retries with one. + +Loader composition proves that the Bundle default, two additional named Claude instances, and the existing Codex package 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. @@ -97,7 +112,7 @@ The project owner's identity-scoped distribution authorization covers the offici #### 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; its model, system instructions, tools, sandbox, and authentication come from the host's native Claude settings and product installation, while the selected Provider instance's Profile configuration fixes the query's environment and non-interactive permission mode. +The Claude Code child receives the standalone text task as one fresh SDK query. Its workspace is the parent Session cwd; its model, system instructions, tools, sandbox, and authentication come from native Claude settings, the selected Provider instance's Profile configuration fixes the query's environment and non-interactive permission mode, and the executable version comes from the Bundle's pinned SDK platform payload. #### Token effect @@ -126,8 +141,8 @@ Append-only: foreground adds one result after the reusable parent prefix, while - **One fresh query and process per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. - **Static instance selection** — Profile rows fix provider names and tool bindings; calls cannot choose a provider dynamically, and every exposed tool needs a unique `toolName`. - **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** — a missing or incompatible `claude`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow. -- **The SDK platform CLI remains in the install closure** — production ignores it in favor of the host `claude`, but the current SDK optional dependency is still installed and supplies the keyless compatibility fixture. Removing that payload belongs to the separate product installation-closure follow-up. +- **Authentication and account state remain native** — the Bundle supplies the CLI but does not create an account, log in, or rewrite Claude settings; configuration and authentication failures surface as startup or run errors. +- **The SDK platform payload is required at delegation time** — installs that omit optional dependencies, unsupported platforms, and missing or damaged payloads fail at the first query; there is no host-CLI fallback. - **No human interaction path** — `AskUserQuestion` is disabled, permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of suspending. - **Assistant payload is final text only** — a failed run may additionally expose the separate safe diagnostic; reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local, while generic Job ids, notices, and status come from the shared job runtime. - **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index ad7cca3e9d..0b8f2f2859 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本包(package)注册由 Profile 命名、默认名称为 `claude-code` 的 Claude Code subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务解析原生 `claude` 可执行文件,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定返回严格的最终答案或安全的失败说明。 +本包(package)注册由 Profile 命名、默认名称为 `claude-code` 的 Claude Code subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,让锁定版本的 SDK 选择随包安装的平台 CLI,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定返回严格的最终答案或独立的安全失败诊断。 ## 启动与所有权 @@ -39,9 +39,17 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | `plan` | 使用原生规划模式,拒绝执行审批,并把完整计划作为最终答案返回。 | | `bypassPermissions` | 显式设置 SDK 的危险确认并跳过权限检查。 | -生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe /v:off` 展开一次,因此合法路径中的元字符仍只是数据。锁定版本的 SDK 随后把固定命令行选项放在 cmd 的命令尾部;这些选项不含 cmd 元字符,也并不是普通的 Windows argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 +生产环境会省略 `pathToClaudeCodeExecutable`,因此 Agent SDK 0.3.220 会从自己的平台包中选择匹配的原生 `claude` 或 `claude.exe`,再通过 custom-spawn 钩子把该绝对命令交给 `dsh-subprocess`。提供方不会检查 `PATH`、重复实现平台选择,也不会回退到宿主 `claude`。原生设置与身份验证继续是权威来源,而 `permissionMode` 是唯一的 query 级策略覆盖。本插件不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承;`PATH` 不参与选择 Claude 可执行文件。 -生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-claude-code`,并可在 host plane(宿主平面)挂载一个或多个具有不同 `providerName`、`permissionMode` 与 `env` 的配置项;省略 `providerName` 时仍使用默认的 `claude-code`。加载实例本身不会在绑定工具调用前启动 Claude 进程。每个 `dsh-tool-subagent` 配置项指定一个提供方,并需要独立的 `toolName`,因此模型看到的是静态工具,而不是动态提供方选择器。完整 Agent Preset 携带对应的默认产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 +本包是可选的 Profile Bundle。将它安装进目标 Profile 后重启该 Profile;安装会把锁定的 Agent SDK 与一个兼容的平台 CLI 载荷带入该 Profile,而包所声明的 `cordis.patch.yml` 层只注册休眠的 `claude-code` Host provider,不会启动 Claude 进程。移除该包后,下一次 Profile 启动会撤回这一 provider 及其私有运行时闭包。 + +```sh +dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code +dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code +dsh --profile +``` + +安装决定 Host 可用性,而不是模型权限。Bundle 会提供休眠的默认 `claude-code` 配置项;Profile 可以替换该配置项的完整 config,也可以挂载更多具有不同 `providerName`、`permissionMode` 与 `env` 的配置项。加载实例本身不会在绑定工具调用前启动 Claude 进程。每个 `dsh-tool-subagent` 配置项指定一个提供方,并需要独立的 `toolName`,因此模型看到的是静态工具,而不是动态提供方选择器。完整 Agent Preset 携带对应的默认产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 下列独立组装展示完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 保留已有 Job 配置项,新增产品提供方与工具配置项,而且不重复挂载 Job 服务。 @@ -61,7 +69,9 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK permissionMode: bypassPermissions env: ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY +``` +```yaml - id: jobs name: '@deepseek-ai/dsh-jobs-local' @@ -70,6 +80,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK - id: tool-subagent-claude-safe name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: claude-safe toolName: subagent_claude_safe @@ -87,7 +98,11 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK ## 产品兼容性与证据 -运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`。生产运行使用原生 `claude` 安装。无密钥真实产品测试使用由 SDK 分发的 Claude Code 2.1.220 CLI 作为确定性 fixture(测试前置数据),并通过同一套原生可执行文件解析路径与 Windows batch shim 路径运行;这项测试不声称兼容每个独立安装的版本。Loader 组合证明两个命名 Claude 实例可与 Codex 包共存,而且不会启动任一产品。 +运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其八个平台包都携带 Claude Code 2.1.220。普通安装会按当前操作系统、CPU 及 Linux libc 选择一个载荷。对于当前 darwin-arm64 载荷,`npm pack --dry-run --json` 报告压缩包为 74,858,812 字节、解包后为 256,908,856 字节;其他平台可能不同,这些数值只用于披露而不是安装阈值。无密钥真实产品测试会让 SDK 选择 CLI,通过回环 Messages fixture 运行它,并断言共享子进程 argv 的首项就是该平台包的原生可执行文件。Loader 组合证明安装该 Bundle 只会注册休眠的 Claude Code provider,不会启动产品进程。 + +如果安装时省略 optional dependencies、当前平台不受支持,或所选载荷缺失,提供方注册仍保持休眠,但第一次委派会以 SDK 的原生载荷启动错误失败。提供方既不会探测宿主 CLI,也不会用它重试。 + +Loader 组合证明 Bundle 默认实例、两个额外命名 Claude 实例与现有 Codex 包可以共存,而且不会启动任一产品。 限定于项目所有者身份的分发授权涵盖官方 SDK 及每个 SDK 版本声明的官方 CLI/平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) 会披露当前可选载荷闭包,但不会认定其中声明的条款属于宽松许可;其他无关的非宽松运行时依赖仍会使第三方声明门禁失败。 @@ -97,7 +112,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK #### 模型看到的内容 -Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。它的工作区是父会话 cwd;其模型、系统指令、工具、沙箱和身份验证来自宿主机原生 Claude 设置与产品安装,而所选提供方实例的 Profile 配置会固定该 query 的环境与非交互权限模式。 +Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。它的工作区是父会话 cwd;其模型、系统指令、工具、沙箱和身份验证来自原生 Claude 设置,所选提供方实例的 Profile 配置会固定该 query 的环境与非交互权限模式,而可执行版本来自 Bundle 锁定的 SDK 平台载荷。 #### 对 token 的影响 @@ -126,8 +141,8 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。 - **每次运行均新建一个 query 和一个进程**:不支持续接、恢复、池化、进度流或产品会话持久化。 - **静态选择实例**:Profile 配置项固定提供方名称与工具绑定;调用无法动态选择提供方,而且每个公开工具都需要唯一的 `toolName`。 - **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。 -- **产品安装与账户状态仍由原生机制管理**:`claude` 缺失或不兼容、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 -- **SDK 平台 CLI 仍在安装闭包内**:生产环境会忽略它,改用宿主提供的 `claude`,但当前 SDK 的可选依赖仍会安装,并提供无密钥兼容性 fixture。移除该载荷属于独立的产品安装闭包后续项。 +- **身份验证与账户状态仍由原生机制管理**:Bundle 会提供 CLI,但不会创建账户、登录或改写 Claude 设置;配置与身份验证失败会呈现为启动错误或运行错误。 +- **委派时必须存在 SDK 平台载荷**:省略 optional dependencies 的安装、不受支持的平台以及缺失或损坏的载荷都会在第一次 query 时失败;不会回退到宿主 CLI。 - **没有人工交互路径**:`AskUserQuestion` 被禁用,权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败而不会挂起。 - **assistant 载荷仅包含最终文本**:失败运行可以额外公开独立的安全诊断;推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部,通用 Job id、通知与状态来自共享作业运行时。 - **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 diff --git a/packages/subagent/subagent-claude-code/cordis.patch.yml b/packages/subagent/subagent-claude-code/cordis.patch.yml new file mode 100644 index 0000000000..63c0319626 --- /dev/null +++ b/packages/subagent/subagent-claude-code/cordis.patch.yml @@ -0,0 +1,6 @@ +# This optional Profile layer registers the dormant Claude Code provider. Agent +# presets separately decide whether one session receives its delegation tool. + +- insert: + - id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index 0c0e54cf11..fe974b0344 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -28,9 +28,15 @@ "files": [ "lib/index.js", "lib/invariant.js", + "cordis.patch.yml", "lib/types/**/*.d.ts" ], "license": "MIT", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -43,7 +49,9 @@ "dependencies": { "@anthropic-ai/sdk": "0.93.0", "@anthropic-ai/claude-agent-sdk": "0.3.220", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index 3cd6de36d1..51b4bdc4a2 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -83,18 +83,12 @@ class ClaudeCodeProvider implements SubagentProvider { 'subagent-claude-code: no working directory for the child — delegate from a parent session that has one', ) } - const executable = await this.ctx.subprocess.resolveExecutable( - 'claude', - this.config.env, - request.signal, - ) const spec: ClaudeCodeRunSpec = { cwd: resolveChildCwd( 'subagent-claude-code', undefined, parentCwd, ), - executable, permissionMode: this.config.permissionMode, env: this.config.env, disposeGraceMs: this.config.disposeGraceMs, diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index 1e2a259ca2..32a545bf08 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -6,7 +6,6 @@ */ import { EventEmitter } from 'node:events' -import { extname } from 'node:path' import type { SpawnedProcess, SpawnOptions, @@ -17,8 +16,6 @@ import { type SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' -const WINDOWS_BATCH_EXECUTABLE_ENV = 'DSH_CLAUDE_CODE_EXECUTABLE' - function thrown(value: unknown): Error { /* v8 ignore next -- the subprocess seam rejects with Error. */ return value instanceof Error ? value : new Error(String(value)) @@ -43,33 +40,22 @@ export function sdkEnvironmentOverlay( * 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. - * @param platform - host platform selecting the Windows batch-shim boundary. * @returns the fully explicit shared subprocess request. - * @remarks The batch-shim path quotes only the resolved executable. The pinned SDK - * supplies fixed flag arguments without cmd metacharacters; cmd reparses that tail. */ export function claudeSpawnSpec( options: SpawnOptions, graceMs: number, - platform: NodeJS.Platform = process.platform, ): SubprocessSpawnSpec { if (options.cwd === undefined || options.cwd.length === 0) { throw new Error('subagent-claude-code: SDK spawn request omitted its workspace') } - const extension = extname(options.command).toLowerCase() - const batchShim = platform === 'win32' && (extension === '.cmd' || extension === '.bat') - const env = sdkEnvironmentOverlay(options.env) - const argv = batchShim - ? ['cmd.exe', '/d', '/v:off', '/s', '/c', `%${WINDOWS_BATCH_EXECUTABLE_ENV}%`, ...options.args] - : [options.command, ...options.args] - if (batchShim) env[WINDOWS_BATCH_EXECUTABLE_ENV] = `"${options.command}"` return { - argv, + argv: [options.command, ...options.args], cwd: options.cwd, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs, signal: options.signal, - env, + env: sdkEnvironmentOverlay(options.env), } } diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 82dcfb4eb4..fef044450c 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -72,8 +72,6 @@ function unattendedDiagnostic( export interface ClaudeCodeRunSpec { /** Parent Session workspace supplied to the SDK and real CLI. */ readonly cwd: string - /** Exact native Claude Code executable resolved from the host PATH. */ - readonly executable: string /** Profile-selected native non-interactive permission mode. */ readonly permissionMode: ClaudeCodePermissionMode /** Explicit deployment/test environment layered after shared scrubbing. */ @@ -90,6 +88,11 @@ 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)) } + +/** Read live request cancellation across awaited startup cleanup. */ +function isAborted(signal: AbortSignal): boolean { + return signal.aborted +} /* jscpd:ignore-end */ /** @@ -220,7 +223,6 @@ export function claudeQueryOptions( return { abortController: controller, cwd: spec.cwd, - pathToClaudeCodeExecutable: spec.executable, env: { ...scrubbedParentEnv(), ...spec.env }, persistSession: false, disallowedTools: spec.permissionMode === 'plan' @@ -324,12 +326,49 @@ export async function startClaudeCodeRun( request.signal.removeEventListener('abort', onAbort) const cancelledBeforeCleanup = controller.signal.aborted requestCancel() + const startupError = thrown(error) + if (child !== undefined && child.pid <= 0) { + let closeError: Error | undefined + try { + query?.close() + } catch (disposeError: unknown) { + closeError = thrown(disposeError) + } + + let spawnError = startupError + try { + await child.done + } catch (childError: unknown) { + spawnError = thrown(childError) + } + + const cancelled = cancelledBeforeCleanup || isAborted(request.signal) + if (closeError !== undefined) { + const failures = cancelled + ? [ + new Error('subagent-claude-code: request was aborted before SDK startup'), + spawnError, + closeError, + ] + : [spawnError, closeError] + throw new AggregateError( + failures, + cancelled + ? `subagent-claude-code: request was aborted before SDK startup; Claude Code process startup also failed: ${spawnError.message}; query cleanup also failed` + : `subagent-claude-code: Claude Code process startup failed: ${spawnError.message}; query cleanup also failed`, + ) + } + if (cancelled) { + throw new Error('subagent-claude-code: request was aborted before SDK startup') + } + throw spawnError + } if (child !== undefined) { try { await disposeClaudeCodeChild(query, child) } catch (disposeError: unknown) { throw new AggregateError( - [thrown(error), thrown(disposeError)], + [startupError, thrown(disposeError)], 'subagent-claude-code: startup failed and CLI cleanup also failed', ) } @@ -338,16 +377,15 @@ export async function startClaudeCodeRun( query.close() } catch (disposeError: unknown) { throw new AggregateError( - [thrown(error), thrown(disposeError)], + [startupError, 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) { + if (cancelledBeforeCleanup || isAborted(request.signal)) { throw new Error('subagent-claude-code: request was aborted before SDK startup') } - throw thrown(error) + throw startupError } const publishedQuery = query diff --git a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts index acdf98c1e9..5f339ce22e 100644 --- a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' @@ -12,26 +13,34 @@ const fixtureDir = fileURLToPath(new URL( )) const driver = join(fixtureDir, 'driver.ts') const configPath = join(fixtureDir, 'cordis.yml') +const packageDir = fileURLToPath(new URL('..', import.meta.url)) +const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { + dsh?: { bundle?: { patch?: string } } +} +const bundlePatch = manifest.dsh?.bundle?.patch +if (bundlePatch === undefined) throw new Error('Claude Code package must declare a Bundle patch') +const bundlePatchPath = join(packageDir, bundlePatch) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) describe('product-provider public Loader composition', () => { - it('loads two named Claude instances, their tools, and Codex without starting either product', async () => { + it('loads the Bundle default, two named Claude instances, their tools, and Codex 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, + binArgs: [configPath, bundlePatchPath], tsconfigPath: repoTsconfig, env: { - // Loading either optional package must not probe or start its binary. + // Loading the optional package must not probe or start a Claude binary. PATH: '', }, }) expect(stderr).toBe('') expect(JSON.parse(stdout)).toEqual({ - registeredProviders: ['codex', 'claude-primary', 'claude-secondary'], + registeredProviders: ['codex', 'claude-primary', 'claude-secondary', 'claude-code'], providers: [ { name: 'codex', @@ -43,6 +52,16 @@ describe('product-provider public Loader composition', () => { }, inheritsParentContext: false, }, + { + name: 'claude-code', + capabilities: { + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }, + inheritsParentContext: false, + }, { name: 'claude-primary', capabilities: { @@ -70,6 +89,11 @@ describe('product-provider public Loader composition', () => { parameterNames: ['description', 'prompt', 'run_in_background'], required: ['description', 'prompt'], }, + { + name: 'subagent_claude_code', + parameterNames: ['description', 'prompt', 'run_in_background'], + required: ['description', 'prompt'], + }, { name: 'subagent_claude_primary', parameterNames: ['description', 'prompt', 'run_in_background'], diff --git a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts index 89d08a1878..1881e14d33 100644 --- a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts @@ -7,7 +7,7 @@ import { rmSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { delimiter, dirname, join, resolve } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { Context } from '@deepseek-ai/cordis' @@ -87,7 +87,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)( ]) mkdirSync(directory) const env = { - PATH: `${dirname(claudeBin)}${delimiter}${process.env.PATH ?? ''}`, ANTHROPIC_AUTH_TOKEN: apiKey, ANTHROPIC_BASE_URL: `${deepSeekBaseUrl()}/anthropic`, ANTHROPIC_MODEL: 'deepseek-v4-pro[1m]', diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index b97bf4a3c0..a8e38885ef 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -4,12 +4,12 @@ import { mkdirSync, mkdtempSync, readFileSync, - symlinkSync, + realpathSync, writeFileSync, } from 'node:fs' import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { delimiter, dirname, join, resolve } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import type { @@ -121,14 +121,12 @@ interface RealHarness { readonly parent: Agent readonly workspace: string readonly env: Record - readonly executable: string } interface RealInstanceFixture { readonly fixture: MessagesFixture readonly workspace: string readonly env: Record - readonly executable: string } async function realInstanceFixture( @@ -140,17 +138,9 @@ async function realInstanceFixture( const workspace = join(root, 'workspace') const claudeConfig = join(root, 'claude-config') const xdgConfig = join(root, 'xdg') - const nativeBin = join(root, 'native&%literal%!bang!bin') mkdirSync(workspace) mkdirSync(claudeConfig) mkdirSync(xdgConfig) - mkdirSync(nativeBin) - const executable = join(nativeBin, process.platform === 'win32' ? 'claude.cmd' : 'claude') - if (process.platform === 'win32') { - writeFileSync(executable, `@echo off\r\n"${claudeBin}" %*\r\n`) - } else { - symlinkSync(claudeBin, executable) - } writeFileSync( join(claudeConfig, 'settings.json'), `${JSON.stringify({ @@ -164,7 +154,6 @@ async function realInstanceFixture( const fixture = await startMessagesFixture(behavior) fixtures.push(fixture) const env = { - PATH: `${nativeBin}${delimiter}${process.env.PATH ?? ''}`, ANTHROPIC_API_KEY: fakeKey, ANTHROPIC_BASE_URL: fixture.baseUrl, CLAUDE_CONFIG_DIR: claudeConfig, @@ -179,7 +168,7 @@ async function realInstanceFixture( ALL_PROXY: '', NO_PROXY: '127.0.0.1,localhost', } - return { fixture, workspace, env, executable } + return { fixture, workspace, env } } interface RealRuntime { @@ -232,7 +221,6 @@ async function realHarness( parent, workspace: instance.workspace, env: instance.env, - executable: instance.executable, }, fixture: instance.fixture, } @@ -275,7 +263,7 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 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(process.platform === 'win32' ? claudeBin : harness.executable, ['--version'], { + const version = await execFileAsync(claudeBin, ['--version'], { env: { ...process.env, ...harness.env }, }) expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)') @@ -292,18 +280,16 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 message.type === 'system' && message.subtype === 'init', ) expect(initMessage?.claude_code_version).toBe('2.1.220') - if (process.platform === 'win32') { - expect(harness.spawnSpecs[0]?.argv.slice(0, 6)).toEqual([ - 'cmd.exe', '/d', '/v:off', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%', - ]) - const batchExecutable = harness.spawnSpecs[0]?.env?.DSH_CLAUDE_CODE_EXECUTABLE - expect(batchExecutable?.startsWith('"')).toBe(true) - expect(batchExecutable?.endsWith('"')).toBe(true) - expect(batchExecutable?.slice(1, -1).toLowerCase()) - .toBe(harness.executable.toLowerCase()) - } else { - expect(harness.spawnSpecs[0]?.argv[0]).toBe(harness.executable) - } + const spawnedExecutable = harness.spawnSpecs[0]?.argv[0] + expect(spawnedExecutable).toBeDefined() + expect(process.platform === 'win32' + ? realpathSync(spawnedExecutable!).toLowerCase() + : realpathSync(spawnedExecutable!)) + .toBe(process.platform === 'win32' + ? realpathSync(claudeBin).toLowerCase() + : realpathSync(claudeBin)) + expect(harness.spawnSpecs[0]?.env) + .not.toHaveProperty('DSH_CLAUDE_CODE_EXECUTABLE') expect(fixture.requests).toHaveLength(1) const recorded = fixture.requests[0]! diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 3ebdf4f0d0..8f1ad34d8a 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -1,4 +1,7 @@ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' import { PassThrough } from 'node:stream' +import { fileURLToPath } from 'node:url' import type { Options, Query, @@ -9,6 +12,7 @@ import type { } from '@anthropic-ai/claude-agent-sdk' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' +import * as yaml from 'js-yaml' import { afterEach, beforeEach, @@ -55,6 +59,19 @@ type QueryFactory = (params: { const queryMock = vi.hoisted(() => vi.fn()) +const CLAUDE_AGENT_SDK_VERSION = '0.3.220' +const CLAUDE_CODE_VERSION = '2.1.220' +const CLAUDE_PLATFORM_PACKAGES = [ + '@anthropic-ai/claude-agent-sdk-darwin-arm64', + '@anthropic-ai/claude-agent-sdk-darwin-x64', + '@anthropic-ai/claude-agent-sdk-linux-arm64', + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl', + '@anthropic-ai/claude-agent-sdk-linux-x64', + '@anthropic-ai/claude-agent-sdk-linux-x64-musl', + '@anthropic-ai/claude-agent-sdk-win32-arm64', + '@anthropic-ai/claude-agent-sdk-win32-x64', +] as const + vi.mock('@anthropic-ai/claude-agent-sdk', async importOriginal => ({ ...await importOriginal(), query: queryMock, @@ -265,7 +282,6 @@ function fakeRun( const options: FakeRun['options'] = [] const spec: ClaudeCodeRunSpec = { cwd: '/workspace', - executable: '/native/claude', permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE, env: { ANTHROPIC_API_KEY: 'fake-key' }, disposeGraceMs: 5, @@ -300,6 +316,66 @@ afterEach(() => { }) describe('task admission and package contracts', () => { + it('ships one independently installable provider-only Bundle patch', () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { + dependencies?: Record + files?: string[] + dsh?: { bundle?: { patch?: string } } + } + expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') + expect(manifest.files).toContain('cordis.patch.yml') + expect(manifest.dependencies).toHaveProperty( + '@anthropic-ai/claude-agent-sdk', + CLAUDE_AGENT_SDK_VERSION, + ) + expect(manifest.dependencies).toHaveProperty( + '@modelcontextprotocol/sdk', + '^1.29.0', + ) + expect(manifest.dependencies).toHaveProperty('zod', '^4.4.3') + expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex') + + const sdkRoot = dirname(fileURLToPath( + import.meta.resolve('@anthropic-ai/claude-agent-sdk'), + )) + const sdkManifest = JSON.parse(readFileSync( + resolve(sdkRoot, 'package.json'), + 'utf8', + )) as { + version: string + claudeCodeVersion: string + optionalDependencies: Record + } + expect(sdkManifest.version).toBe(CLAUDE_AGENT_SDK_VERSION) + expect(sdkManifest.claudeCodeVersion).toBe(CLAUDE_CODE_VERSION) + expect(sdkManifest.optionalDependencies).toEqual(Object.fromEntries( + CLAUDE_PLATFORM_PACKAGES.map(packageName => [ + packageName, + CLAUDE_AGENT_SDK_VERSION, + ]), + )) + const lockfile = readFileSync(resolve(root, '../../../pnpm-lock.yaml'), 'utf8') + for (const packageName of CLAUDE_PLATFORM_PACKAGES) { + expect(lockfile).toContain( + ` '${packageName}@${CLAUDE_AGENT_SDK_VERSION}':`, + ) + expect(lockfile).toContain( + ` '${packageName}': ${CLAUDE_AGENT_SDK_VERSION}`, + ) + } + + const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8')) + const rows = Array.isArray(parsed) + ? (parsed as Array<{ insert?: Array<{ id?: string; name?: string }> }>).flatMap(entry => entry.insert ?? []) + : [] + expect(rows).toEqual([{ + id: 'subagent-claude-code', + name: '@deepseek-ai/dsh-subagent-claude-code', + }]) + expect(JSON.stringify(rows)).not.toContain('tool-subagent') + }) + it('preserves text sequences and rejects empty, blank, and non-text tasks', () => { expect(textTask([ { type: 'text', text: 'one' }, @@ -491,7 +567,7 @@ describe('task admission and package contracts', () => { const spawn = vi.spyOn(ctx.subprocess, 'spawn') .mockImplementation(() => child.handle) const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable') - .mockResolvedValue('/native/claude') + .mockResolvedValue('/host/bin/claude') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) await ctx.plugin(claudeCode, { providerName: 'claude-diagnostic', @@ -515,10 +591,15 @@ describe('task admission and package contracts', () => { ) expect(queryMock).not.toHaveBeenCalled() - resolveExecutable.mockRejectedValueOnce(new Error('claude missing from PATH')) + vi.stubEnv('PATH', '/host/bin') + queryMock.mockImplementationOnce(() => { + throw new Error( + 'Native CLI binary for fixture-platform not found. Reinstall @anthropic-ai/claude-agent-sdk without --omit=optional, or set options.pathToClaudeCodeExecutable.', + ) + }) await expect(ctx.subagents.start('claude-diagnostic', request())) - .rejects.toThrow('claude missing from PATH') - expect(queryMock).not.toHaveBeenCalled() + .rejects.toThrow('Native CLI binary for fixture-platform not found') + expect(resolveExecutable).not.toHaveBeenCalled() const run = await ctx.subagents.start('claude-diagnostic', request()) child.settle({ exitCode: 9, signal: null }) @@ -530,14 +611,10 @@ describe('task admission and package contracts', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining( 'subagent-claude-code "claude-diagnostic": child run failed (error):', )) - expect(resolveExecutable).toHaveBeenCalledWith( - 'claude', - expect.objectContaining({ ANTHROPIC_API_KEY: 'provider-fake-key' }), - expect.any(AbortSignal), - ) - expect(queryMock.mock.calls[0]?.[0].options.pathToClaudeCodeExecutable) - .toBe('/native/claude') - expect(queryMock.mock.calls[0]?.[0].options.permissionMode).toBe('auto') + expect(resolveExecutable).not.toHaveBeenCalled() + expect(queryMock.mock.calls[1]?.[0].options) + .not.toHaveProperty('pathToClaudeCodeExecutable') + expect(queryMock.mock.calls[1]?.[0].options.permissionMode).toBe('auto') expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: process.cwd(), graceMs: 29, @@ -617,20 +694,17 @@ describe('official spawn projection', () => { )).toThrow('SDK spawn request omitted its workspace') }) - it.each(['cmd', 'bat'])('routes a Windows .%s shim through cmd.exe', (extension) => { - const command = String.raw`C:\Program Files\Claude\claude.${extension}` + it('forwards the SDK-selected Windows native executable without a batch shim', () => { + const command = String.raw`C:\Program Files\Claude\claude.exe` const spec = claudeSpawnSpec(sdkSpawnOptions({ command, args: ['--output-format', 'stream-json'], - }), 7, 'win32') + }), 7) expect(spec.argv).toEqual([ - 'cmd.exe', '/d', '/v:off', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%', - '--output-format', 'stream-json', + command, '--output-format', 'stream-json', ]) - expect(spec.env).toEqual(expect.objectContaining({ - DSH_CLAUDE_CODE_EXECUTABLE: `"${command}"`, - })) + expect(spec.env).not.toHaveProperty('DSH_CLAUDE_CODE_EXECUTABLE') }) it('projects streams, exit facts, listeners, and idempotent tree termination', async () => { @@ -701,7 +775,6 @@ describe('query options and result mapping', () => { const diagnostics: string[] = [] const spec: ClaudeCodeRunSpec = { cwd: '/workspace', - executable: '/native/claude', permissionMode: 'acceptEdits', env: { HOST_VISIBLE: 'overridden', @@ -723,12 +796,12 @@ describe('query options and result mapping', () => { expect(options).toMatchObject({ abortController: controller, cwd: '/workspace', - pathToClaudeCodeExecutable: '/native/claude', persistSession: false, disallowedTools: ['AskUserQuestion'], permissionMode: 'acceptEdits', supportedDialogKinds: ['refusal_fallback_prompt'], }) + expect(options).not.toHaveProperty('pathToClaudeCodeExecutable') expect(options).not.toHaveProperty('allowDangerouslySkipPermissions') expect(options.env).toMatchObject({ HOST_VISIBLE: 'overridden', @@ -792,7 +865,6 @@ describe('query options and result mapping', () => { const child = fakeChild() const options = claudeQueryOptions({ cwd: '/workspace', - executable: '/native/claude', permissionMode, env: {}, disposeGraceMs: 17, @@ -816,7 +888,6 @@ describe('query options and result mapping', () => { const child = fakeChild() const options = claudeQueryOptions({ cwd: '/workspace', - executable: '/native/claude', permissionMode: 'plan', env: {}, disposeGraceMs: 17, @@ -942,7 +1013,6 @@ describe('run publication, cancellation, and settlement', () => { let childIndex = 0 const spec: ClaudeCodeRunSpec = { cwd: '/workspace', - executable: '/native/claude', permissionMode: 'dontAsk', env: {}, disposeGraceMs: 5, @@ -1012,7 +1082,6 @@ describe('run publication, cancellation, and settlement', () => { let index = 0 const spec: ClaudeCodeRunSpec = { cwd: '/workspace', - executable: '/native/claude', permissionMode: 'dontAsk', env: {}, disposeGraceMs: 5, @@ -1064,7 +1133,6 @@ describe('run publication, cancellation, and settlement', () => { request(undefined, parentAbort.signal), { cwd: '/workspace', - executable: '/native/claude', permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE, env: {}, disposeGraceMs: 5, @@ -1153,14 +1221,101 @@ describe('run publication, cancellation, and settlement', () => { expect(factoryController?.signal.aborted).toBe(true) expect(spawned.terminate).toHaveBeenCalledOnce() + const spawnError = Object.assign( + new Error('spawn /sdk/claude EACCES'), + { code: 'EACCES', path: '/sdk/claude' }, + ) const failedSpawn = fakeChild({ pid: -1, - doneError: new Error('spawn failed'), + doneError: spawnError, }) const failed = fakeRun([], undefined, failedSpawn) await expect(startClaudeCodeRun(request(), failed.spec)) - .rejects.toBeInstanceOf(AggregateError) + .rejects.toBe(spawnError) expect(failed.close).toHaveBeenCalledOnce() + expect(failedSpawn.terminate).not.toHaveBeenCalled() + expect(failedSpawn.waitForExit).not.toHaveBeenCalled() + + const failedSpawnAbort = new AbortController() + const cancelledFailedSpawn = fakeChild({ + pid: -1, + doneError: spawnError, + }) + const cancelledFailedClose = vi.fn() + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + failedSpawnAbort.abort(new Error('startup cancelled')) + return queryFrom([], undefined, cancelledFailedClose) + }) + await expect(startClaudeCodeRun( + request(undefined, failedSpawnAbort.signal), + { ...unused.spec, spawn: () => cancelledFailedSpawn.handle }, + )).rejects.toThrow('aborted before SDK startup') + expect(cancelledFailedClose).toHaveBeenCalledOnce() + + const cancelledFailedSpawnCloseError = new Error('cancelled query close failed') + const cancelledFailedSpawnClose = vi.fn(() => { + throw cancelledFailedSpawnCloseError + }) + const cancelledFailedSpawnWithCloseFailure = fakeChild({ + pid: -1, + doneError: spawnError, + }) + const failedSpawnAbortWithCloseFailure = new AbortController() + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + failedSpawnAbortWithCloseFailure.abort(new Error('startup cancelled')) + return queryFrom([], undefined, cancelledFailedSpawnClose) + }) + const cancelledWithCloseFailure = startClaudeCodeRun( + request(undefined, failedSpawnAbortWithCloseFailure.signal), + { ...unused.spec, spawn: () => cancelledFailedSpawnWithCloseFailure.handle }, + ) + await expect(cancelledWithCloseFailure).rejects.toMatchObject({ + message: 'subagent-claude-code: request was aborted before SDK startup; Claude Code process startup also failed: spawn /sdk/claude EACCES; query cleanup also failed', + errors: [ + expect.objectContaining({ message: 'subagent-claude-code: request was aborted before SDK startup' }), + spawnError, + cancelledFailedSpawnCloseError, + ], + }) + expect(cancelledFailedSpawnClose).toHaveBeenCalledOnce() + + const failedSpawnCloseError = new Error('query close failed') + const failedSpawnClose = vi.fn(() => { throw failedSpawnCloseError }) + const failedSpawnWithCloseFailure = fakeChild({ + pid: -1, + doneError: spawnError, + }) + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return queryFrom([], undefined, failedSpawnClose) + }) + const failedWithCloseFailure = startClaudeCodeRun(request(), { + ...unused.spec, + spawn: () => failedSpawnWithCloseFailure.handle, + }) + await expect(failedWithCloseFailure) + .rejects.toThrow('spawn /sdk/claude EACCES') + await expect(failedWithCloseFailure).rejects.toMatchObject({ + errors: [spawnError, failedSpawnCloseError], + }) + + const cleanupError = new Error('live child cleanup failed') + const constructionError = new Error( + 'query construction failed with a live child', + ) + const liveChildCleanupFailure = fakeChild({ doneError: cleanupError }) + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + throw constructionError + }) + await expect(startClaudeCodeRun(request(), { + ...unused.spec, + spawn: () => liveChildCleanupFailure.handle, + })).rejects.toMatchObject({ + errors: [constructionError, cleanupError], + }) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6123468bef..4869e0c86b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7169,6 +7169,12 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.4.3) + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 5852839961..8b98333ce2 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -87,6 +87,11 @@ export interface PackageManifest { devDependencies?: Record dependencies?: Record optionalDependencies?: Record + dsh?: { + bundle?: { + patch?: string + } + } } /** One workspace manifest and its repo-relative path. */ @@ -134,10 +139,6 @@ function workspaceManifests(): WorkspaceManifest[] { } const packageFileExtras: Readonly> = { - // Profile bundles publish their dsh.bundle.patch layer beside the lib. - '@deepseek-ai/dsh-base': ['cordis.patch.yml'], - '@deepseek-ai/dsh-web-app': ['cordis.patch.yml'], - '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], // Statically linked client libraries keep their stylesheets next to the emitted // JavaScript, which imports them by relative path: the compile shell runs // them through its own CSS pipeline, so the sheets are published artifacts. @@ -164,7 +165,12 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl } function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { - const extras = manifest.name ? packageFileExtras[manifest.name] ?? [] : [] + const declaredPatch = manifest.dsh?.bundle?.patch + const bundleFiles = declaredPatch === undefined ? [] : [declaredPatch.replace(/^\.\//, '')] + const extras = [ + ...bundleFiles, + ...(manifest.name ? packageFileExtras[manifest.name] ?? [] : []), + ] return [ 'lib/index.js', // Every package publishes its invariant ownership companion as a separate diff --git a/scripts/verify-config-source-ownership.spec.ts b/scripts/verify-config-source-ownership.spec.ts index 41026c5fe4..3a099156c2 100644 --- a/scripts/verify-config-source-ownership.spec.ts +++ b/scripts/verify-config-source-ownership.spec.ts @@ -14,7 +14,7 @@ describe('configuration source ownership gate', () => { it('rejects inline endpoints in shipped bundle patches', () => { const root = mkdtempSync(join(tmpdir(), 'dsh-config-source-ownership-')) roots.push(root) - const directory = join(root, 'packages/bundle/base') + const directory = join(root, 'packages/subagent/subagent-claude-code') mkdirSync(directory, { recursive: true }) writeFileSync( join(directory, 'cordis.patch.yml'), @@ -22,7 +22,7 @@ describe('configuration source ownership gate', () => { ) expect(collectConfigSourceOwnershipViolations(root)).toEqual([ - 'packages/bundle/base/cordis.patch.yml:2: inlines a credential or endpoint from the environment.' + 'packages/subagent/subagent-claude-code/cordis.patch.yml:2: inlines a credential or endpoint from the environment.' + ' The adapter resolves apiKeyEnv through ctx.credentials and the endpoint through the' + ' environment snapshot; inlining here bypasses both ladders.', ]) diff --git a/scripts/verify-config-source-ownership.ts b/scripts/verify-config-source-ownership.ts index 0684124215..c42e7cb793 100644 --- a/scripts/verify-config-source-ownership.ts +++ b/scripts/verify-config-source-ownership.ts @@ -14,7 +14,8 @@ const SHIPPED_CONFIG_GLOBS = [ 'apps/*/config/*.yml', 'examples/*/*.cordis.yml', 'examples/*/cordis.yml', - 'packages/bundle/*/cordis.patch.yml', + // Bundle identity comes from the package manifest, not the domain directory. + 'packages/*/*/cordis.patch.yml', // The Python runtime ships its own default composition inside the wheel. 'python/*/src/**/cordis.yml', ] diff --git a/scripts/verify-cordis-config.spec.ts b/scripts/verify-cordis-config.spec.ts index 6c1304e16a..f63031e46c 100644 --- a/scripts/verify-cordis-config.spec.ts +++ b/scripts/verify-cordis-config.spec.ts @@ -4,8 +4,15 @@ * metadata field must stay static, and a disabled expression must parse. */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { metadataExpressionErrors } from './verify-cordis-config.ts' +import { + bundleManifestPaths, + bundlePluginDependencyErrors, + metadataExpressionErrors, +} from './verify-cordis-config.ts' describe('verify-cordis-config metadata expressions', () => { it('accepts a disabled !!js expression', () => { @@ -37,3 +44,45 @@ describe('verify-cordis-config metadata expressions', () => { expect(problems.some(problem => problem.includes('[0].disabled: disabled expression does not parse'))).toBe(true) }) }) + +describe('workspace Bundle discovery and product dependency closures', () => { + it('discovers a Bundle outside packages/bundle from its manifest declaration', () => { + const fixture = mkdtempSync(join(tmpdir(), 'dsh-bundle-discovery-')) + try { + const bundleDir = join(fixture, 'packages/subagent/example') + const plainDir = join(fixture, 'packages/bundle/plain') + mkdirSync(bundleDir, { recursive: true }) + mkdirSync(plainDir, { recursive: true }) + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({ + name: '@deepseek-ai/dsh-subagent-example', + dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + writeFileSync(join(plainDir, 'package.json'), JSON.stringify({ + name: '@deepseek-ai/dsh-plain', + })) + + expect(bundleManifestPaths(fixture)).toEqual([ + 'packages/subagent/example/package.json', + ]) + } finally { + rmSync(fixture, { recursive: true, force: true }) + } + }) + + it('allows a Bundle to mount itself but rejects an undeclared plugin package', () => { + const manifestPath = 'packages/subagent/example/package.json' + const file = 'packages/subagent/example/cordis.patch.yml' + const manifest = { + name: '@deepseek-ai/dsh-subagent-example', + dependencies: {}, + } + const self = { file, name: '@deepseek-ai/dsh-subagent-example' } + expect(bundlePluginDependencyErrors(manifestPath, manifest, [self])).toEqual([]) + expect(bundlePluginDependencyErrors(manifestPath, manifest, [ + self, + { file, name: '@deepseek-ai/dsh-missing-plugin' }, + ])).toEqual([ + `${file}: @deepseek-ai/dsh-missing-plugin must be declared in ${manifestPath} dependencies`, + ]) + }) +}) diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index f2e0aef46c..ebfbbbc939 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -20,12 +20,14 @@ interface JsExpr { __jsExpr: string } -interface PackageManifest { +export interface PackageManifest { name?: string dependencies?: Record + optionalDependencies?: Record + dsh?: { bundle?: { patch?: string } } } -interface PluginReference { +export interface PluginReference { file: string name: string } @@ -260,11 +262,14 @@ function validateExampleResolution(): string[] { function validateAppResolution(): string[] { const violations: string[] = [] + const bundleManifests = bundleManifestPaths() // App overlays (and any config left under apps/cli/config) resolve from the // dsh app's own dependency surface — the profile module fallback mirrors it. const appDependencies = { ...readManifest('apps/cli/package.json').dependencies, - // The fallback also links every bundle's own dependencies (healProfilesModuleFallback). + // The fallback also links every in-box bundle's own dependencies + // (healProfilesModuleFallback). Optional Profile bundles stay outside the + // app installation until that Profile installs them. ...Object.fromEntries(globSync('packages/bundle/*/package.json', { cwd: root }) .flatMap(file => Object.entries(readManifest(file).dependencies ?? {}))), } @@ -274,20 +279,49 @@ function validateAppResolution(): string[] { violations.push(...missingPluginDependencies(appReferences, appDependencies, 'apps/cli/package.json or a bundle manifest')) // Each bundle's patch rows must resolve from that bundle's own dependencies: // per-layer resolution anchors on the bundle package directory. - for (const manifestPath of globSync('packages/bundle/*/package.json', { cwd: root })) { + for (const manifestPath of bundleManifests) { const bundleDir = manifestPath.replace(/\/package\.json$/, '') const manifest = readManifest(manifestPath) - const references = pluginReferences.filter(reference => reference.file.startsWith(`${bundleDir}/`)) - violations.push(...missingPluginDependencies( - // A bundle may mount its own package (the web-app runtime row). - references.filter(reference => packageNameFromSpecifier(reference.name) !== manifest.name), - manifest.dependencies ?? {}, - manifestPath, - )) + const patch = manifest.dsh?.bundle?.patch + if (typeof patch !== 'string') continue + const patchFile = relative(root, resolve(root, bundleDir, patch)).replaceAll('\\', '/') + const references = pluginReferences.filter(reference => reference.file === patchFile) + violations.push(...bundlePluginDependencyErrors(manifestPath, manifest, references)) } return violations } +/** + * Discover workspace Bundle packages from their manifest declaration. + * @param repoRoot Repository root to scan. + * @returns Sorted repository-relative package manifest paths. + */ +export function bundleManifestPaths(repoRoot: string = root): string[] { + return globSync('packages/*/*/package.json', { cwd: repoRoot }) + .filter(path => typeof readManifest(path, repoRoot).dsh?.bundle?.patch === 'string') + .sort() +} + +/** + * Validate plugin packages referenced by one Bundle patch. + * @param manifestPath Repository-relative Bundle manifest path. + * @param manifest Parsed Bundle manifest. + * @param references Plugin rows read from the Bundle package directory. + * @returns Missing production dependency diagnostics. + */ +export function bundlePluginDependencyErrors( + manifestPath: string, + manifest: PackageManifest, + references: readonly PluginReference[], +): string[] { + return missingPluginDependencies( + // A Bundle may mount its own package (for example, its provider or runtime row). + references.filter(reference => packageNameFromSpecifier(reference.name) !== manifest.name), + manifest.dependencies ?? {}, + manifestPath, + ) +} + /** * Every configured specifier of a local workspace package must resolve through * the tsconfig `paths` facade to a `.ts`/`.tsx` source file. The `dsh` source @@ -363,8 +397,8 @@ function missingPluginDependencies( : `${[...locations].join(', ')}: ${packageName} must be declared in ${manifestPath} dependencies`) } -function readManifest(path: string): PackageManifest { - return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest +function readManifest(path: string, repoRoot: string = root): PackageManifest { + return JSON.parse(readFileSync(resolve(repoRoot, path), 'utf8')) as PackageManifest } function localPackageDirectories(): Map {