Kaynağa Gözat

Merge remote-tracking branch 'origin/master' into feat/code-runtime-python-protocol

Chinesezjc 1 ay önce
ebeveyn
işleme
af91c4d7ed
71 değiştirilmiş dosya ile 907 ekleme ve 376 silme
  1. 6 0
      .agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml
  2. 25 0
      .agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.md
  3. 25 0
      .agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.zh.md
  4. 2 2
      .agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml
  5. 3 1
      .agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md
  6. 3 1
      .agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml
  8. 1 1
      .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md
  9. 1 1
      .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml
  11. 1 1
      .agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md
  12. 1 1
      .agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md
  13. 2 2
      .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml
  14. 7 8
      .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md
  15. 7 8
      .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md
  16. 1 1
      AGENTS.md
  17. 6 0
      apps/cli/composition.md
  18. 8 3
      apps/cli/config/agent-presets/code/agent.cordis.yml
  19. 8 3
      apps/cli/config/agent-presets/cordis/agent.cordis.yml
  20. 2 2
      apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md
  21. 8 3
      apps/cli/config/agent-presets/standard/agent.cordis.yml
  22. 0 7
      apps/cli/src/dump-config.ts
  23. 12 20
      apps/cli/src/profile-boot.ts
  24. 0 52
      apps/cli/src/windows-shell.ts
  25. 67 1
      apps/cli/tests/web-agent-presets.e2e.ts
  26. 98 100
      apps/cli/tests/windows-shell.spec.ts
  27. 8 2
      apps/web/tests/scaffold.ts
  28. 7 0
      apps/web/tests/startup-auto-selection.e2e.ts
  29. 8 1
      apps/web/tests/subagent-conversation.e2e.ts
  30. 2 2
      docs/config-catalog.i18n.yaml
  31. 5 0
      docs/config-catalog.md
  32. 5 0
      docs/config-catalog.zh.md
  33. 2 2
      docs/cordis-primer.i18n.yaml
  34. 1 1
      docs/cordis-primer.md
  35. 1 1
      docs/cordis-primer.zh.md
  36. 2 2
      docs/cordis-tutorial/05-config.i18n.yaml
  37. 1 1
      docs/cordis-tutorial/05-config.md
  38. 1 1
      docs/cordis-tutorial/05-config.zh.md
  39. 2 2
      docs/subsystems/core.i18n.yaml
  40. 1 1
      docs/subsystems/core.md
  41. 1 1
      docs/subsystems/core.zh.md
  42. 68 0
      packages/boot/app-boot/tests/user-patches.spec.ts
  43. 2 2
      packages/bundle/base/README.i18n.yaml
  44. 1 1
      packages/bundle/base/README.md
  45. 1 1
      packages/bundle/base/README.zh.md
  46. 10 0
      packages/bundle/base/cordis.patch.yml
  47. 0 2
      packages/bundle/base/package.json
  48. 31 27
      packages/bundle/base/tests/base.spec.ts
  49. 0 31
      packages/bundle/base/windows.cordis.patch.yml
  50. 12 6
      packages/bundle/web-app/cordis.patch.yml
  51. 3 1
      packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx
  52. 31 3
      packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css
  53. 2 2
      packages/preset/agent-presets/README.i18n.yaml
  54. 14 1
      packages/preset/agent-presets/README.md
  55. 14 1
      packages/preset/agent-presets/README.zh.md
  56. 15 0
      packages/preset/agent-presets/src/discovery.ts
  57. 35 8
      packages/preset/agent-presets/src/index.ts
  58. 1 1
      packages/preset/agent-presets/src/invariant.ts
  59. 5 0
      packages/preset/agent-presets/src/preset.ts
  60. 7 0
      packages/preset/agent-presets/tests/authoring.spec.ts
  61. 25 3
      packages/preset/agent-presets/tests/invariant.spec.ts
  62. 12 10
      packages/preset/agent-presets/tests/mount.spec.ts
  63. 1 1
      packages/preset/agent-presets/tests/settings.spec.ts
  64. 131 0
      packages/preset/agent-presets/tests/user-root.spec.ts
  65. 1 1
      packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts
  66. 2 3
      scripts/check-workspace-constraints.ts
  67. 2 0
      scripts/rescope-vendor.ts
  68. 39 0
      scripts/verify-cordis-config.spec.ts
  69. 83 29
      scripts/verify-cordis-config.ts
  70. 1 0
      vendor/README.md
  71. 13 3
      vendor/loader/src/config/entry.ts

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.md
+2026-08-11-loader-entry-disabled-interpolation.md: fd760ea0f15f19e5f287aaddc36fb8eeb5f519ba
+2026-08-11-loader-entry-disabled-interpolation.zh.md: 15f5a80931c58555dceab359d05e8515334513b2

+ 25 - 0
.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.md

@@ -0,0 +1,25 @@
+# Agent Note: Loader interpolates the entry `disabled` field
+
+Status: implemented
+
+English | [中文](2026-08-11-loader-entry-disabled-interpolation.zh.md)
+
+## Problem
+
+The Windows platform layer (then a separate `windows.cordis.patch.yml` beside the base patch, since folded into the base rows — see Decision) disabled `tool-bash` on win32, but the shipped presets each mount a `tool-bash` row. Preset rows compose last, so the same-id row re-enabled the tool on Windows — the session had both `tool-bash` (PowerShell-backed) and `tool-pwsh`, silently, because no spec pinned the composed preset layer. Entry metadata had no conditional mechanism: `!!js` interpolates only under plugin `config`, and [postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) documents that `disabled: !!js ...` stays a truthy expression object, disabling the row everywhere.
+
+## Decision
+
+The Loader interpolates the entry `disabled` field (`vendor/loader/src/config/entry.ts`): a `!!js` expression evaluates against the loader context at every mount decision. `disabled` is the only interpolated metadata field; `id`, `name`, `group`, and `inject` stay static. The raw node stays in the options, so write-back keeps the `!!js` form. The shipped presets (standard, code, cordis) declare the shell tool rows themselves and gate them by platform — `tool-bash` with `disabled: !!js process.platform === 'win32'` and its `tool-pwsh` twin with the inverted expression — so the preset layer exposes exactly one shell tool per host; the web-app overlay disables the host rows of both tools, letting each session's preset decide. `verify-cordis-config` now allows expressions in `disabled` only.
+
+The mechanism completes the platform-layer fold: the base bundle's `cordis.patch.yml` gates both shell stacks on its own rows — `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'`, and their twins `pwsh-sandbox`/`tool-pwsh` mount only on win32 with the inverted expression. The launcher's separate Windows platform layer (`windows.cordis.patch.yml` plus `apps/cli/src/windows-shell.ts` and its injection into boot, live recomposition, and config dumps) is deleted — the layer existed only because entry metadata was static, and with `disabled` interpolated the condition lives on the row it governs.
+
+## Alternatives considered
+
+**A declarative `platform` field on the row.** Static and gate-checkable, but a second composition mechanism beside `!!js`, and platform is only today's condition.
+
+**Preset-level platform overlays.** Rejected: the condition belongs on the row it governs — the same principle folds the launcher's separate Windows platform layer into the base rows.
+
+## Consequences
+
+A row can gate itself on platform or environment; a bad expression fails loud at boot. Every other metadata field remains literal and the gate keeps rejecting expressions there — the postmortem-0002 hazard is closed for `disabled` by evaluation, not prohibition. The Windows shell swap moved from a launcher-injected patch layer to the base bundle's own rows: win32 mounts the confined pwsh stack, POSIX carries the pwsh rows disabled, and one shared patch file serves both rosters — the [Windows pwsh default](../feature/2026-08-01-windows-pwsh-default.md) note's layer mechanism is superseded. The shell TOOL rows follow the same one-plane rule as every other preset-declared row: the web-app overlay disables the host `tool-bash`/`tool-pwsh` rows and the presets declare both with inverted platform gates, so a preset can drop or replace the shell tool per session on either host. The `minimal` preset's missing win32 PTY stack is a preset-metadata follow-up.

+ 25 - 0
.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.zh.md

@@ -0,0 +1,25 @@
+# Agent Note:Loader 插值条目 `disabled` 字段
+
+Status: implemented
+
+[English](2026-08-11-loader-entry-disabled-interpolation.md) | 中文
+
+## 问题
+
+Windows 平台层(当时是 base patch 旁独立的 `windows.cordis.patch.yml`,现已折入 base 行——见「决策」)在 win32 上禁用 `tool-bash`,但 shipped 预设各自挂载了一行 `tool-bash`。预设行最后组合,同名行在 Windows 上重新启用了该工具——会话同时拥有 `tool-bash`(PowerShell 后端)与 `tool-pwsh`,且是静默的,因为没有 spec pin 组合后的预设层。条目元数据没有条件机制:`!!js` 只在插件 `config` 下插值,[postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) 记录了 `disabled: !!js ...` 保持真值表达式对象、在所有平台上禁用该行的事故。
+
+## 决策
+
+Loader 插值条目 `disabled` 字段(`vendor/loader/src/config/entry.ts`):`!!js` 表达式在每次挂载决策时基于 loader 上下文求值。`disabled` 是唯一被插值的元数据字段;`id`、`name`、`group`、`inject` 保持静态。原始节点保留在 options 中,写回保持 `!!js` 形式。shipped 预设(standard、code、cordis)自己声明 shell 工具行并按平台门控——`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`,其孪生行 `tool-pwsh` 以取反的表达式——因此预设层每台宿主恰好暴露一个 shell 工具;web-app overlay 禁用两个工具的 host 行,由每个会话的预设决定。`verify-cordis-config` 现在只允许 `disabled` 中的表达式。
+
+该机制补全了平台层折叠:base bundle 的 `cordis.patch.yml` 在自身行上按平台门控两个 shell 栈——`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`,它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载。启动器的独立 Windows 平台层(`windows.cordis.patch.yml` 以及 `apps/cli/src/windows-shell.ts` 及其注入到 boot、live 重组合、config dump 的逻辑)被删除——该层只因条目元数据是静态的而存在,`disabled` 可插值后条件就落在它所治理的行上。
+
+## 备选方案
+
+**行上的声明式 `platform` 字段。** 静态且可被门禁检查,但它是 `!!js` 之外的第二种组合机制,且平台只是今天的条件。
+
+**预设级平台 overlay。** 被否:条件应当属于它所治理的行——同一原则把启动器独立的 Windows 平台层折入 base 行。
+
+## 后果
+
+行可以按平台或环境门控自身;错误的表达式在启动时响亮失败。其余元数据字段保持字面值,门禁继续拒绝那里的表达式——`disabled` 上的 postmortem-0002 隐患以「求值」而非「禁止」关闭。Windows shell 栈的切换从启动器注入的 patch 层移到 base bundle 自身的行上:win32 挂载受限 pwsh 栈,POSIX 携带被禁用的 pwsh 行,同一份 patch 文件服务两种阵容——[Windows 默认 pwsh](../feature/2026-08-01-windows-pwsh-default.md) note 的层机制已被取代。shell 工具行遵循与其他预设声明行相同的 one-plane 规则:web-app overlay 禁用 host 面的 `tool-bash`/`tool-pwsh` 行,预设以互逆的平台门控声明两者,因此任一宿主的每个会话都可以按预设丢弃或替换 shell 工具。`minimal` 预设缺失的 win32 PTY 栈是预设元数据的后续工作。

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.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/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md
-2026-08-11-preset-authoring-agent-validates-its-own-composition.md: 6b9cdf32b70e3ab4adc9f3b0e20bb3d2245486c7
-2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: e6e8dabcd886a6331d294744b667552caa01e7b4
+2026-08-11-preset-authoring-agent-validates-its-own-composition.md: eb21094f0d859a31d5f16d780cada6818a508b36
+2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: 02c245348a9c7e9968472044d7ff95e1ff21120c

+ 3 - 1
.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md

@@ -32,7 +32,9 @@ The agent reaches the roster service the way `cordis_mount` documents: a tempora
 
 "Whether a row publishes a service" resolves through `cordis_inspect what:"services"`, which names the owning fiber of every live service.
 
-The guidance keeps `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the answer to "where do my presets live" — it is where every `dsh` launcher puts them — while routing the path an agent actually reads or edits through `list()` or `resolve()`. `Config.roots` defaults to `[]` and `apps/cli` patches both roots in, `writableRoot()` takes the first `user` one, and no call reports either path; `authorable` answers only whether a writable root exists, and `list()` cannot reveal a user root that holds nothing yet. Stating the path is therefore right for talking to a person and wrong for feeding a file tool.
+The guidance keeps `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the answer to "where do my presets live" while routing the path an agent actually reads or edits through `list()` or `resolve()`. Stating the path is right for talking to a person and wrong for feeding a file tool: a deployment may configure other roots, and `list()` cannot reveal a user root that holds nothing yet.
+
+That path is now a property of the package rather than of one launcher. `AgentPresets` derives `<dshHome>/.agent-presets` as a `user` root unless `includeUserRoot` is false, the way [`dsh-skill-local`](../../../../packages/skill/skill-local/README.md) derives `<dshHome>/skills`, and `apps/cli` supplies only the SHIPPED root — the one path an installed app alone can resolve. The asymmetry it replaces cost a bug: with both roots patched in by one launcher, `dsh run` booted a roster with no roots at all and failed resolving `standard` (fixed then by teaching every launcher the patch). The derived root is appended after every configured root, so a shipped id still shadows a home directory claiming it, and `writableRoot()` still prefers an explicitly configured `user` root. It is resolved once at construction: a root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw.
 
 The prohibition on touching the shipped install is promoted from a paragraph inside the authoring steps to a top `## Off-limits` section, extended to cover editing the host composition as a workaround. The new self-validation calls do not weaken it: `copy()` refuses an id any root supplies, and `remove()` refuses a preset that ships with the deployment.
 

+ 3 - 1
.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md

@@ -32,7 +32,9 @@ agent 按 `cordis_mount` 自身文档所述的方式够到 roster 服务:挂
 
 「某行是否发布服务」改由 `cordis_inspect what:"services"` 回答,它会给出每个存活服务的持有 fiber。
 
-指导保留 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为「我的 preset 在哪」的答案——每个 `dsh` 启动器都把它们放在那里——同时把 agent 实际读取或编辑的路径改走 `list()` 或 `resolve()`。`Config.roots` 默认为 `[]`,两个根均由 `apps/cli` 补入,`writableRoot()` 取其中第一个 `user` 根,且没有任何调用会报告任一路径;`authorable` 只回答是否存在可写根,而 `list()` 无法揭示一个尚且为空的用户根。因此写出该路径对人讲是对的,喂给文件工具是错的。
+指导保留 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为「我的 preset 在哪」的答案,同时把 agent 实际读取或编辑的路径改走 `list()` 或 `resolve()`。写出该路径对人讲是对的,喂给文件工具是错的:部署可以配置其他根目录,而 `list()` 无法揭示一个尚且为空的用户根。
+
+该路径如今是本包的属性,而非某个启动器的属性。除非 `includeUserRoot` 为 false,`AgentPresets` 自行推导 `<dshHome>/.agent-presets` 作为 `user` 根,正如 [`dsh-skill-local`](../../../../packages/skill/skill-local/README.md) 推导 `<dshHome>/skills`;`apps/cli` 只提供**随附**根——那是唯有已安装 app 才能解析的路径。它取代的那种不对称曾付出过代价:两个根都由单一启动器补入时,`dsh run` 启动的 roster 一个根都没有,解析 `standard` 直接失败(当时的修法是让每个启动器都执行该 patch)。推导出的根追加在全部已配置根之后,因此随附 id 仍会遮蔽占用它的家目录目录,而 `writableRoot()` 仍优先选择显式配置的 `user` 根。它在构造时解析一次:若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。
 
 禁止改动随发布安装的约束,从创作步骤中的一段提升为顶部的 `## Off-limits` 一节,并扩展到禁止改宿主组装绕行。新增的自校验调用不削弱它:`copy()` 拒绝任何根已提供的 id,`remove()` 拒绝随部署发布的 preset。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.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-07-20-dsh-cli-personal-config.md
-2026-07-20-dsh-cli-personal-config.md: ed04725e92848bbab550a27ef2f4c021536f765e
-2026-07-20-dsh-cli-personal-config.zh.md: cc97987f803f7fb513e94ce0ce079558f5e3dc75
+2026-07-20-dsh-cli-personal-config.md: bc2aff322de01bb9c6beebb1679b2ff9909d1fe3
+2026-07-20-dsh-cli-personal-config.zh.md: 507a7188a4a77d904e3204499290f3ed22abab2c

+ 1 - 1
.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md

@@ -6,7 +6,7 @@ English | [中文](2026-07-20-dsh-cli-personal-config.zh.md)
 
 ## Problem
 
-A developer's own preferences — which provider and model the TUI uses, personal credentials, a private adapter route — had nowhere to live except edits to committed files. Pointing the TUI demo at a personal Anthropic-proxy Opus route meant patching `examples/tui-agent/cordis.yml` and `.env` in the working tree, which risks committing secrets and repeats per checkout. There was also no installable command: running the agent in an arbitrary project directory required invoking the repo's demo script from the repo root. Loader metadata is static, so "conditional composition uses overlays" (AGENTS.md) — but overlays only existed as committed sibling files, not as a machine-level layer.
+A developer's own preferences — which provider and model the TUI uses, personal credentials, a private adapter route — had nowhere to live except edits to committed files. Pointing the TUI demo at a personal Anthropic-proxy Opus route meant patching `examples/tui-agent/cordis.yml` and `.env` in the working tree, which risks committing secrets and repeats per checkout. There was also no installable command: running the agent in an arbitrary project directory required invoking the repo's demo script from the repo root. Loader metadata is static except the entry `disabled` field (see the [loader `disabled` interpolation decision](../architecture/2026-08-11-loader-entry-disabled-interpolation.md)), so "conditional composition uses overlays" (AGENTS.md) — but overlays only existed as committed sibling files, not as a machine-level layer.
 
 ## Decision
 

+ 1 - 1
.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md

@@ -6,7 +6,7 @@ Status: implemented
 
 ## Problem
 
-开发者自己的偏好——TUI 使用哪个提供方和模型、个人凭证、私有的适配器路由——除了改动已提交的文件之外无处安放。要把 TUI 示例指向个人的 Anthropic 代理 Opus 路由,只能在工作区里改 `examples/tui-agent/cordis.yml` 和 `.env`,既有提交密钥的风险,又要在每个 checkout 里重复一遍。也没有可安装的命令:想在任意项目目录里运行这个 agent,必须回到仓库根目录调用示例脚本。Loader 元数据是静态的,所以「条件组合使用 overlay」(AGENTS.md)——但 overlay 此前只以已提交的同级文件形式存在,没有机器级的层。
+开发者自己的偏好——TUI 使用哪个提供方和模型、个人凭证、私有的适配器路由——除了改动已提交的文件之外无处安放。要把 TUI 示例指向个人的 Anthropic 代理 Opus 路由,只能在工作区里改 `examples/tui-agent/cordis.yml` 和 `.env`,既有提交密钥的风险,又要在每个 checkout 里重复一遍。也没有可安装的命令:想在任意项目目录里运行这个 agent,必须回到仓库根目录调用示例脚本。Loader 元数据是静态的——条目 `disabled` 字段除外(见 [loader `disabled` 插值决策](../architecture/2026-08-11-loader-entry-disabled-interpolation.md))——所以「条件组合使用 overlay」(AGENTS.md);但 overlay 此前只以已提交的同级文件形式存在,没有机器级的层。
 
 ## Decision
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.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-07-26-code-dispatch-ui-foundation.md
-2026-07-26-code-dispatch-ui-foundation.md: 4115a1898de7d2cce01346c3f005fcd19c325f4c
-2026-07-26-code-dispatch-ui-foundation.zh.md: aeb57b93d781163dd0a4747ac03053c65deda1db
+2026-07-26-code-dispatch-ui-foundation.md: 94316e774f231a2f2d5e9bcc8d1a30fd4a2ec733
+2026-07-26-code-dispatch-ui-foundation.zh.md: 2c9ee5b93e20b5c09950e2896a888f90863b8bd0

+ 1 - 1
.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md

@@ -16,7 +16,7 @@ Three changes, one per obstacle:
 
 1. **`run_code` gains a required `description` parameter** (bash's exact contract: active voice, 5-10 words, shown in the UI; whitespace-only rejected at execute). `presentCall` now titles the card with the description and moves the program to `rawInput`. The prompt-side cost is a few tokens per call; the return is that every surface — TUI card, ACP title, web row — gets a human-readable label without parsing TypeScript.
 2. **`tool/code-dispatch` logs the sub-call's complete model-facing outcome** — `content: ContentBlock[]` + `isError`, the `tool/result` vocabulary — replacing `resultSummary` and deleting the summarize/cwd-normalization machinery outright. A UI renders a sub-call through the identical code path as a native result, including error text and non-text blocks. The event stays log-only (`deriveMessages()` ignores it): nothing about model context changes.
-3. **`DSH_TOOLS_MODE` env var on the `dsh` config tree** (`native`|`code`|`both`; unset keeps the schema default): the `tools` row reads it via `!!js`, and the worker code runtime is mounted unconditionally (Loader metadata is static, so no conditional row exists; a native boot only registers the service — workers spawn per run). This is an explicitly temporary configuration hook: per-session tool-mode selection owned by the web UI is the design goal, and the env var dies when that lands.
+3. **`DSH_TOOLS_MODE` env var on the `dsh` config tree** (`native`|`code`|`both`; unset keeps the schema default): the `tools` row reads it via `!!js`, and the worker code runtime is mounted unconditionally (Loader metadata was static when this shipped — no conditional row existed; the later [`disabled` interpolation decision](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) makes one possible but changes nothing here — a native boot only registers the service, workers spawn per run). This is an explicitly temporary configuration hook: per-session tool-mode selection owned by the web UI is the design goal, and the env var dies when that lands.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md

@@ -16,7 +16,7 @@ Status: implemented
 
 1. **`run_code` 新增必填的 `description` 参数**(与 bash 完全相同的约定:主动语态、5-10 个词、展示在 UI 中;仅含空白的取值在执行时被拒绝)。`presentCall` 现在以该 description 作为卡片标题,并把程序文本移入 `rawInput`。提示词侧的成本是每次调用多出几个 token;换来的是每个表面——TUI 卡片、ACP(Agent Client Protocol)标题、Web 行——都无需解析 TypeScript 就能获得可供人阅读的标签。
 2. **`tool/code-dispatch` 记录子调用面向模型的完整结果**(`content: ContentBlock[]` 加 `isError`,即 `tool/result` 的词汇),取代 `resultSummary`,并把摘要与 cwd 归一化机制彻底删除。UI 渲染子调用走的代码路径与渲染原生结果完全相同,包括错误文本和非文本块。该事件保持仅日志(`deriveMessages()` 忽略它):模型上下文没有任何变化。
-3. **`dsh` 配置树上的 `DSH_TOOLS_MODE` 环境变量**(`native`|`code`|`both`;未设置时保持 schema 默认值):`tools` 行通过 `!!js` 读取它,worker 代码运行时则无条件挂载(loader 元数据是静态的,因此不存在条件行;native 启动只是注册该服务,worker 要到每次运行时才 spawn)。这是一个明确标注为临时的配置钩子:设计目标是让 Web UI 拥有按会话的工具模式选择,该目标落地后,这个环境变量随即退役。
+3. **`dsh` 配置树上的 `DSH_TOOLS_MODE` 环境变量**(`native`|`code`|`both`;未设置时保持 schema 默认值):`tools` 行通过 `!!js` 读取它,worker 代码运行时则无条件挂载(本项交付时 loader 元数据仍是静态的,因此不存在条件行;后来的 [`disabled` 插值决策](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) 让条件行成为可能,但此处不变——native 启动只是注册该服务,worker 要到每次运行时才 spawn)。这是一个明确标注为临时的配置钩子:设计目标是让 Web UI 拥有按会话的工具模式选择,该目标落地后,这个环境变量随即退役。
 
 ## 曾考虑的替代方案
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.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-01-windows-pwsh-default.md
-2026-08-01-windows-pwsh-default.md: 4e681b32088954d870df86898e26fe2cae669f14
-2026-08-01-windows-pwsh-default.zh.md: a9d600f8a8e47db49c3733f33091e667e341c6a7
+2026-08-01-windows-pwsh-default.md: c66e289c24d6024b1df53cd60f25c27d46fafc5a
+2026-08-01-windows-pwsh-default.zh.md: b2ad45ec96d546d01436a383ed7d8884b978aa31

+ 7 - 8
.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md

@@ -12,9 +12,8 @@ The harness's shipped execution profile is bash-first on every platform. Windows
 
 Windows hosts booting a shipped profile (`dsh web`, `dsh --profile headless`, one-shot tasks) get the PowerShell stack by default; POSIX hosts are unchanged.
 
-- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`. It disables the POSIX-only `bash-sandbox`/`tool-bash` rows and inserts `pwsh-sandbox`/`tool-pwsh`. The later [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) filled the win32 runner chain and superseded this note's original unconfined roster: `sandbox`, `sandbox-policy`, `fs-sandbox`, `permission`/`ui-permission`, and `approval` now stay enabled exactly as on POSIX, while the ACL backend truthfully reports its Everyone and hard-link gaps as partial enforcement.
-- **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack re-enables the bash rows and disables both pwsh rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud.
-- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` declare `dsh-pwsh-sandbox`/`dsh-tool-pwsh`; the executor's dependency chain supplies `dsh-pwsh-local`, and the base bundle lists every row plugin as a dependency by house style.
+- **The base patch gates both shell stacks on its own rows** (the [loader `disabled` interpolation](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) note records the mechanism and the platform-layer fold): `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'` (bash has no Windows runner), and their twins `pwsh-sandbox`/`tool-pwsh` mount only on win32 with the inverted expression — one shared patch file, exactly one shell stack per host. The confined pwsh stack runs over the ACL restricted-token runner, and the permission surface stays exactly as on POSIX (the [Windows ACL restricted-token sandbox](2026-08-08-windows-acl-restricted-token-sandbox.md) note owns that roster). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack or an unconfined pwsh executor 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) — composition config is the one override channel. The separate `windows.cordis.patch.yml` layer and the launcher's `apps/cli/src/windows-shell.ts` injection are deleted; the layer existed only because entry metadata was static.
+- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the pwsh rows. `apps/cli` and `dsh-base` declare `dsh-pwsh-sandbox`/`dsh-tool-pwsh`, and the executor's dependency chain supplies `dsh-pwsh-local`; the base bundle lists every row plugin as a dependency by house style.
 
 The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches bash decision](2026-08-05-pwsh-ui-bash-parity.md); the [pwsh tool bash parity decision](2026-08-02-pwsh-tool-bash-parity.md) ships the tool's surface. Nothing in this decision changes POSIX behavior.
 
@@ -32,13 +31,13 @@ The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches ba
 
 ## Consequences
 
-- A Windows host running a shipped `dsh` surface gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled).
+- A Windows host running a shipped `dsh` surface gets the confined `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there. On the Web surface the shell TOOL rows come from the session's preset (the [loader `disabled` interpolation](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) note owns the one-plane mechanism): each shipped preset declares `tool-pwsh` gated by `process.platform !== 'win32'` and its `tool-bash` twin by the inverted expression, so the preset layer exposes exactly one shell tool per host.
 - Windows commands and fs operations share the sandbox policy, permission switcher, and approval service. The ACL runner confines writes but reports `enforcement: 'partial'`; explicit `danger-full-access` remains the approved bypass rather than the platform default.
-- POSIX hosts are unchanged: the platform layer never applies, and the bash stack remains the universal `cordis.patch.yml` rows.
-- Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — disabling `pwsh-sandbox`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel.
+- POSIX hosts mount the bash stack as before; the pwsh rows sit disabled in their composition, because the one shared patch file lists both stacks and each row gates itself.
+- A Windows host that prefers the bash stack (e.g. with WSL/Git-Bash on PATH) overrides the shipped rows through its profile or home `cordis.patch.yml` — disabling `pwsh-sandbox`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel.
 
 ## Verification
 
-- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, custom-profile skip, missing-patch failure, cold-start dependency closure, and real composed roster; `packages/bundle/base/tests/base.spec.ts` pins that the Windows layer disables only the bash rows, inserts the confined pwsh rows, and leaves sandbox, permission, fs, and approval ownership untouched.
-- Keyless: a win32 `dsh --profile <name> --dump-config` shows the pwsh rows with `windows.cordis.patch.yml` provenance and the bash rows disabled; the POSIX dump (CI Linux) is unchanged.
+- Unit: `apps/cli/tests/windows-shell.spec.ts` composes the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the app installation) through the boot's patch algorithm and pins the effective per-platform roster — the win32 pwsh roster, the POSIX bash roster, and the base-only profile — plus the preset-level shell-tool gates (`tool-bash`/`tool-pwsh`) and the cold-start resolution closure; `packages/bundle/base/tests/base.spec.ts` pins the four shell rows' symmetric `!!js` platform gates and that no separate platform patch ships.
+- Keyless: a `dsh --profile <name> --dump-config` shows both stacks in the one shared patch layer, with each row's own `disabled` expression deciding the roster at mount.
 - The real-composition smoke boots the web profile on win32 with the pwsh stack mounted (the exact roster this note describes).

+ 7 - 8
.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md

@@ -12,9 +12,8 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机
 
 启动交付 profile(`dsh web`、`dsh --profile headless`、一次性任务)的 Windows 主机默认获得 PowerShell 栈;POSIX 主机不变。
 
-- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml)。它禁用仅限 POSIX 的 `bash-sandbox`/`tool-bash` 行,并插入 `pwsh-sandbox`/`tool-pwsh`。后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.md)填充了 win32 runner 链,并取代了本笔记最初的不限权清单:`sandbox`、`sandbox-policy`、`fs-sandbox`、`permission`/`ui-permission` 与 `approval` 均与 POSIX 上一样保持启用,而 ACL 后端则如实把 Everyone 与硬链接缺口报告为部分强制执行。
-- **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行,并禁用两个 pwsh 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。
-- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 声明 `dsh-pwsh-sandbox`/`dsh-tool-pwsh`;执行器的依赖链提供 `dsh-pwsh-local`,按仓库惯例,base bundle 把每个行插件都列为依赖。
+- **base patch 在自身行上按平台门控两个 shell 栈**([loader `disabled` 插值](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) note 记录了该机制与平台层折叠):`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`(bash 没有 Windows runner),它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载——同一份 patch 文件,每个宿主恰好挂载一个 shell 栈。受限 pwsh 栈运行在 ACL 受限令牌 runner 之上,权限面与 POSIX 完全一致([Windows ACL 受限令牌沙箱](2026-08-08-windows-acl-restricted-token-sandbox.md) note 拥有该清单)。覆盖交付默认是组合决策:偏好 bash 栈或不限权 pwsh 执行器的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。独立的 `windows.cordis.patch.yml` 层与启动器的 `apps/cli/src/windows-shell.ts` 注入已删除;该层只因条目元数据是静态的而存在。
+- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到 pwsh 行。`apps/cli` 与 `dsh-base` 声明 `dsh-pwsh-sandbox`/`dsh-tool-pwsh`,执行器的依赖链提供 `dsh-pwsh-local`;按仓库惯例,base bundle 把每个行插件都列为依赖。
 
 pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-bash-parity.md) 先行交付;[pwsh 工具与 bash 对齐决策](2026-08-02-pwsh-tool-bash-parity.md) 交付了工具表面。本决策不改变任何 POSIX 行为。
 
@@ -32,13 +31,13 @@ pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-b
 
 ## 后果
 
-- 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。
+- 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得受限 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`。在 Web 表面,shell 工具行来自会话的预设([loader `disabled` 插值](../architecture/2026-08-11-loader-entry-disabled-interpolation.md) note 拥有 one-plane 机制):每个 shipped 预设声明 `tool-pwsh`(以 `process.platform !== 'win32'` 门控)及其孪生行 `tool-bash`(取反表达式),因此预设层每台宿主恰好暴露一个 shell 工具。
 - Windows 命令与 fs 操作共用沙箱策略、权限切换器和 approval 服务。ACL runner 限制写入,但报告 `enforcement: 'partial'`;显式的 `danger-full-access` 仍是获准的绕过方式,而非平台默认。
-- POSIX 主机不变:平台层永不生效,bash 栈仍是通用 `cordis.patch.yml` 的行。
-- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。
+- POSIX 主机如常挂载 bash 栈;pwsh 行以其自身的门控表达式处于禁用状态——同一份共享 patch 文件列出两个栈,每个行自己决定挂载。
+- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付行——禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。
 
 ## 验证
 
-- 单元:`apps/cli/tests/windows-shell.spec.ts` 固定 win32 默认、自定义 profile 跳过、缺少 patch 时失败、冷启动依赖闭包和真实组合清单;`packages/bundle/base/tests/base.spec.ts` 固定 Windows 层仅禁用 bash 行、插入受限的 pwsh 行,并且不改变沙箱、权限、fs 与审批的归属。
-- Keyless:win32 上的 `dsh --profile <name> --dump-config` 显示带 `windows.cordis.patch.yml` 出处的 pwsh 行、被禁用的 bash 行;POSIX 转储(CI Linux)不变。
+- 单元:`apps/cli/tests/windows-shell.spec.ts` 通过启动所用的 patch 算法组合真实交付的 bundle 层(从应用安装解析的 dsh-base + dsh-web-app),固定每个平台的有效清单——win32 pwsh 清单、POSIX bash 清单与 base-only profile——外加预设级 shell 工具门控(`tool-bash`/`tool-pwsh`)与冷启动解析闭包;`packages/bundle/base/tests/base.spec.ts` 固定四个 shell 行的对称 `!!js` 平台门控,并断言不再交付独立的平台 patch。
+- Keyless:`dsh --profile <name> --dump-config` 在同一份共享 patch 层中显示两个栈,每个行以自己的 `disabled` 表达式在挂载时决定清单。
 - 真实组合冒烟在 win32 上启动 web profile,pwsh 栈挂载成功(即本笔记描述的确切清单)。

+ 1 - 1
AGENTS.md

@@ -92,7 +92,7 @@ Run checks before pushes via [dsh-pre-push-checks](.agents/skills/dsh-pre-push-c
 
 ## Secrets / .env
 
-Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and root `.env`. cordis.yml allows `!!js` (never `!js`) only under plugin `config`; Loader metadata is static, so conditional composition uses overlays ([primer](docs/cordis-primer.md#loader-configuration)). Never commit credentials. CI e2e skips without a key; [testing.md](docs/testing.md) owns key policy.
+Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and root `.env`. cordis.yml allows `!!js` (never `!js`) under plugin `config` and entry `disabled`; other metadata stays literal, so conditional composition also uses overlays ([primer](docs/cordis-primer.md#loader-configuration)). Never commit credentials. CI e2e skips without a key; [testing.md](docs/testing.md) owns key policy.
 
 ## Conventions
 

+ 6 - 0
apps/cli/composition.md

@@ -60,6 +60,8 @@ flowchart LR
   cfg --> plugin_dsh_base_sandbox_policy
   plugin_dsh_base_bash_sandbox["bash-sandbox<br/>@deepseek-ai/dsh-bash-sandbox"]
   cfg --> plugin_dsh_base_bash_sandbox
+  plugin_dsh_base_pwsh_sandbox["pwsh-sandbox<br/>@deepseek-ai/dsh-pwsh-sandbox"]
+  cfg --> plugin_dsh_base_pwsh_sandbox
   plugin_dsh_base_approval["approval<br/>@deepseek-ai/dsh-user-approval"]
   cfg --> plugin_dsh_base_approval
   plugin_dsh_base_permission["permission<br/>@deepseek-ai/dsh-permission"]
@@ -68,6 +70,8 @@ flowchart LR
   cfg --> plugin_dsh_base_bash_env
   plugin_dsh_base_tool_bash["tool-bash<br/>@deepseek-ai/dsh-tool-bash"]
   cfg --> plugin_dsh_base_tool_bash
+  plugin_dsh_base_tool_pwsh["tool-pwsh<br/>@deepseek-ai/dsh-tool-pwsh"]
+  cfg --> plugin_dsh_base_tool_pwsh
   plugin_dsh_base_tool_tasks["tool-tasks<br/>@deepseek-ai/dsh-tool-tasks"]
   cfg --> plugin_dsh_base_tool_tasks
   plugin_dsh_base_fs_policy["fs-policy<br/>@deepseek-ai/dsh-fs-policy"]
@@ -194,10 +198,12 @@ flowchart LR
 | `sandbox` | `@deepseek-ai/dsh-sandbox-local` |
 | `sandbox-policy` | `@deepseek-ai/dsh-sandbox-policy` |
 | `bash-sandbox` | `@deepseek-ai/dsh-bash-sandbox` |
+| `pwsh-sandbox` | `@deepseek-ai/dsh-pwsh-sandbox` |
 | `approval` | `@deepseek-ai/dsh-user-approval` |
 | `permission` | `@deepseek-ai/dsh-permission` |
 | `bash-env` | `@deepseek-ai/dsh-bash-env` |
 | `tool-bash` | `@deepseek-ai/dsh-tool-bash` |
+| `tool-pwsh` | `@deepseek-ai/dsh-tool-pwsh` |
 | `tool-tasks` | `@deepseek-ai/dsh-tool-tasks` |
 | `fs-policy` | `@deepseek-ai/dsh-fs-policy` |
 | `tool-fs` | `@deepseek-ai/dsh-tool-fs` |

+ 8 - 3
apps/cli/config/agent-presets/code/agent.cordis.yml

@@ -45,11 +45,16 @@
 # publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is
 # the criterion for host-plane ownership — injection resolves before any session
 # exists, so there is no agent to key by. Behind a preset realm those variables
-# never reached the model's shell at all. `tool-bash` consumes the host registry
-# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the
-# sandbox policy owns it.
+# never reached the model's shell at all. Both shell tools consume the host
+# registry from here; their executors (`bash-sandbox`/`pwsh-sandbox`) are
+# host-plane too.
 - id: tool-bash
   name: '@deepseek-ai/dsh-tool-bash'
+  disabled: !!js process.platform === 'win32'
+
+- id: tool-pwsh
+  name: '@deepseek-ai/dsh-tool-pwsh'
+  disabled: !!js process.platform !== 'win32'
 
 # ── filesystem ──────────────────────────────────────────────────────────────
 

+ 8 - 3
apps/cli/config/agent-presets/cordis/agent.cordis.yml

@@ -39,11 +39,16 @@
 # publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is
 # the criterion for host-plane ownership — injection resolves before any session
 # exists, so there is no agent to key by. Behind a preset realm those variables
-# never reached the model's shell at all. `tool-bash` consumes the host registry
-# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the
-# sandbox policy owns it.
+# never reached the model's shell at all. Both shell tools consume the host
+# registry from here; their executors (`bash-sandbox`/`pwsh-sandbox`) are
+# host-plane too.
 - id: tool-bash
   name: '@deepseek-ai/dsh-tool-bash'
+  disabled: !!js process.platform === 'win32'
+
+- id: tool-pwsh
+  name: '@deepseek-ai/dsh-tool-pwsh'
+  disabled: !!js process.platform !== 'win32'
 
 # ── filesystem ──────────────────────────────────────────────────────────────
 

+ 2 - 2
apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md

@@ -25,13 +25,13 @@ Two planes, and the choice is not about how "agent-related" something feels —
 
 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. Both roots are configuration rather than fixed locations, though, and no call reports them — `authorable` says only whether a writable one exists — so take the path you actually read or edit from `list()` or `resolve()`, which is also where `copy()` reports what it just created.
+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. The four calls this skill relies on:
+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.

+ 8 - 3
apps/cli/config/agent-presets/standard/agent.cordis.yml

@@ -38,11 +38,16 @@
 # publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is
 # the criterion for host-plane ownership — injection resolves before any session
 # exists, so there is no agent to key by. Behind a preset realm those variables
-# never reached the model's shell at all. `tool-bash` consumes the host registry
-# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the
-# sandbox policy owns it.
+# never reached the model's shell at all. Both shell tools consume the host
+# registry from here; their executors (`bash-sandbox`/`pwsh-sandbox`) are
+# host-plane too.
 - id: tool-bash
   name: '@deepseek-ai/dsh-tool-bash'
+  disabled: !!js process.platform === 'win32'
+
+- id: tool-pwsh
+  name: '@deepseek-ai/dsh-tool-pwsh'
+  disabled: !!js process.platform !== 'win32'
 
 # ── filesystem ──────────────────────────────────────────────────────────────
 

+ 0 - 7
apps/cli/src/dump-config.ts

@@ -15,7 +15,6 @@ import {
   type ConfigDumpLayer,
 } from '@deepseek-ai/dsh-app-boot'
 import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts'
-import { resolveWindowsShellLayer } from './windows-shell.ts'
 
 const NAME = 'dsh'
 
@@ -34,12 +33,6 @@ export function runDumpConfig(profile: string, defaultOnly: boolean, patches: re
     label: layer.packageName,
     patches: layer.patches,
   }))
-  // The win32 shell platform layer rides between bundles and user layers,
-  // exactly where the boot applies it.
-  const windowsShellLayer = resolveWindowsShellLayer(process.platform, loaded.layers, NAME)
-  if (windowsShellLayer !== undefined) {
-    layers.push({ label: windowsShellLayer.label, patches: windowsShellLayer.patches })
-  }
   if (!defaultOnly) {
     if (existsSync(loaded.patchPath)) {
       layers.push({ label: loaded.patchPath, patches: loaded.patches })

+ 12 - 20
apps/cli/src/profile-boot.ts

@@ -29,17 +29,14 @@ import {
   watchUserPatches,
   type Profile,
 } from '@deepseek-ai/dsh-app-boot'
-import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths'
+import { resolveDshHome } from '@deepseek-ai/dsh-paths'
 
 /** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */
 const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url))
 
-/** Harness-home directory holding locally authored agent presets. */
-const USER_PRESET_DIR = '.agent-presets'
 import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
 import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
 import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
-import { resolveWindowsShellLayer } from './windows-shell.ts'
 
 const NAME = 'dsh'
 
@@ -110,8 +107,6 @@ interface ComposedProfile {
   profile: Profile
   /** Bundle layers concatenated — the part below the user layers on a live reload. */
   bundlePatches: PatchOptions[]
-  /** The win32 shell platform layer (the base bundle's `windows.cordis.patch.yml`), between bundles and user layers. */
-  windowsShellPatches: PatchOptions[]
   /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */
   homePatches: PatchOptions[]
   /** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */
@@ -127,7 +122,6 @@ interface ComposedProfile {
 function allPatches(composed: ComposedProfile): PatchOptions[] {
   return [
     ...composed.bundlePatches,
-    ...composed.windowsShellPatches,
     ...composed.profile.patches,
     ...composed.homePatches,
     ...composed.overlays,
@@ -136,10 +130,10 @@ function allPatches(composed: ComposedProfile): PatchOptions[] {
 
 /**
  * Load `name` and compose its effective patch stack: bundle layers in
- * `dsh.profile.bundles` order, the win32 shell platform layer (when the host
- * is Windows), the profile's user layer, the home-level user layer
- * (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to
- * every profile, so it outranks the per-profile layer), `--patch` overlays,
+ * `dsh.profile.bundles` order (the base bundle gates the shell stacks by
+ * platform on its own rows), the profile's user layer, the home-level user
+ * layer (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply
+ * to every profile, so it outranks the per-profile layer), `--patch` overlays,
  * then the telemetry switch.
  * @param name - the profile name.
  * @param patchFiles - `--patch` overlay paths, in argv order.
@@ -153,28 +147,27 @@ function composeProfile(
   const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
   const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
   const bundlePatches = profile.layers.flatMap(layer => layer.patches)
-  const windowsShellPatches = resolveWindowsShellLayer(process.platform, profile.layers, NAME)?.patches ?? []
   const rows = new Map<string, EntryOptions>()
-  for (const row of composeEntries([bundlePatches, windowsShellPatches, profile.patches, homePatches, overlays])) {
+  for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) {
     if (typeof row.id === 'string') rows.set(row.id, row)
   }
   const composedOverlays = [...overlays]
-  // Preset roots belong to every dsh composition that mounts the roster.
+  // The SHIPPED root is the part of the roster only this app can resolve: it
+  // sits beside this app's own config, in both the source and built layouts.
+  // The writable root the roster appends is `dsh-agent-presets`' own, so a
+  // launcher that never reaches this patch still finds a person's presets.
   if (rows.has('agent-presets')) {
     composedOverlays.push({
       id: 'agent-presets',
       config: {
         ...(rows.get('agent-presets')?.config ?? {}) as Record<string, unknown>,
-        roots: [
-          { path: SHIPPED_PRESET_ROOT, trust: 'system' },
-          { path: dshHomePath(USER_PRESET_DIR), trust: 'user' },
-        ],
+        roots: [{ path: SHIPPED_PRESET_ROOT, trust: 'system' }],
       },
     })
   }
   const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
   if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch)
-  return { profile, bundlePatches, windowsShellPatches, homePatches, overlays: composedOverlays, rows }
+  return { profile, bundlePatches, homePatches, overlays: composedOverlays, rows }
 }
 
 /** Options for {@link runProfile}. */
@@ -246,7 +239,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
   // removing the override could never revert the row to the bundle default.
   const composeLive = (): PatchOptions[] => structuredClone([
     ...composed.bundlePatches,
-    ...composed.windowsShellPatches,
     ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
     ...loadOptionalPatches(NAME, homePatchPath()) ?? [],
     ...composed.overlays,

+ 0 - 52
apps/cli/src/windows-shell.ts

@@ -1,52 +0,0 @@
-/**
- * The Windows shell platform layer: on win32 hosts the shipped profile
- * compositions swap the POSIX-only bash stack for the sandbox-confined
- * PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox` +
- * `@deepseek-ai/dsh-tool-pwsh`). The layer is the base bundle's
- * `windows.cordis.patch.yml`, injected by the launcher between the bundle
- * layers and the user layers so a user patch can still override it — the
- * only override channel is composition config, like every other roster
- * decision. POSIX hosts never receive the layer.
- * @module @deepseek-ai/dsh/windows-shell
- */
-
-import { join } from 'node:path'
-import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import { loadOverlayPatches, type ProfileLayer } from '@deepseek-ai/dsh-app-boot'
-
-/** The base bundle whose package carries the Windows shell patch. */
-export const BASE_BUNDLE = '@deepseek-ai/dsh-base'
-
-/** The Windows shell patch filename inside the base bundle package. */
-export const WINDOWS_SHELL_PATCH_FILENAME = 'windows.cordis.patch.yml'
-
-/** One Windows shell platform layer: its patch file and parsed patches. */
-export interface WindowsShellLayer {
-  /** The patch file path, used as the config-dump provenance label. */
-  label: string
-  /** The parsed patch entries, applied after the bundle layers. */
-  patches: PatchOptions[]
-}
-
-/**
- * Resolve the Windows shell platform layer for a profile composition.
- * @param platform - the host platform (`process.platform` at call sites).
- * @param layers - the profile's bundle layers, in application order.
- * @param binName - the diagnostic prefix on thrown errors (`dsh`).
- * @returns the pwsh layer on win32, else `undefined`. A custom profile that
- *   mounts no base bundle is skipped (it owns its shell stack); a base
- *   bundle whose Windows shell patch is missing fails loud in
- *   {@link loadOverlayPatches} — the shipped package always carries it, so
- *   a miss is a broken installation.
- */
-export function resolveWindowsShellLayer(
-  platform: NodeJS.Platform,
-  layers: readonly ProfileLayer[],
-  binName: string,
-): WindowsShellLayer | undefined {
-  if (platform !== 'win32') return undefined
-  const base = layers.find(layer => layer.packageName === BASE_BUNDLE)
-  if (base === undefined) return undefined
-  const label = join(base.packageDir, WINDOWS_SHELL_PATCH_FILENAME)
-  return { label, patches: loadOverlayPatches(binName, label) }
-}

+ 67 - 1
apps/cli/tests/web-agent-presets.e2e.ts

@@ -96,7 +96,11 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis
     // document overrides.
     {
       id: 'agent-presets',
-      config: { default: 'standard', roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }] },
+      config: {
+        default: 'standard',
+        roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }],
+        includeUserRoot: false,
+      },
     },
     ...extra,
   ]
@@ -442,6 +446,7 @@ describe('product subagent rows in user presets', () => {
           { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' },
           { path: userRoot, trust: 'user' },
         ],
+        includeUserRoot: false,
       },
     }])
   }, 120_000)
@@ -624,6 +629,66 @@ describe('a delegated child', () => {
   })
 })
 
+describe('a launcher that configures no writable root', () => {
+  // The claim this default exists for, asserted through the real shipped
+  // bundles rather than a hand-built context: `apps/cli` patches in only the
+  // system root, and a person's own presets are found anyway because the
+  // roster derives `<dshHome>/.agent-presets` itself. `$DSH_HOME` is pointed
+  // at a temp home BEFORE boot — the derived root is resolved when the plugin
+  // is constructed, and an unpinned run would read the developer's own.
+  let derivedCtx: Context
+  let previousHome: string | undefined
+
+  beforeAll(async () => {
+    const home = await mkdtemp(join(tmpdir(), 'dsh-preset-derived-'))
+    previousHome = process.env.DSH_HOME
+    process.env.DSH_HOME = home
+    await mkdir(join(home, '.agent-presets', 'derived-mine'), { recursive: true })
+    await writeFile(
+      join(home, '.agent-presets', 'derived-mine', 'agent.cordis.yml'),
+      '- id: tool-todo\n  name: \'@deepseek-ai/dsh-tool-todo\'\n  config:\n    allowParallelInProgress: true\n',
+    )
+    const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-derived-settings-')), 'settings.yaml')
+    await writeFile(settingsFile, '{}\n')
+    // Only the shipped root, exactly what `composeProfile` supplies; the
+    // writable one is the roster's own default rather than this patch's job.
+    derivedCtx = await bootWeb(settingsFile, [{
+      id: 'agent-presets',
+      config: {
+        default: 'standard',
+        roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }],
+        includeUserRoot: true,
+      },
+    }])
+  }, 120_000)
+
+  afterAll(async () => {
+    if (previousHome === undefined) delete process.env.DSH_HOME
+    else process.env.DSH_HOME = previousHome
+    await derivedCtx.fiber.dispose()
+  })
+
+  it('discovers and mounts a preset the person authored under the harness home', async () => {
+    const listed = await derivedCtx.agentPresets.list()
+
+    const mine = listed.find(preset => preset.id === 'derived-mine')
+    expect(mine).toMatchObject({ trust: 'user' })
+    // Omitted rather than undefined: a healthy row carries no `broken` key.
+    expect(mine?.broken).toBeUndefined()
+    expect(derivedCtx.agentPresets.authorable).toBe(true)
+
+    const handle = await derivedCtx.agents.create({
+      sessionId: SessionId('preset-derived-root'),
+      setup: agentCtx => derivedCtx.agentPresets.mount(agentCtx, 'derived-mine').then(() => undefined),
+    })
+    try {
+      expect(toolNames(derivedCtx, handle.agent)).toContain('todo_write')
+    } finally {
+      await handle.dispose()
+    }
+  })
+})
+
 describe('authoring a preset on the shipped composition', () => {
   let authorCtx: Context
   let userRoot: string
@@ -642,6 +707,7 @@ describe('authoring a preset on the shipped composition', () => {
           // nothing is the normal first-run state.
           { path: userRoot, trust: 'user' },
         ],
+        includeUserRoot: false,
       },
     }])
   })

+ 98 - 100
apps/cli/tests/windows-shell.spec.ts

@@ -1,73 +1,38 @@
+/**
+ * The shipped shell composition: the base bundle gates both shell stacks by
+ * platform on its own rows (`disabled: !!js process.platform`), so exactly
+ * one shell stack mounts per host and no separate platform layer exists —
+ * the launcher applies nothing beyond the bundle layers. The spec composes
+ * the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the
+ * app installation anchor) through the boot's patch algorithm and pins the
+ * effective per-platform roster, the preset-level gates that keep tool-bash
+ * out of win32 sessions and tool-pwsh out of POSIX sessions, and the
+ * cold-start resolution closure for the pwsh rows' bare plugin names.
+ */
+
 import { afterEach, describe, expect, it } from 'vitest'
-import { mkdtempSync, writeFileSync, rmSync, mkdirSync, readFileSync } from 'node:fs'
+import { mkdtempSync, rmSync, readFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
-import { join } from 'node:path'
+import { join, resolve } from 'node:path'
 import { fileURLToPath } from 'node:url'
-import type { ProfileLayer } from '@deepseek-ai/dsh-app-boot'
+import yaml from 'js-yaml'
+import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
+import { evaluate } from '@deepseek-ai/cordis-plugin-loader'
 import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot'
-import {
-  BASE_BUNDLE,
-  resolveWindowsShellLayer,
-  WINDOWS_SHELL_PATCH_FILENAME,
-} from '../src/windows-shell.ts'
-
-const WINDOWS_PATCH = `- id: bash-sandbox
-  disabled: true
-- insert:
-    - id: pwsh-sandbox
-      name: '@deepseek-ai/dsh-pwsh-sandbox'
-`
-
-/** One fake bundle layer rooted in a temp directory. */
-function fakeLayer(packageName: string, dir: string): ProfileLayer {
-  return { packageName, packageDir: dir, patchPath: join(dir, 'cordis.patch.yml'), patches: [] }
-}
-
-/** A base bundle layer whose package carries the Windows shell patch. */
-function baseLayerWithPatch(dir: string): ProfileLayer {
-  writeFileSync(join(dir, WINDOWS_SHELL_PATCH_FILENAME), WINDOWS_PATCH)
-  return fakeLayer(BASE_BUNDLE, dir)
-}
 
-describe('resolveWindowsShellLayer', () => {
-  let base: string
-  afterEach(() => { if (base !== undefined) rmSync(base, { recursive: true, force: true }) })
-  const tempBase = (): string => {
-    base = mkdtempSync(join(tmpdir(), 'dsh-windows-shell-'))
-    return base
+/**
+ * The effective disabled state of one row on one platform: a `!!js` expression
+ * evaluates with a platform-scoped `process` so both outcomes pin on any host.
+ */
+function disabledOn(row: { disabled?: unknown }, platform: 'win32' | 'linux'): boolean {
+  const value = row.disabled
+  if (value !== null && typeof value === 'object' && '__jsExpr' in value) {
+    return Boolean(evaluate({ process: { platform } }, (value as { __jsExpr: string }).__jsExpr))
   }
+  return value === true
+}
 
-  it('never applies on POSIX hosts', () => {
-    expect(resolveWindowsShellLayer('linux', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined()
-    expect(resolveWindowsShellLayer('darwin', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined()
-  })
-
-  it('defaults Windows hosts to the pwsh platform layer', () => {
-    const layer = resolveWindowsShellLayer('win32', [baseLayerWithPatch(tempBase())], 'dsh')
-    expect(layer).toBeDefined()
-    expect(layer?.label.endsWith(WINDOWS_SHELL_PATCH_FILENAME)).toBe(true)
-    expect(layer?.patches).toEqual([
-      { id: 'bash-sandbox', disabled: true },
-      { insert: [{ id: 'pwsh-sandbox', name: '@deepseek-ai/dsh-pwsh-sandbox' }] },
-    ])
-  })
-
-  it('skips custom profiles without a base bundle', () => {
-    const other = fakeLayer('@deepseek-ai/dsh-custom', tempBase())
-    expect(resolveWindowsShellLayer('win32', [other], 'dsh')).toBeUndefined()
-  })
-
-  it('fails loud when the base bundle ships no Windows shell patch', () => {
-    const base = tempBase()
-    mkdirSync(base, { recursive: true })
-    // The overlay loader owns the fail-loud contract: the caller named this
-    // file, so its absence is a misconfiguration, not "no overlay".
-    expect(() => resolveWindowsShellLayer('win32', [fakeLayer(BASE_BUNDLE, base)], 'dsh'))
-      .toThrow(/dsh: failed to read overlay .*windows\.cordis\.patch\.yml/)
-  })
-})
-
-describe('the shipped Windows composition (real bundle layers)', () => {
+describe('the shipped shell composition (real bundle layers)', () => {
   let home: string
   afterEach(() => { if (home !== undefined) rmSync(home, { recursive: true, force: true }) })
   // The app installation anchor, mirroring profile-boot.ts: the bundle layers
@@ -75,65 +40,98 @@ describe('the shipped Windows composition (real bundle layers)', () => {
   // suite composes the shipped patch files, not test fixtures.
   const anchor = fileURLToPath(new URL('../package.json', import.meta.url))
 
-  it('composes the win32 confined roster through the real patch layers', () => {
+  it('composes the confined pwsh roster on win32 and the bash roster on POSIX from the same rows', () => {
     home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-'))
     initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
     const profile = loadProfile('dsh', 'web', anchor, home)
     const warnings: string[] = []
-    const win32 = resolveWindowsShellLayer('win32', profile.layers, 'dsh')
-    expect(win32).toBeDefined()
     const rows = composeEntries(
-      [...profile.layers.map(layer => layer.patches), win32!.patches],
+      profile.layers.map(layer => layer.patches),
       message => warnings.push(message),
     )
     const byId = new Map(rows.map(row => [row.id, row]))
-    // Only the POSIX bash stack leaves the roster: the permission surface
-    // (sandbox/sandbox-policy/fs-sandbox, permission, approval) stays enabled
-    // exactly as on POSIX — the confined pwsh executor is what changes.
-    for (const id of ['bash-sandbox', 'tool-bash']) {
-      expect(byId.get(id)?.disabled, `row ${id}`).toBe(true)
+    // One shared patch set, two rosters: the shell stacks gate themselves.
+    for (const id of ['bash-sandbox', 'pwsh-sandbox', 'tool-bash', 'tool-pwsh']) {
+      expect(byId.has(id), `row ${id}`).toBe(true)
     }
+    expect(disabledOn(byId.get('bash-sandbox')!, 'win32'), 'bash-sandbox on win32').toBe(true)
+    expect(disabledOn(byId.get('bash-sandbox')!, 'linux'), 'bash-sandbox on linux').toBe(false)
+    expect(disabledOn(byId.get('pwsh-sandbox')!, 'win32'), 'pwsh-sandbox on win32').toBe(false)
+    expect(disabledOn(byId.get('pwsh-sandbox')!, 'linux'), 'pwsh-sandbox on linux').toBe(true)
+    // Host shell-tool rows are disabled on every platform; sessions mount
+    // their own rows instead.
+    expect(byId.get('tool-bash')?.disabled).toBe(true)
+    expect(byId.get('tool-pwsh')?.disabled).toBe(true)
+    // The permission surface never moves: the sandbox/policy rows, the
+    // permission switcher, fs-sandbox, and the approval service stay enabled
+    // exactly as on POSIX — the confined pwsh executor is what changes.
     for (const id of ['permission', 'ui-permission', 'sandbox', 'sandbox-policy', 'fs-sandbox', 'approval']) {
       expect(byId.get(id)?.disabled, `row ${id}`).not.toBe(true)
     }
-    for (const id of ['pwsh-sandbox', 'tool-pwsh']) {
-      expect(byId.has(id), `inserted row ${id}`).toBe(true)
-    }
     // The launcher's cold-start module fallback BFS-links the apps/cli
-    // dependency closure into the profile's node_modules (the pwsh-local
-    // precedent), so every inserted bare plugin must resolve from there.
+    // dependency closure into the profile's node_modules, so every bare
+    // plugin name in the base patch must resolve from there.
     const cliManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies?: Record<string, string> }
     for (const name of ['@deepseek-ai/dsh-pwsh-sandbox', '@deepseek-ai/dsh-tool-pwsh']) {
       expect(cliManifest.dependencies?.[name], `cold-start closure must reach ${name}`).toBeDefined()
     }
-    // The patch touches only base-owned rows plus inserts, so the full web
-    // profile composes without any no-match warning.
     expect(warnings).toEqual([])
   })
 
-  it('leaves POSIX untouched and base-only profiles compose without warnings', () => {
+  it('base-only profiles carry both stacks with the same platform gating', () => {
     home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-'))
-    initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
-    const profile = loadProfile('dsh', 'web', anchor, home)
-    // POSIX: no platform layer, the bash stack stays enabled.
-    const posixRows = composeEntries(profile.layers.map(layer => layer.patches))
-    const posixById = new Map(posixRows.map(row => [row.id, row]))
-    expect(posixById.get('bash-sandbox')?.disabled).not.toBe(true)
-    expect(posixById.has('pwsh-local')).toBe(false)
-    expect(posixById.has('pwsh-sandbox')).toBe(false)
-
-    // A base-only custom profile (the DEFAULT_PROFILE_BUNDLES template): the
-    // patch touches only base-owned rows (bash-sandbox/tool-bash) plus its
-    // inserts, so the composition produces no no-match warning.
     initProfile(join(home, PROFILES_DIR, 'base-only'), ['@deepseek-ai/dsh-base'])
-    const baseOnly = loadProfile('dsh', 'base-only', anchor, home)
-    const baseWarnings: string[] = []
-    const win32 = resolveWindowsShellLayer('win32', baseOnly.layers, 'dsh')
-    expect(win32).toBeDefined()
-    composeEntries(
-      [...baseOnly.layers.map(layer => layer.patches), win32!.patches],
-      message => baseWarnings.push(message),
+    const profile = loadProfile('dsh', 'base-only', anchor, home)
+    const warnings: string[] = []
+    const rows = composeEntries(
+      profile.layers.map(layer => layer.patches),
+      message => warnings.push(message),
     )
-    expect(baseWarnings).toEqual([])
+    const byId = new Map(rows.map(row => [row.id, row]))
+    for (const id of ['bash-sandbox', 'tool-bash', 'pwsh-sandbox', 'tool-pwsh']) {
+      expect(byId.has(id), `row ${id}`).toBe(true)
+    }
+    // No web overlay: the tool rows keep their own gating too.
+    expect(disabledOn(byId.get('tool-bash')!, 'win32'), 'tool-bash on win32').toBe(true)
+    expect(disabledOn(byId.get('tool-bash')!, 'linux'), 'tool-bash on linux').toBe(false)
+    expect(disabledOn(byId.get('tool-pwsh')!, 'win32'), 'tool-pwsh on win32').toBe(false)
+    expect(disabledOn(byId.get('tool-pwsh')!, 'linux'), 'tool-pwsh on linux').toBe(true)
+    expect(warnings).toEqual([])
+  })
+})
+
+describe('shipped agent presets gate both shell tools by platform', () => {
+  const presetRoot = resolve(fileURLToPath(new URL('../package.json', import.meta.url)), '..', 'config', 'agent-presets')
+
+  it.each(['standard', 'code', 'cordis'])('preset %s gates its shell tool rows by platform', (preset) => {
+    const entries: unknown = yaml.load(
+      readFileSync(join(presetRoot, preset, 'agent.cordis.yml'), 'utf8'),
+      { schema: entryListSchema },
+    )
+    if (!Array.isArray(entries)) throw new TypeError(`preset ${preset} must parse to an entry array`)
+    for (const [id, win32] of [['tool-bash', true], ['tool-pwsh', false]] as const) {
+      const row = entries.find((entry): entry is Record<string, unknown> => (
+        typeof entry === 'object' && entry !== null && (entry as Record<string, unknown>).id === id
+      ))
+      if (row === undefined) throw new TypeError(`preset ${preset} must mount ${id}`)
+      expect(row.disabled).toMatchObject({ __jsExpr: expect.any(String) as string })
+      // A platform-scoped context pins both outcomes on every host.
+      const expression = (row.disabled as { __jsExpr: string }).__jsExpr
+      expect(Boolean(evaluate({ process: { platform: 'win32' } }, expression)), `${id} on win32`).toBe(win32)
+      expect(Boolean(evaluate({ process: { platform: 'linux' } }, expression)), `${id} on linux`).toBe(!win32)
+    }
+  })
+
+  it('minimal mounts no shell tool row at all (its shell is the PTY stack)', () => {
+    const entries: unknown = yaml.load(
+      readFileSync(join(presetRoot, 'minimal', 'agent.cordis.yml'), 'utf8'),
+      { schema: entryListSchema },
+    )
+    if (!Array.isArray(entries)) throw new TypeError('minimal preset must parse to an entry array')
+    for (const id of ['tool-bash', 'tool-pwsh']) {
+      expect(entries.some(entry => (
+        typeof entry === 'object' && entry !== null && (entry as Record<string, unknown>).id === id
+      )), `${id} must be absent from minimal`).toBe(false)
+    }
   })
 })

+ 8 - 2
apps/web/tests/scaffold.ts

@@ -385,7 +385,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     // able to change a golden.
     {
       id: 'agent-presets',
-      config: { default: 'standard', roots: [{ path: SHIPPED_PRESET_DIR, trust: 'system' }] },
+      config: {
+        default: 'standard',
+        roots: [{ path: SHIPPED_PRESET_DIR, trust: 'system' }],
+        includeUserRoot: false,
+      },
     },
     { id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
     { id: 'session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } },
@@ -445,7 +449,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     ] },
     ...options.agentPresets === undefined
       ? []
-      : [{ id: 'agent-presets', config: options.agentPresets }],
+      // Never the derived harness-home root: a developer's own presets must not
+      // be able to change a golden, whatever roots a scenario asks for.
+      : [{ id: 'agent-presets', config: { ...options.agentPresets, includeUserRoot: false } }],
     ...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }],
     ...options.cordisTools === true
       ? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }]

+ 7 - 0
apps/web/tests/startup-auto-selection.e2e.ts

@@ -68,6 +68,13 @@ describe('web e2e: startup auto-selection', () => {
   it('keeps the resident Hero and composer nodes when the first Workspace session appears', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-first-workspace-stable-tree'))
     await page.locator(`${ROOT_PHASE}[data-phase="hero"]`).waitFor({ timeout: 15_000 })
+    const headline = page.getByText('Into the Unknown', { exact: true })
+    const fish = headline.locator('xpath=preceding-sibling::span[1]/*[name()="svg"]')
+    const fishHitbox = fish.locator('..')
+    expect(await fish.evaluate(node => getComputedStyle(node).color))
+      .toBe(await headline.evaluate(node => getComputedStyle(node).color))
+    await fishHitbox.hover()
+    expect(await fish.evaluate(node => getComputedStyle(node).animationName)).not.toBe('none')
     await page.evaluate(() => {
       const refs = {
         root: document.querySelector('div[data-phase="hero"]'),

+ 8 - 1
apps/web/tests/subagent-conversation.e2e.ts

@@ -31,6 +31,8 @@ const ONE_SHOT_LABEL = 'event-sourcing reviewer'
 const NESTED_LABEL = 'example editor'
 const PARENT_PROMPT = 'Ask a research subagent to explain event sourcing.'
 const INITIAL_PROMPT = 'Explain event sourcing in one sentence.'
+/** The grandchild's own first message; its arrival is what says its history finished loading. */
+const NESTED_PROMPT = 'Give one concrete event sourcing example.'
 const FOLLOWUP = 'Now give the same explanation to a human reader.'
 const POST_FORK_FOLLOWUP = 'Continue the original conversation after the fork.'
 
@@ -176,7 +178,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
         seq: 1,
         time: authoredAt + 1,
         data: {
-          content: [{ type: 'text', text: 'Give one concrete event sourcing example.' }],
+          content: [{ type: 'text', text: NESTED_PROMPT }],
           source: { kind: 'user' },
         },
         surfaceOp: 'append',
@@ -404,6 +406,11 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     )
     await nestedRow.click()
     await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor()
+    // The offline banner renders from the descriptor alone, so it says nothing
+    // about the transcript below it. The golden pins that transcript, and
+    // `captureStableAria` calls two identical polls stable — including two of
+    // "Loading history…". Wait for the message the golden asserts.
+    await page.getByText(NESTED_PROMPT).waitFor()
     const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
     const crumbs = await hierarchy.getByRole('button').allTextContents()
     expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL])

+ 2 - 2
docs/config-catalog.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 docs/config-catalog.md
-config-catalog.md: d79385d43657d7c97c8ea66e0e799c42fe9aad06
-config-catalog.zh.md: a5af999b88eced352d2c070378b8ea57f38595be
+config-catalog.md: 8d1f2a0782612eb1a0356e69b9ccfd2ba70d2a1d
+config-catalog.zh.md: 620e863dba6ef3be8d1d92b40368e539d7b4a9e6

+ 5 - 0
docs/config-catalog.md

@@ -135,6 +135,11 @@ export interface Config {
   default: string
   /** Scanned roots in precedence order; an earlier root wins a duplicate id. */
   roots: PresetRoot[]
+  /**
+   * Append the harness home's `USER_PRESET_DIR` as a `user` root, after every
+   * configured root. False mounts a roster over `roots` alone.
+   */
+  includeUserRoot: boolean
 }
 
 /** One directory scanned for preset subdirectories. */

+ 5 - 0
docs/config-catalog.zh.md

@@ -137,6 +137,11 @@ export interface Config {
   default: string
   /** Scanned roots in precedence order; an earlier root wins a duplicate id. */
   roots: PresetRoot[]
+  /**
+   * Append the harness home's `USER_PRESET_DIR` as a `user` root, after every
+   * configured root. False mounts a roster over `roots` alone.
+   */
+  includeUserRoot: boolean
 }
 
 /** One directory scanned for preset subdirectories. */

+ 2 - 2
docs/cordis-primer.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 docs/cordis-primer.md
-cordis-primer.md: d1e7c5fd8eaaa89fe448d238359389d945cd6346
-cordis-primer.zh.md: d6ce0f2024f65b006c9505daffaa06a08bb56875
+cordis-primer.md: c57055e9657ebc8a0c3f537825ddcbdda1ced68a
+cordis-primer.zh.md: 45cce2abb2117aef44028ab53a9836d24fab91d6

+ 1 - 1
docs/cordis-primer.md

@@ -35,7 +35,7 @@ For single-decision events, short-circuiting is the design. A policy listener ca
 
 ## Loader Configuration
 
-`@deepseek-ai/cordis-plugin-include` parses `!!js` into expression nodes. Loader interpolates only an entry's `config`, after declared injections activate, against that plugin context (`ctx.serviceName`); Include preserves nested row expressions until target activation. Entry metadata (`id`, `name`, `group`, `disabled`, `inject`, `intercept`, `isolate`) stays literal, so `disabled: !!js ...` always disables the entry. Use overlays when the environment selects plugins.
+`@deepseek-ai/cordis-plugin-include` parses `!!js` into expression nodes. Loader interpolates an entry's `config` (after declared injections activate, against that plugin context — `ctx.serviceName`) and its `disabled` field (at every mount decision, against the loader context); Include preserves nested row expressions until target activation. Other entry metadata stays literal. Use overlays when the environment selects plugins.
 
 ## Practical Rules
 

+ 1 - 1
docs/cordis-primer.zh.md

@@ -39,7 +39,7 @@ Cordis 是 DeepSeek Harness SDK 底层以 vendor 方式引入的插件框架。
 
 ## Loader 配置
 
-`@deepseek-ai/cordis-plugin-include` 将 `!!js` 解析为表达式节点。Loader 只在声明的注入激活后,基于该插件上下文(`ctx.serviceName`)插值条目的 `config`;Include 会保留嵌套行表达式,直到目标行激活。条目元数据(`id`、`name`、`group`、`disabled`、`inject`、`intercept`、`isolate`)保持字面值,因此 `disabled: !!js ...` 始终禁用该条目。由环境选择插件时,请使用 overlay。
+`@deepseek-ai/cordis-plugin-include` 将 `!!js` 解析为表达式节点。Loader 在声明的注入激活后,基于该插件上下文(`ctx.serviceName`)插值条目的 `config`,并在每次挂载决策时基于 loader 上下文插值其 `disabled` 字段;Include 会保留嵌套行表达式,直到目标行激活。其余条目元数据保持字面值。由环境选择插件时,请使用 overlay。
 
 ## 实践规则
 

+ 2 - 2
docs/cordis-tutorial/05-config.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 docs/cordis-tutorial/05-config.md
-05-config.md: 2357f663135d6fc78a65f9d0952e0bc3f5eefae4
-05-config.zh.md: fbd94d179494ad0b6f73baff2ca525c786cc9e33
+05-config.md: 17cccce2ec43be65477ce527800ee6a636ee5d96
+05-config.zh.md: 87f1cb465d5f3e52a6f8e449cfb29818ba86dcb8

+ 1 - 1
docs/cordis-tutorial/05-config.md

@@ -77,7 +77,7 @@ The loader used in this repo supports a `!!js` tag for config values that must b
     greeting: !!js process.env.DEMO_GREETING ?? 'Hello'
 ```
 
-`!!js` works **only inside `config`**. Entry metadata (`name`, `id`, `disabled`, `inject`, ...) is static; `disabled: !!js ...` produces a truthy expression object that always disables the entry. See [loader configuration](../cordis-primer.md#loader-configuration).
+`!!js` works only inside `config` and in an entry's `disabled` field. `disabled: !!js ...` evaluates against the loader context at every mount decision (this repo's extension), so a row can gate itself on platform or environment; the other metadata (`name`, `id`, `inject`, ...) stays static, where an expression is ordinary truthy data. See [loader configuration](../cordis-primer.md#loader-configuration).
 
 Next: [Composition and HMR](06-composition-and-hmr.md) — treating `cordis.yml` as the application.
 

+ 1 - 1
docs/cordis-tutorial/05-config.zh.md

@@ -77,7 +77,7 @@ ValidationError: invalid config:
     greeting: !!js process.env.DEMO_GREETING ?? 'Hello'
 ```
 
-`!!js` **仅在 `config` 内有效**。Cordis 配置项的元数据(`name`、`id`、`disabled`、`inject` 等)是静态的;`disabled: !!js ...` 会生成一个真值表达式对象,始终禁用该 Cordis 配置项。详见 [loader 配置](../cordis-primer.md#loader-configuration)。
+`!!js` 仅在 `config` 与条目 `disabled` 字段内有效。`disabled: !!js ...` 在每次挂载决策时基于 loader 上下文求值(本仓库的扩展),可以按平台或环境门控一行;其余元数据(`name`、`id`、`inject` 等)保持静态,其中的表达式是普通真值数据。详见 [loader 配置](../cordis-primer.md#loader-configuration)。
 
 下一章:[组合与 HMR(热模块替换)](06-composition-and-hmr.md):将 `cordis.yml` 视为应用。
 

+ 2 - 2
docs/subsystems/core.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 docs/subsystems/core.md
-core.md: e52a7619085b2956496be6234f711441902fc259
-core.zh.md: e9cfe19129e33a1c17e87561397b13139a6d7537
+core.md: 2e89bac4c0468c094814aa7137381f4be569fc29
+core.zh.md: 2a8a35bb46adf28fd2ba07d0a319cdca0cbb4a2f

+ 1 - 1
docs/subsystems/core.md

@@ -546,7 +546,7 @@ async standingKeyFor(id?: string): Promise<ScopeKey>
 
 Types: [ScopeKey](scope.md)
 
-Source: [`packages/preset/agent-presets/src/index.ts:81`](../../packages/preset/agent-presets/src/index.ts)
+Source: [`packages/preset/agent-presets/src/index.ts:82`](../../packages/preset/agent-presets/src/index.ts)
 
 <a id="ctxagents--agentregistry"></a>
 

+ 1 - 1
docs/subsystems/core.zh.md

@@ -554,7 +554,7 @@ async standingKeyFor(id?: string): Promise<ScopeKey>
 
 Types: [ScopeKey](scope.md)
 
-Source: [`packages/preset/agent-presets/src/index.ts:81`](../../packages/preset/agent-presets/src/index.ts)
+Source: [`packages/preset/agent-presets/src/index.ts:82`](../../packages/preset/agent-presets/src/index.ts)
 
 <a id="ctxagents--agentregistry"></a>
 

+ 68 - 0
packages/boot/app-boot/tests/user-patches.spec.ts

@@ -199,6 +199,74 @@ describe('Loader config interpolation', () => {
   })
 })
 
+describe('Loader entry disabled interpolation', () => {
+  it('evaluates a !!js disabled expression against the loader context', async () => {
+    const dir = tmp()
+    writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
+    writeFileSync(join(dir, 'cordis.yml'), [
+      '- id: expr-off',
+      '  name: ./noop.mjs',
+      '  disabled: !!js process.version.length > 0',
+      '- id: expr-on',
+      '  name: ./noop.mjs',
+      '  disabled: !!js process.version.length === 0',
+      '',
+    ].join('\n'))
+    const ctx = await boot(NAME, join(dir, 'cordis.yml'))
+    try {
+      const off = [...ctx.loader.entries()].find(entry => entry.options.id === 'expr-off')
+      const on = [...ctx.loader.entries()].find(entry => entry.options.id === 'expr-on')
+      expect(off?.disabled).toBe(true)
+      expect(off?.fiber).toBeUndefined()
+      expect(on?.disabled).toBe(false)
+      expect(on?.fiber).toBeDefined()
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('keeps the raw expression in the options so write-back preserves the !!js form', async () => {
+    const dir = tmp()
+    writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
+    writeFileSync(join(dir, 'cordis.yml'), '- id: expr\n  name: ./noop.mjs\n  disabled: !!js process.platform === "win32"\n')
+    const ctx = await boot(NAME, join(dir, 'cordis.yml'))
+    try {
+      const entry = [...ctx.loader.entries()].find(item => item.options.id === 'expr')
+      // The evaluated boolean drives the mount decision; the serialized
+      // expression node stays in the options for the file-backed tree.
+      expect(entry?.options.disabled).toEqual({ __jsExpr: 'process.platform === "win32"' })
+      expect(entry?.disabled).toBe(process.platform === 'win32')
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('re-evaluates when update() replaces the expression, mounting and unmounting', async () => {
+    const dir = tmp()
+    writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
+    writeFileSync(join(dir, 'cordis.yml'), '- id: expr\n  name: ./noop.mjs\n  disabled: !!js process.version.length === 0\n')
+    const ctx = await boot(NAME, join(dir, 'cordis.yml'))
+    try {
+      const entry = [...ctx.loader.entries()].find(item => item.options.id === 'expr')
+      expect(entry?.disabled).toBe(false)
+      expect(entry?.fiber).toBeDefined()
+      // The expression form is the file dialect; the typed programmatic API
+      // carries booleans. Include reapplication feeds the raw node through
+      // the untyped file path — simulated here with the serialized shape.
+      const disabledTrue = { __jsExpr: 'process.version.length > 0' } as unknown as boolean
+      const disabledFalse = { __jsExpr: 'process.version.length === 0' } as unknown as boolean
+      await entry?.update({ disabled: disabledTrue })
+      expect(entry?.disabled).toBe(true)
+      expect(entry?.fiber).toBeUndefined()
+      await entry?.update({ disabled: disabledFalse })
+      expect(entry?.disabled).toBe(false)
+      expect(entry?.fiber).toBeDefined()
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+})
+
 describe('boot with user patches', () => {
   it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => {
     const dir = tmp()

+ 2 - 2
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: 8b0db20274036a2601da19617a35e6bf4aeb30ca
-README.zh.md: ac5ab10a523fa211c1c1daf4c55d4dc8702eb782
+README.md: bd38f39f58ee1f765ff34d40cf57cc6daed2b32b
+README.zh.md: 2c6ff8513bae2b7d4b595733e83223bb7af34780

+ 1 - 1
packages/bundle/base/README.md

@@ -4,7 +4,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. Codex and Claude Code providers load dormant; Agent Presets independently decide whether their agent contributes either model-facing delegation tool. 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.
 
-Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash stack (`bash-sandbox`/`tool-bash`) and inserts the sandbox-confined PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox`, `@deepseek-ai/dsh-tool-pwsh`). 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. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; 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 never receive it.
+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.
 
 The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it.
 

+ 1 - 1
packages/bundle/base/README.zh.md

@@ -4,7 +4,7 @@
 
 以 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` 列表中的第一层。Codex 与 Claude Code provider 以休眠状态加载;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 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 栈(`bash-sandbox`/`tool-bash`),并插入沙盒受限的 PowerShell 栈(`@deepseek-ai/dsh-pwsh-sandbox`、`@deepseek-ai/dsh-tool-pwsh`)。权限面与 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` 并在加载时失败。启动器在 win32 主机上把该层应用于 bundle 层与用户层之间;偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机永远不会收到它。
+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` 服务,配方不完整会在加载时 fail loud)。POSIX 主机看到的是被禁用的 pwsh 行。
 
 行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。
 

+ 10 - 0
packages/bundle/base/cordis.patch.yml

@@ -172,9 +172,14 @@
 
     - id: bash-sandbox
       name: '@deepseek-ai/dsh-bash-sandbox'
+      disabled: !!js process.platform === 'win32'
       config:
         timeoutMs: 60000
 
+    - id: pwsh-sandbox
+      name: '@deepseek-ai/dsh-pwsh-sandbox'
+      disabled: !!js process.platform !== 'win32'
+
     - id: approval
       name: '@deepseek-ai/dsh-user-approval'
       config:
@@ -199,6 +204,11 @@
 
     - id: tool-bash
       name: '@deepseek-ai/dsh-tool-bash'
+      disabled: !!js process.platform === 'win32'
+
+    - id: tool-pwsh
+      name: '@deepseek-ai/dsh-tool-pwsh'
+      disabled: !!js process.platform !== 'win32'
 
     - id: tool-tasks
       name: '@deepseek-ai/dsh-tool-tasks'

+ 0 - 2
packages/bundle/base/package.json

@@ -23,7 +23,6 @@
       "default": "./lib/invariant.js"
     },
     "./cordis.patch.yml": "./cordis.patch.yml",
-    "./windows.cordis.patch.yml": "./windows.cordis.patch.yml",
     "./src/*": "./src/*",
     "./package.json": "./package.json"
   },
@@ -31,7 +30,6 @@
     "lib/index.js",
     "lib/invariant.js",
     "cordis.patch.yml",
-    "windows.cordis.patch.yml",
     "lib/types/**/*.d.ts"
   ],
   "license": "BSD-3-Clause",

+ 31 - 27
packages/bundle/base/tests/base.spec.ts

@@ -3,12 +3,13 @@
  * field must name a real, parseable patch list.
  */
 
-import { readFileSync } from 'node:fs'
+import { existsSync, readFileSync } from 'node:fs'
 import { fileURLToPath } from 'node:url'
 import { resolve } from 'node:path'
 import { describe, expect, it } from 'vitest'
 import * as yaml from 'js-yaml'
 import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
+import { evaluate } from '@deepseek-ai/cordis-plugin-loader'
 
 describe('dsh-base bundle', () => {
   it('declares a parseable patch list through the dsh.bundle.patch manifest field', () => {
@@ -39,34 +40,37 @@ describe('dsh-base bundle', () => {
     })
   })
 
-  it('ships the Windows platform layer as the confined pwsh roster over the ACL runner chain', () => {
+  it('gates each shell stack by platform with a symmetric disabled expression', () => {
     const root = fileURLToPath(new URL('..', import.meta.url))
     const parsed = yaml.load(
-      readFileSync(resolve(root, 'windows.cordis.patch.yml'), 'utf8'),
+      readFileSync(resolve(root, 'cordis.patch.yml'), 'utf8'),
       { schema: entryListSchema },
-    ) as {
-      id?: string
-      disabled?: boolean
-      insert?: { id?: string; name?: string }[]
-      config?: { policy?: string }
-    }[]
-    const disables = parsed
-      .filter(patch => patch.disabled === true)
-      .map(patch => patch.id)
-    // Only the POSIX bash stack is disabled: the Windows roster confines the
-    // pwsh executor through the ACL runner chain, so the sandbox/policy rows,
-    // the permission switcher, fs-sandbox, and the approval service all stay
-    // enabled exactly as on POSIX — only the shell is swapped.
-    expect(disables).toEqual(['bash-sandbox', 'tool-bash'])
-    const inserted = parsed
-      .flatMap(patch => patch.insert ?? [])
-      .map(row => row.id)
-    expect(inserted).toEqual(['pwsh-sandbox', 'tool-pwsh'])
-    // The patch no longer touches the permission/approval surface at all.
-    expect(parsed.find(patch => patch.id === 'approval')).toBeUndefined()
-    expect(parsed.find(patch => patch.id === 'permission')).toBeUndefined()
-    expect(parsed.find(patch => patch.id === 'sandbox')).toBeUndefined()
-    expect(parsed.find(patch => patch.id === 'sandbox-policy')).toBeUndefined()
-    expect(parsed.find(patch => patch.id === 'fs-sandbox')).toBeUndefined()
+    )
+    if (!Array.isArray(parsed)) throw new TypeError('base patch must parse to a patch list')
+    const rows = parsed.flatMap((patch): Record<string, unknown>[] =>
+      typeof patch === 'object' && patch !== null
+        ? (patch as { insert?: Record<string, unknown>[] }).insert ?? []
+        : [],
+    )
+    // Symmetric gating: each stack's executor and tool rows carry the same
+    // platform fact, inverted between the bash and pwsh twins, so exactly one
+    // shell stack mounts per host. Evaluate with a platform-scoped context
+    // (the `with` scope shadows the global `process`) so both outcomes pin on
+    // every host.
+    for (const [id, win32, linux] of [
+      ['bash-sandbox', true, false],
+      ['tool-bash', true, false],
+      ['pwsh-sandbox', false, true],
+      ['tool-pwsh', false, true],
+    ] as const) {
+      const row = rows.find(candidate => candidate.id === id)
+      if (row === undefined) throw new Error(`base patch must mount ${id}`)
+      const expression = (row.disabled as { __jsExpr?: string } | undefined)?.__jsExpr
+      if (expression === undefined) throw new Error(`${id} must gate on a !!js disabled expression`)
+      expect(Boolean(evaluate({ process: { platform: 'win32' } }, expression)), `${id} on win32`).toBe(win32)
+      expect(Boolean(evaluate({ process: { platform: 'linux' } }, expression)), `${id} on linux`).toBe(linux)
+    }
+    // The platform layer folded into these rows: no separate patch file ships.
+    expect(existsSync(resolve(root, 'windows.cordis.patch.yml'))).toBe(false)
   })
 })

+ 0 - 31
packages/bundle/base/windows.cordis.patch.yml

@@ -1,31 +0,0 @@
-# The dsh-base Windows platform layer: applied by the dsh launcher on win32
-# hosts, between the bundle layers and the user layers. Windows confines
-# through the ACL restricted-token runner (the win32 chain of
-# dsh-sandbox-local → @deepseek-ai/dsh-sandbox-windows-acl), so the shipped
-# stack is the SANDBOXED PowerShell executor plus the full permission
-# surface: sandbox/sandbox-policy enforce the file-effect policy, the
-# permission switcher and the approval service run exactly as on POSIX, and
-# the fs row stays the base's sandboxed provider (fs-sandbox) — mounting
-# dsh-fs-local alongside it would double-register ctx.fs and fail the load.
-# Only the POSIX bash
-# stack (bash-sandbox/tool-bash) is disabled — bash has no Windows runner.
-# 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 and
-# tool-pwsh AND re-enable bash-sandbox and tool-bash — both executor
-# families register the same 'bash' service, so re-enabling the bash rows
-# while pwsh-sandbox stays inserted fails loud at load on a duplicate
-# registration.
-
-- id: bash-sandbox
-  disabled: true
-
-- id: tool-bash
-  disabled: true
-
-- insert:
-    - id: pwsh-sandbox
-      name: '@deepseek-ai/dsh-pwsh-sandbox'
-
-    - id: tool-pwsh
-      name: '@deepseek-ai/dsh-tool-pwsh'

+ 12 - 6
packages/bundle/web-app/cordis.patch.yml

@@ -262,6 +262,9 @@
 - id: tool-bash
   disabled: true
 
+- id: tool-pwsh
+  disabled: true
+
 # The background-task REGISTRY stays on the host plane; only the model-facing
 # `task_*` controls move. Its producers — `tool-bash` here, `tool-pty` and a
 # non-continuable `tool-subagent` elsewhere — are preset rows that resolve it
@@ -374,12 +377,15 @@
   disabled: true
 
 # The preset roster. `config/agent-presets/` ships with the deployment and is
-# read-only (its entries carry `system` trust);
-# `$DSH_HOME/.agent-presets` is where a person — or an agent — authors their own, and
-# carries the same trust as shell access because a preset IS a composition.
-# `roots` is an assembly fact, not user config: the shipped preset directory
-# ships beside this file, so AppCLIEntry resolves it and patches it in — the
-# same treatment `distIndex` gets on the webserver row.
+# read-only (its entries carry `system` trust); `$DSH_HOME/.agent-presets` is
+# where a person — or an agent — authors their own, and carries the same trust
+# as shell access because a preset IS a composition.
+#
+# Only the SHIPPED root is an assembly fact: it sits beside the installed app's
+# own config, so `apps/cli`'s `composeProfile` resolves and patches it in — the
+# same treatment `distIndex` gets on the webserver row. The writable root is
+# `dsh-agent-presets`' own default (`includeUserRoot`), so a composition that
+# never reaches that patch still finds a person's presets.
 - insert:
     - id: agent-presets
       name: '@deepseek-ai/dsh-agent-presets'

+ 3 - 1
packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx

@@ -118,7 +118,9 @@ export function HeroShell({ t, children }: HeroShellProps) {
       <div className={css.stack}>
         <div className={css.headline}>
           {/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
-          <FishLogo size={34} className={css.fish} />
+          <span className={css.fishHitbox}>
+            <FishLogo size={34} className={css.fish} />
+          </span>
           <span className={css.headlineText}>{t('hero.headline')}</span>
           <span className={css.previewBadge}>{t('hero.preview')}</span>
         </div>

+ 31 - 3
packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css

@@ -61,11 +61,39 @@
   white-space: nowrap;
 }
 
-/* figma fish fill rides business blue. */
-.fish {
+/* Keep hover detection on a stationary box while the mark moves within it. */
+.fishHitbox {
   grid-row: 1;
   grid-column: 1;
-  color: var(--dsw-alias-state-business-primary);
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+}
+
+/* Keep the hero mark in the same primary ink as its headline. */
+.fish {
+  color: var(--dsw-alias-label-primary);
+  transform-origin: 50% 60%;
+}
+
+@keyframes hero-fish-swim {
+  0%, 100% {
+    transform: translate(0, 0) rotate(0deg);
+  }
+
+  35% {
+    transform: translate(-1px, -1px) rotate(-5deg);
+  }
+
+  70% {
+    transform: translate(1px, 0) rotate(3deg);
+  }
+}
+
+@media (hover: hover) and (prefers-reduced-motion: no-preference) {
+  .fishHitbox:hover .fish {
+    animation: hero-fish-swim var(--ds-transition-duration-slow) var(--ds-ease-in-out);
+  }
 }
 
 /* Workspace row sits 12px above the input card (figma y80 → y112). The blue

+ 2 - 2
packages/preset/agent-presets/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/preset/agent-presets/README.md
-README.md: 28b9a31ed41e5fc41e38d6b0349c5bd9cbaeed9d
-README.zh.md: 1d8d1481c20005b9e7fed5341aa26dca63dcd815
+README.md: 63bed95d192e6aeff6f484b63bdde711df0f1967
+README.zh.md: 505cb017a3a2439a11e0f3ed1c7a950893f5e7de

+ 14 - 1
packages/preset/agent-presets/README.md

@@ -18,7 +18,8 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal
 - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` The preset one LIVE agent runs on, read from its scope chain rather than from its session — the only answer available for an agent whose durable header is still being built.
 - `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`.
 - `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`.
-- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created at all.
+- `ctx.agentPresets.roots: readonly PresetRoot[]` The roots this roster scans — every configured root in order, then the derived harness-home root. Not `config.roots`: read this to answer whether a roster is composed at all, so one derivation decides it.
+- `ctx.agentPresets.authorable: boolean` Whether any of those roots has `user` trust, and therefore whether a preset can be created at all.
 - `ctx.agentPresets.read(id): Promise<string>` One preset's composition text, exactly as stored.
 - `ctx.agentPresets.copy(from, id, name?): Promise<void>` Create a locally authored preset by copying an existing one's whole directory — the only authoring write. No composition text crosses this seam, so a copy is exactly as loadable as its source; the copied metadata keeps the source's description but never its name or roster order, and `name` (or the id fallback) is what distinguishes the rows.
 - `ctx.agentPresets.remove(id): Promise<void>` Delete a locally authored preset; joined sessions keep their standing mount. Clears the user default when it named the preset just deleted: storing a default that does not exist yet is deliberate, but one this call removed will never be supplied again and would fail every session created without an explicit pick.
@@ -86,9 +87,20 @@ Every read failure degrades to no metadata — absent, malformed, wrongly typed,
 |---|---|---|
 | `default` | required | Preset id mounted when a caller names none |
 | `roots` | `[]` | Scanned directories in precedence order; each supplies `path` (a leading `~` expands) and `trust` (defaults to `user`) |
+| `includeUserRoot` | `true` | Append `<dshHome>/.agent-presets` as a `user` root, after every configured root |
 
 An absent root supplies no presets rather than failing: the user root does not exist until the first locally authored preset, and naming a default no root supplies already fails loud at resolution.
 
+### The writable root is this package's, the shipped root is the app's
+
+`<dshHome>/.agent-presets` is where a person's own presets live, the way `<dshHome>/skills` is where their own skills live ([`dsh-skill-local`](../../skill/skill-local/README.md)), so the roster derives it rather than waiting for a deployment to remember it — a launcher that configures nothing still finds and authors presets. It is appended AFTER every configured root, which keeps an earlier root winning a duplicate id: a shipped `standard` still shadows a home directory that claimed the name, and `copy()` refuses that id rather than landing a preset nothing would resolve.
+
+The roots are resolved once, when the service is constructed. A root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw.
+
+`includeUserRoot: false` mounts a roster over `roots` alone. A deployment that confines presets to its own directories needs it, and so does any test pinning an exact roster — otherwise the machine's real `<dshHome>` decides what the roster contains.
+
+The SHIPPED root stays an assembly fact: it sits beside the installed app's own config, a path only that app can resolve.
+
 ### The default preset is a user setting
 
 When a settings provider is composed, this plugin registers the `agent-presets` namespace with `config.default` as its composition base, so the user document layers over the deployment's engineering default:
@@ -132,6 +144,7 @@ Prefix-stable for the life of an agent: a composition is installed once, before
 
 ## Known Limitations and Deferred Work
 
+- **A preset outside the writable root is discoverable but not deletable** — `remove()` refuses anything that does not live under the FIRST `user` root, so a deployment that configures its own writable root while leaving `includeUserRoot` on lists the harness-home presets, mounts them, and then answers "it does not live under the writable preset root" for every delete. The roster carries one writable root by design; a deployment that wants only its own sets `includeUserRoot: false`.
 - **A preset cannot be changed once a session has produced anything** — `recompose` re-links a BLANK session's parent scope to another standing mount, and only a blank one: switching a composition that already ran would strand tools the model has called. Changing the default affects only sessions created afterwards.
 - **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts.
 - **A superseded generation is never reclaimed** — sessions already joined keep the generation they run on, and the roster holds no join count that could tell when the last one left, so the whole subtree stays mounted until the process ends. The cost is per generation rather than per session, but it is not free: `dsh-skill-local` watches its roots by default, so each edit-then-create cycle adds a live watcher set. Bounded by how often compositions are edited — which the settings-page authoring flow makes a per-save event rather than a per-deploy one. Reclaiming one needs a joined-agent count on the standing mount; see the `TODO` at `ensureStanding`.

+ 14 - 1
packages/preset/agent-presets/README.zh.md

@@ -18,7 +18,8 @@
 - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` 某个**活着的** agent 正在运行的 preset,从其 scope 链读取而不是从其会话读取——对于持久化 header 尚在构建中的 agent,这是唯一能拿到的答案。
 - `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。
 - `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。
-- `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可创建。
+- `ctx.agentPresets.roots: readonly PresetRoot[]` 本 roster 实际扫描的根目录——全部已配置根目录按序在前,随后是推导出的 harness home 根目录。它不是 `config.roots`:判断「是否已组装 roster」应读它,从而由同一处推导决定。
+- `ctx.agentPresets.authorable: boolean` 上述根目录中是否有任一具备 `user` 信任级别,因而 preset 是否可创建。
 - `ctx.agentPresets.read(id): Promise<string>` 某个 preset 的组装文本,与存储内容逐字一致。
 - `ctx.agentPresets.copy(from, id, name?): Promise<void>` 通过整目录复制一个既有 preset 来创建本地创作的 preset——唯一的创作写入。组装文本不经过这道接缝,因此副本与其来源同等可加载;复制出的元数据保留来源的描述、但绝不保留其名称与 roster 排序,`name`(或回退到 id)才是区分两行的依据。
 - `ctx.agentPresets.remove(id): Promise<void>` 删除一个本地创作的 preset;已加入的会话保留其常驻挂载。若用户默认值恰好指向刚删除的 preset 则一并清除:存一个尚不存在的默认值是刻意的,但本次删除的这个再也不会有人提供,留着会让所有未显式指定的新会话无法启动。
@@ -86,9 +87,20 @@ description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agen
 |---|---|---|
 | `default` | 必填 | 调用方未指定时挂载的 preset id |
 | `roots` | `[]` | 按优先级排列的扫描目录;每项提供 `path`(开头的 `~` 会展开)与 `trust`(默认为 `user`) |
+| `includeUserRoot` | `true` | 在全部已配置根目录之后,追加 `<dshHome>/.agent-presets` 作为 `user` 根目录 |
 
 根目录不存在时视为不提供任何 preset,而非失败:用户根目录在写出第一个本地 preset 之前并不存在,而指定了没有任何根目录提供的默认值,在解析时本就会明确报错。
 
+### 可写根目录属于本包,随附根目录属于 app
+
+`<dshHome>/.agent-presets` 是个人自有 preset 的所在,正如 `<dshHome>/skills` 是其自有 skill 的所在([`dsh-skill-local`](../../skill/skill-local/README.md)),因此 roster 自行推导它,而不等某个部署记得配置——一个什么都没配的启动器同样能发现并创作 preset。它追加在全部已配置根目录**之后**,从而保持靠前的根目录赢得重复 id:随附的 `standard` 仍然遮蔽一个占用该名字的家目录目录,而 `copy()` 会拒绝该 id,不会落下一个无人解析得到的 preset。
+
+根目录在服务构造时解析一次。若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。
+
+`includeUserRoot: false` 使 roster 只覆盖 `roots`。把 preset 限制在自有目录内的部署需要它,任何钉住确切 roster 的测试同样需要——否则将由这台机器真实的 `<dshHome>` 决定 roster 的内容。
+
+随附根目录仍然是装配事实:它位于已安装 app 自身配置的旁边,那个路径只有该 app 能解析。
+
 ### 默认 preset 是一项用户设置
 
 当组装中存在 settings 提供方时,本插件会注册 `agent-presets` 命名空间,并以 `config.default` 作为其组装 base,因此用户文档会层叠覆盖部署方的工程默认值:
@@ -132,6 +144,7 @@ Indirectly, through the plugins a standing composition registers, which own ever
 
 ## Known Limitations and Deferred Work
 
+- **位于可写根目录之外的 preset 可被发现却无法删除** —— `remove()` 拒绝任何不在**第一个** `user` 根目录下的 preset,因此一个既配置了自有可写根、又保留 `includeUserRoot` 的部署,会列出并挂载 harness home 下的 preset,却对每次删除回答「它不在可写 preset 根目录之下」。roster 按设计只有一个可写根;只想要自有根的部署应设置 `includeUserRoot: false`。
 - **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。
 - **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。
 - **被替代的代际永不回收** —— 已加入的会话保持其运行所在的代际,而名单没有加入计数可以判断最后一个何时离开,因此整棵子树一直挂到进程结束。代价按代际计而非按会话计,但并非为零:`dsh-skill-local` 默认监听自己的根目录,因此每一轮「编辑后建会话」都会新增一套活的 watcher。上限取决于组装被编辑的频率——而设置页的编写流程把这件事从「每次部署」变成了「每次保存」。要回收就需要给常驻挂载加上已加入 agent 的计数;见 `ensureStanding` 处的 `TODO`。

+ 15 - 0
packages/preset/agent-presets/src/discovery.ts

@@ -25,6 +25,21 @@ import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts'
 /** The composition file that makes a directory a preset. */
 export const COMPOSITION_FILE = 'agent.cordis.yml'
 
+/**
+ * Harness-home directory holding locally authored presets.
+ *
+ * This package owns the writable root the way `dsh-skill-local` owns
+ * `<dshHome>/skills`. An app must assemble the SHIPPED root, whose path only
+ * the installed app can resolve; where a person's own presets go is the same
+ * place in every deployment that does not say otherwise, so a launcher that
+ * forgets to configure one still finds them.
+ *
+ * Package-internal on purpose: no consumer outside this package addresses the
+ * directory by name, and a test that imported it could not catch this value
+ * being wrong — the expected segment is spelled out where it is asserted.
+ */
+export const USER_PRESET_DIR = '.agent-presets'
+
 /**
  * Why `rows` cannot be an entry list, or undefined when it can.
  *

+ 35 - 8
packages/preset/agent-presets/src/index.ts

@@ -28,11 +28,12 @@ import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type
 // Type-only: resolves the `agent/created` lifecycle event this service watches.
 import type {} from '@deepseek-ai/dsh-agent'
 import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings'
-import { discoverPresets } from './discovery.ts'
+import { dshHomePath } from '@deepseek-ai/dsh-paths'
+import { discoverPresets, USER_PRESET_DIR } from './discovery.ts'
 import { copyComposition, deleteComposition, readComposition } from './authoring.ts'
 import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts'
 import { PresetExistsError } from './authoring.ts'
-import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './preset.ts'
+import { PresetMountError, UnknownPresetError, type AgentPreset, type Config, type PresetRoot } from './preset.ts'
 import type {} from './types.ts'
 
 /** Settings namespace carrying the user's chosen default preset. */
@@ -88,8 +89,21 @@ export class AgentPresets extends Service {
       path: z.string().required(),
       trust: z.union(['system', 'user'] as const).default('user'),
     })).default([]),
+    includeUserRoot: z.boolean().default(true),
   }) as z<Config>
 
+  /**
+   * The roots discovery and authoring actually scan: every configured root in
+   * order, then the harness-home user root unless `includeUserRoot` is false.
+   *
+   * Derived once, because a root set that changed between `list()` and the
+   * `copy()` acting on its answer would author into a directory the caller
+   * never saw. Appending rather than prepending keeps an earlier configured
+   * root winning a duplicate id, so a shipped preset still shadows a
+   * locally authored directory that claimed its name.
+   */
+  private readonly resolvedRoots: readonly PresetRoot[]
+
   /**
    * The user layer over `config.default`, present only while a settings
    * provider is composed. Held rather than snapshotted so a hot-reloaded
@@ -116,6 +130,9 @@ export class AgentPresets extends Service {
   constructor(ctx: Context, public config: Config) {
     super(ctx, 'agentPresets')
     this.selfCtx = ctx
+    this.resolvedRoots = config.includeUserRoot
+      ? [...config.roots, { path: dshHomePath(USER_PRESET_DIR), trust: 'user' }]
+      : [...config.roots]
     // Deliberately not `installSettingsSection`: that helper exists to re-judge
     // what a consumer DERIVED from the source — memoized resolutions,
     // registration-level facts — across attach, detach, and change. Nothing
@@ -147,7 +164,7 @@ export class AgentPresets extends Service {
     // does that today — the Web surface mounts in `setup` and children join
     // through `composeFrom` before publication.
     ctx.on('agent/created', ({ agent }) => {
-      if (this.config.roots.length === 0) return
+      if (this.resolvedRoots.length === 0) return
       if (this.composedPreset(agent.ctx) !== undefined) return
       ctx.logger.warn(
         `agent "${agent.id}" was published without joining an agent preset; `
@@ -180,7 +197,7 @@ export class AgentPresets extends Service {
    * @returns the presets, first-root-wins per id.
    */
   async list(): Promise<AgentPreset[]> {
-    return await discoverPresets(this.config.roots)
+    return await discoverPresets(this.resolvedRoots)
   }
 
   /**
@@ -320,9 +337,19 @@ export class AgentPresets extends Service {
     return standingMountFor(agentCtx)?.presetId
   }
 
-  /** Whether this deployment configures a root locally authored presets go to. */
+  /**
+   * The roots this roster scans, which is not `config.roots`: it is every
+   * configured root in order, then the harness-home user root unless
+   * `includeUserRoot` is false. Read this — not the config field — to answer
+   * whether a roster is composed at all, so one derivation decides it.
+   */
+  get roots(): readonly PresetRoot[] {
+    return this.resolvedRoots
+  }
+
+  /** Whether this deployment has a root locally authored presets go to. */
   get authorable(): boolean {
-    return this.config.roots.some(root => root.trust === 'user')
+    return this.resolvedRoots.some(root => root.trust === 'user')
   }
 
   /**
@@ -358,7 +385,7 @@ export class AgentPresets extends Service {
     if ((await this.list()).some(preset => preset.id === id)) {
       throw new PresetExistsError(id)
     }
-    await copyComposition(this.config.roots, source, id, name)
+    await copyComposition(this.resolvedRoots, source, id, name)
     // A settled mount under this id can only be stale (its preset was deleted
     // from disk outside `remove`); the new preset must not inherit it. Every
     // session already joined keeps the generation it runs on regardless.
@@ -371,7 +398,7 @@ export class AgentPresets extends Service {
    * @throws when the preset is unknown or ships with the deployment.
    */
   async remove(id: string): Promise<void> {
-    await deleteComposition(this.config.roots, await this.resolve(id))
+    await deleteComposition(this.resolvedRoots, await this.resolve(id))
     // Sessions on the deleted preset keep their standing mount; only new
     // sessions see the roster without it.
     this.standing.delete(id)

+ 1 - 1
packages/preset/agent-presets/src/invariant.ts

@@ -60,7 +60,7 @@ const install: InvariantInstaller = (ctx, fail) => {
   ctx.on('system-prompt/assemble', (_assembly, context, next) => {
     const presets = ctx.get('agentPresets')
     const agent = context.agent
-    if (presets !== undefined && presets.config.roots.length > 0
+    if (presets !== undefined && presets.roots.length > 0
       && agent !== undefined && presets.composedPreset(agent.ctx) === undefined) {
       fail(
         `agent "${agent.id}" addressed a model without joining any agent preset while a roster is `

+ 5 - 0
packages/preset/agent-presets/src/preset.ts

@@ -54,6 +54,11 @@ export interface Config {
   default: string
   /** Scanned roots in precedence order; an earlier root wins a duplicate id. */
   roots: PresetRoot[]
+  /**
+   * Append the harness home's `USER_PRESET_DIR` as a `user` root, after every
+   * configured root. False mounts a roster over `roots` alone.
+   */
+  includeUserRoot: boolean
 }
 
 /**

+ 7 - 0
packages/preset/agent-presets/tests/authoring.spec.ts

@@ -52,6 +52,10 @@ beforeEach(async () => {
       { path: join(FIXTURES, 'system'), trust: 'system' as const },
       { path: userRoot, trust: 'user' as const },
     ],
+    // Every roster in this file pins its own roots: the derived harness-home
+    // root would add the developer's real presets to what these assertions
+    // count, and `copy` would write into it.
+    includeUserRoot: false,
   })
 })
 
@@ -199,6 +203,7 @@ describe('a deployment with more than one user root', () => {
         { path: userRoot, trust: 'user' as const },
         { path: second, trust: 'user' as const },
       ],
+      includeUserRoot: false,
     })
 
     // Writes go to the first user root, so a preset discovered from a later
@@ -219,6 +224,7 @@ describe('a deployment with no writable root', () => {
     await readOnly.plugin(AgentPresets, {
       default: 'standard',
       roots: [{ path: join(FIXTURES, 'system'), trust: 'system' as const }],
+      includeUserRoot: false,
     })
 
     expect(readOnly.agentPresets.authorable).toBe(false)
@@ -240,6 +246,7 @@ describe('a user root that does not exist yet', () => {
         { path: join(FIXTURES, 'system'), trust: 'system' as const },
         { path: absent, trust: 'user' as const },
       ],
+      includeUserRoot: false,
     })
 
     await fresh.agentPresets.copy('standard', 'mine')

+ 25 - 3
packages/preset/agent-presets/tests/invariant.spec.ts

@@ -11,7 +11,7 @@ import AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent'
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import InvariantService from '@deepseek-ai/dsh-invariants'
 import { describe, expect, it } from 'vitest'
-import AgentPresets, { livePresetMounts } from '@deepseek-ai/dsh-agent-presets'
+import AgentPresets, { livePresetMounts, type Config } from '@deepseek-ai/dsh-agent-presets'
 import * as AgentPresetsInvariant from '@deepseek-ai/dsh-agent-presets/invariant'
 
 const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
@@ -20,7 +20,7 @@ const ROOTS = [
   { path: join(FIXTURES, 'user'), trust: 'user' as const },
 ]
 
-async function harness(): Promise<Context> {
+async function harness(roster: Partial<Config> = {}): Promise<Context> {
   const ctx = new Context()
   ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
   await ctx.plugin(Loader)
@@ -31,7 +31,7 @@ async function harness(): Promise<Context> {
   await ctx.plugin(ToolRegistry)
   await ctx.plugin(AgentRegistry)
   await ctx.plugin(AgentLoop, { agents: [] })
-  await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS })
+  await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeUserRoot: false, ...roster })
   await ctx.plugin(InvariantService)
   await ctx.plugin(AgentPresetsInvariant)
   return ctx
@@ -97,6 +97,28 @@ describe('agent-presets invariants', () => {
       .rejects.toThrow(/without joining any agent preset/)
   })
 
+  it('rejects one just the same when the derived home root is the whole roster', async () => {
+    // The shape this plugin defaults to: an app configures nothing and the
+    // roster is the harness home alone. A roster is a roster however its roots
+    // were resolved, so the fail-loud half must not go quiet here — it read
+    // `config.roots` once, which is empty in exactly this case.
+    const ctx = await harness({ roots: [], includeUserRoot: true })
+    const handle = await ctx.agents.create({ sessionId: SessionId('inv-derived-only') })
+
+    await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent)))
+      .rejects.toThrow(/without joining any agent preset/)
+  })
+
+  it('stays silent for a composition that opted out of every root', async () => {
+    // `includeUserRoot: false` with no configured roots is a deployment that
+    // mounts the roster but keeps its agents on the host plane; there is no
+    // roster to join, so an unjoined agent is not a violation.
+    const ctx = await harness({ roots: [], includeUserRoot: false })
+    const handle = await ctx.agents.create({ sessionId: SessionId('inv-no-roster') })
+
+    await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))).resolves.toBeDefined()
+  })
+
   it('admits a joined agent, a scopeless read, and a standing-key read', async () => {
     const ctx = await harness()
     const handle = await ctx.agents.create({

+ 12 - 10
packages/preset/agent-presets/tests/mount.spec.ts

@@ -38,7 +38,7 @@ const ROOTS = [
  * @param roster - roster config, defaulting to the fixture roots.
  * @returns the booted context.
  */
-async function harness(roster: Config = { default: 'standard', roots: ROOTS }): Promise<Context> {
+async function harness(roster: Config = { default: 'standard', roots: ROOTS, includeUserRoot: false }): Promise<Context> {
   const ctx = new Context()
   ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
   await ctx.plugin(Loader)
@@ -94,7 +94,7 @@ describe('composing an agent from a preset', () => {
       join(presetDir, COMPOSITION_FILE),
       `- id: only\n  name: ${plugin}\n  config:\n    tool: absolute\n`,
     )
-    const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }] })
+    const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }], includeUserRoot: false })
     const imported = vi.spyOn(scoped.loader.internal!, 'import')
 
     await agentOn(scoped, 'sess-absolute-plugin')
@@ -347,7 +347,7 @@ describe('composing from a broken preset', () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-'))
     await mkdir(join(root, 'damaged'))
     await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition)
-    return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }] })
+    return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false })
   }
 
   it('refuses the mount up front with the discovery-reported reason', async () => {
@@ -380,7 +380,7 @@ describe('a roster with nothing in it', () => {
   it('says so instead of naming an empty list of candidates', async () => {
     const bare = new Context()
     await bare.plugin(Loader)
-    await bare.plugin(AgentPresets, { default: 'standard', roots: [] })
+    await bare.plugin(AgentPresets, { default: 'standard', roots: [], includeUserRoot: false })
 
     await expect(bare.agentPresets.resolve())
       .rejects.toThrow(/preset "standard" not found \(available: none\)/)
@@ -418,7 +418,7 @@ describe('the preset file is an input, never a persistence target', () => {
     await scoped.plugin(ToolRegistry)
     await scoped.plugin(AgentRegistry)
     await scoped.plugin(AgentLoop, { agents: [] })
-    await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }] })
+    await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false })
 
     await scoped.agents.create({
       sessionId: SessionId('sess-self-dispose'),
@@ -528,11 +528,13 @@ describe('replacing a composition', () => {
     expect(warnings).toEqual([])
   })
 
-  it('says nothing when the deployment configures no roster at all', async () => {
+  it('says nothing when the composition opts out of every root', async () => {
     // Presets are optional: every surface except the Web bundle keeps its
     // model-facing rows in the host plane, so an agent with a chain of one is
-    // exactly right there and the diagnostic must stay silent.
-    const rosterless = await harness({ default: 'standard', roots: [] })
+    // exactly right there and the diagnostic must stay silent. Opting out is
+    // what makes this rosterless — empty `roots` alone would still derive the
+    // harness-home root, which is a roster like any other.
+    const rosterless = await harness({ default: 'standard', roots: [], includeUserRoot: false })
     const warnings: string[] = []
     rosterless.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof rosterless.logger.warn
 
@@ -581,7 +583,7 @@ describe('replacing a composition', () => {
     await scoped.plugin(ToolRegistry)
     await scoped.plugin(AgentRegistry)
     await scoped.plugin(AgentLoop, { agents: [] })
-    await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }] })
+    await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false })
     const handle = await scoped.agents.create({
       sessionId: SessionId('sess-restore-gone'),
       setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx, 'first'),
@@ -621,7 +623,7 @@ describe('editing a composition file', () => {
     await mkdir(join(root, id))
     const path = join(root, id, COMPOSITION_FILE)
     await writeFile(path, rowFor('before'))
-    const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }] })
+    const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false })
     return { scoped, path }
   }
 

+ 1 - 1
packages/preset/agent-presets/tests/settings.spec.ts

@@ -49,7 +49,7 @@ async function harness(
   await ctx.plugin(AgentLoop, { agents: [] })
   const settingsFiber = ctx.plugin(SettingsLocal, { path: settingsFile, watch: false })
   await settingsFiber
-  await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots] })
+  await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots], includeUserRoot: false })
   return { ctx, settingsFile, settingsFiber }
 }
 

+ 131 - 0
packages/preset/agent-presets/tests/user-root.spec.ts

@@ -0,0 +1,131 @@
+/**
+ * The writable root is this package's own, not an assembly fact each app must
+ * remember: a roster configured with only a `system` root still discovers and
+ * authors into `<dshHome>/.agent-presets`, the way `dsh-skill-local` owns
+ * `<dshHome>/skills`. `includeUserRoot: false` is how a deployment — or a test
+ * pinning an exact roster — opts out.
+ *
+ * `$DSH_HOME` is repointed per test because the derived root is resolved in the
+ * constructor: the plugin must be mounted while the environment names the
+ * temporary home, or it would reach the developer's real one.
+ */
+
+import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
+import { existsSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Include from '@deepseek-ai/cordis-plugin-include'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import AgentPresets, { COMPOSITION_FILE, type Config } from '@deepseek-ai/dsh-agent-presets'
+
+const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
+const SYSTEM_ROOT = join(FIXTURES, 'system')
+/** Spelled out rather than imported: the convention is what these tests assert. */
+const USER_ROOT_SEGMENT = '.agent-presets'
+const VALID = '- id: tool-alpha\n  name: ../../plugins/contribute.js\n  config:\n    tool: alpha\n'
+
+let home: string
+let previousHome: string | undefined
+
+beforeEach(async () => {
+  home = await mkdtemp(join(tmpdir(), 'dsh-preset-home-'))
+  previousHome = process.env.DSH_HOME
+  process.env.DSH_HOME = home
+})
+
+afterEach(() => {
+  if (previousHome === undefined) delete process.env.DSH_HOME
+  else process.env.DSH_HOME = previousHome
+})
+
+/** Boot a roster over the fixture system root, with the derived root left to the plugin. */
+async function roster(config: Partial<Config> = {}): Promise<Context> {
+  const ctx = new Context()
+  ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
+  await ctx.plugin(Loader)
+  ctx.loader.builtins.include = Include
+  await ctx.plugin(AgentPresets, {
+    default: 'standard',
+    roots: [{ path: SYSTEM_ROOT, trust: 'system' as const }],
+    includeUserRoot: true,
+    ...config,
+  })
+  return ctx
+}
+
+/** Hand-place a preset directory under the harness home's preset root. */
+async function seedHomePreset(id: string): Promise<void> {
+  await mkdir(join(home, USER_ROOT_SEGMENT, id), { recursive: true })
+  await writeFile(join(home, USER_ROOT_SEGMENT, id, COMPOSITION_FILE), VALID)
+}
+
+describe('the harness-home preset root', () => {
+  it('is what a roster gets when config names no roots at all', () => {
+    // The schema default is the contract an app relies on by saying nothing;
+    // every other case here passes the field explicitly. The cast stands for
+    // the untyped document the Loader hands the schema, which is where a
+    // composition that omits the key actually comes from.
+    const parsed = AgentPresets.Config({ default: 'standard' } as unknown as Config)
+
+    expect(parsed).toMatchObject({ includeUserRoot: true, roots: [] })
+  })
+
+  it('is discovered without any app configuring it', async () => {
+    await seedHomePreset('mine')
+    const ctx = await roster()
+
+    const listed = await ctx.agentPresets.list()
+
+    expect(listed.find(preset => preset.id === 'mine')).toMatchObject({ trust: 'user' })
+    expect((await ctx.agentPresets.resolve('mine')).path)
+      .toBe(join(home, USER_ROOT_SEGMENT, 'mine', COMPOSITION_FILE))
+  })
+
+  it('makes a roster with only a system root authorable, and receives the copy', async () => {
+    const ctx = await roster()
+
+    expect(ctx.agentPresets.authorable).toBe(true)
+    await ctx.agentPresets.copy('standard', 'copied')
+
+    expect(existsSync(join(home, USER_ROOT_SEGMENT, 'copied', COMPOSITION_FILE))).toBe(true)
+  })
+
+  it('sorts after every configured root, so a shipped id still shadows a home directory', async () => {
+    // `standard` exists in the fixture system root; claiming the name at home
+    // must not take it over, because `copy` refuses an id any root supplies
+    // and a session resolving `standard` must reach the shipped composition.
+    await seedHomePreset('standard')
+    const ctx = await roster()
+
+    expect((await ctx.agentPresets.resolve('standard')).trust).toBe('system')
+    await expect(ctx.agentPresets.copy('standard', 'standard')).rejects.toThrow(/already exists/)
+  })
+
+  it('is absent under includeUserRoot: false, which leaves the roster unauthorable', async () => {
+    await seedHomePreset('mine')
+    const ctx = await roster({ includeUserRoot: false })
+
+    expect((await ctx.agentPresets.list()).map(preset => preset.id)).not.toContain('mine')
+    expect(ctx.agentPresets.authorable).toBe(false)
+    await expect(ctx.agentPresets.copy('standard', 'mine'))
+      .rejects.toThrow(/no user-writable preset root/)
+  })
+
+  it('yields to a configured user root for authoring, which writableRoot takes first', async () => {
+    const explicit = await mkdtemp(join(tmpdir(), 'dsh-preset-explicit-'))
+    const ctx = await roster({
+      roots: [
+        { path: SYSTEM_ROOT, trust: 'system' as const },
+        { path: explicit, trust: 'user' as const },
+      ],
+    })
+
+    await ctx.agentPresets.copy('standard', 'copied')
+
+    expect(existsSync(join(explicit, 'copied', COMPOSITION_FILE))).toBe(true)
+    expect(existsSync(join(home, USER_ROOT_SEGMENT, 'copied'))).toBe(false)
+  })
+})

+ 1 - 1
packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts

@@ -40,7 +40,7 @@ async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter;
   ctx.loader.builtins.include = Include
   await mountAgentLoopTestDependencies(ctx)
   await ctx.plugin(AgentLoop, { agents: [] })
-  await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS })
+  await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeUserRoot: false })
   const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')])
   ctx.llm.registerAdapter(['mock'], adapter)
   const handle = await ctx.agents.create({

+ 2 - 3
scripts/check-workspace-constraints.ts

@@ -131,9 +131,8 @@ function workspaceManifests(): WorkspaceManifest[] {
 }
 
 const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
-  // Profile bundles publish their dsh.bundle.patch layer beside the lib;
-  // dsh-base also ships the win32 shell platform layer the launcher reads.
-  '@deepseek-ai/dsh-base': ['cordis.patch.yml', 'windows.cordis.patch.yml'],
+  // 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'],
   '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'],

+ 2 - 0
scripts/rescope-vendor.ts

@@ -99,6 +99,8 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [
   // the preset a model mounts, so the scoped name would send the model after an
   // id no roster reports.
   { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', upstream: ['cordis'] },
+  // The preset-roster loop names the `cordis` preset id, not a package.
+  { file: 'apps/cli/tests/windows-shell.spec.ts', upstream: ['cordis'] },
   // GROUP_ORDER holds `packages/<group>/` directory names, not package names.
   { file: 'scripts/gen-module-graph.ts', upstream: ['cordis'] },
   { file: 'scripts/gen-doc-graphs.ts', upstream: ['cordis'] },

+ 39 - 0
scripts/verify-cordis-config.spec.ts

@@ -0,0 +1,39 @@
+/**
+ * The verify-cordis-config metadata contract: `disabled` is the one entry
+ * metadata field whose `!!js` expression the Loader interpolates; every other
+ * metadata field must stay static, and a disabled expression must parse.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { metadataExpressionErrors } from './verify-cordis-config.ts'
+
+describe('verify-cordis-config metadata expressions', () => {
+  it('accepts a disabled !!js expression', () => {
+    const problems = metadataExpressionErrors(
+      { id: 'tool-bash', name: '@deepseek-ai/dsh-tool-bash', disabled: { __jsExpr: "process.platform === 'win32'" } },
+      '[0]',
+    )
+    expect(problems).toEqual([])
+  })
+
+  it('rejects an expression in a static metadata field', () => {
+    const problems = metadataExpressionErrors({ id: { __jsExpr: 'process.platform' }, name: 'pkg' }, '[0]')
+    expect(problems).toContain('[0].id: !!js is not interpolated here')
+  })
+
+  it('rejects an expression nested below disabled (only the field itself interpolates)', () => {
+    const problems = metadataExpressionErrors(
+      { id: 'tool-bash', name: 'pkg', disabled: { when: { __jsExpr: 'process.platform' } } },
+      '[0]',
+    )
+    expect(problems).toContain('[0].disabled.when: !!js is not interpolated here')
+  })
+
+  it('rejects a disabled expression that does not parse (the loader would fail the boot)', () => {
+    const problems = metadataExpressionErrors(
+      { id: 'tool-bash', name: 'pkg', disabled: { __jsExpr: 'process.platform ===' } },
+      '[0]',
+    )
+    expect(problems.some(problem => problem.includes('[0].disabled: disabled expression does not parse'))).toBe(true)
+  })
+})

+ 83 - 29
scripts/verify-cordis-config.ts

@@ -1,11 +1,13 @@
 /**
  * Validate Cordis Loader entry metadata and package resolution.
  *
- * The Loader interpolates only a plugin entry's `config`; expression objects in
- * fields such as `disabled` remain truthy data and silently change composition.
- * Example configs and the dsh Web composition resolve named plugins from their
- * owning workspace manifests. Local example packages must also be in the root
- * TypeScript project graph.
+ * The Loader interpolates a plugin entry's `config` (after declared injections
+ * activate, against that plugin context) and the entry `disabled` field (at
+ * every mount decision, against the loader context). Every other entry
+ * metadata field stays static, so an expression there remains truthy data and
+ * silently changes composition. Example configs and the dsh Web composition
+ * resolve named plugins from their owning workspace manifests. Local example
+ * packages must also be in the root TypeScript project graph.
  */
 
 import { globSync, readFileSync } from 'node:fs'
@@ -36,7 +38,7 @@ const appOverlayFiles = new Set([
   'examples/web-schedule/cordis.yml',
   ...globSync('examples/mcp-memory/*.cordis.yml', { cwd: root }),
 ])
-const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
+const metadataFields = ['id', 'name', 'group', 'inject', 'intercept', 'isolate'] as const
 
 /** The adaptive directory-picker chooser package (mounts a backend row at boot). */
 const CHOOSER_PACKAGE = '@deepseek-ai/dsh-host-directory-picker-auto'
@@ -64,33 +66,36 @@ const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
 })
 const schema = yaml.JSON_SCHEMA.extend(jsExprType)
 
-const files = cordisConfigFiles(root)
 const errors: string[] = []
 const pluginReferences: PluginReference[] = []
 
-for (const file of files) {
-  const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
-  if (!isUnknownArray(document)) {
-    errors.push(`${file}: root must be a Loader entry array`)
-    continue
-  }
-  for (let index = 0; index < document.length; index++) {
-    validateEntry(document[index], file, `[${index}]`)
+if (import.meta.main) {
+  const files = cordisConfigFiles(root)
+
+  for (const file of files) {
+    const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
+    if (!isUnknownArray(document)) {
+      errors.push(`${file}: root must be a Loader entry array`)
+      continue
+    }
+    for (let index = 0; index < document.length; index++) {
+      validateEntry(document[index], file, `[${index}]`)
+    }
   }
-}
 
-errors.push(...validateExampleResolution())
-errors.push(...validateAppResolution())
-errors.push(...validateSourcePlaneResolution())
-errors.push(...validatePresetPlaneSeparation())
-errors.push(...validateClientHalvesDeclared())
-
-if (errors.length > 0) {
-  console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:')
-  for (const error of errors) console.error(`- ${error}`)
-  process.exitCode = 1
-} else {
-  console.log(`verify-cordis-config: ${files.length} config files passed.`)
+  errors.push(...validateExampleResolution())
+  errors.push(...validateAppResolution())
+  errors.push(...validateSourcePlaneResolution())
+  errors.push(...validatePresetPlaneSeparation())
+  errors.push(...validateClientHalvesDeclared())
+
+  if (errors.length > 0) {
+    console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:')
+    for (const error of errors) console.error(`- ${error}`)
+    process.exitCode = 1
+  } else {
+    console.log(`verify-cordis-config: ${files.length} config files passed.`)
+  }
 }
 
 /**
@@ -409,11 +414,60 @@ function packageNameFromSpecifier(specifier: string): string | undefined {
 }
 
 function validateMetadata(entry: Record<string, unknown>, file: string, path: string): void {
+  for (const problem of metadataExpressionErrors(entry, path)) {
+    errors.push(`${file}${problem}`)
+  }
+}
+
+/**
+ * Expression-node diagnostics for one entry. `disabled` is the single
+ * interpolated metadata field: its own `!!js` expression node is allowed and
+ * must parse, while expressions nested below it stay truthy data; every other
+ * metadata field must stay fully static.
+ * @param entry - one loader entry (or patch row).
+ * @param path - the entry's diagnostic path prefix.
+ * @returns one diagnostic per offending expression.
+ */
+export function metadataExpressionErrors(entry: Record<string, unknown>, path: string): string[] {
+  const problems: string[] = []
   for (const field of metadataFields) {
     if (!(field in entry)) continue
     const expressionPaths: string[] = []
     collectExpressionPaths(entry[field], `${path}.${field}`, expressionPaths)
-    for (const expressionPath of expressionPaths) errors.push(`${file}${expressionPath}: !!js is not interpolated here`)
+    for (const expressionPath of expressionPaths) problems.push(`${expressionPath}: !!js is not interpolated here`)
+  }
+  const disabled = entry.disabled
+  if (disabled !== undefined) {
+    if (isJsExpr(disabled)) {
+      const detail = disabledExpressionProblem(disabled.__jsExpr)
+      if (detail !== undefined) problems.push(`${path}.disabled${detail}`)
+    } else {
+      // A non-expression value gates on Boolean() at mount; an expression
+      // nested anywhere below it never evaluates, so it must stay literal.
+      const expressionPaths: string[] = []
+      collectExpressionPaths(disabled, `${path}.disabled`, expressionPaths)
+      for (const expressionPath of expressionPaths) problems.push(`${expressionPath}: !!js is not interpolated here`)
+    }
+  }
+  return problems
+}
+
+/**
+ * Parse-only validation of a `disabled` expression: the Loader evaluates it
+ * at every mount decision, and a syntax error would fail the boot — rejecting
+ * it here moves that failure to the earliest resolvable point.
+ * @param expression - the `!!js` expression text.
+ * @returns the diagnostic suffix, or `undefined` when the expression parses.
+ */
+function disabledExpressionProblem(expression: string): string | undefined {
+  try {
+    // Compilation only — the constructor never executes the body.
+    // oxlint-disable-next-line typescript/no-implied-eval
+    new Function(`return (${expression})`)
+    return undefined
+  } catch (error) {
+    const detail = error instanceof Error ? error.message : String(error)
+    return `: disabled expression does not parse: ${detail}`
   }
 }
 

+ 1 - 0
vendor/README.md

@@ -47,6 +47,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
 15. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include declares the `EntryGroup.key` tree-carrier marker (as Group does): its config is entry and patch lists, so interpolation keeps it literal and a `!!js` expression inside a nested row's config resolves lazily in that row's own fiber (Include's own `path` therefore stays literal too). Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`.
 16. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match.
 17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).
+18. **Entry `disabled` interpolation in `loader/src/config/entry.ts`**: a `disabled: !!js` expression evaluates against the loader context at every mount decision; the raw node stays in the options, so write-back keeps the `!!js` form. `disabled` is the only interpolated metadata field. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts` and `apps/cli/tests/windows-shell.spec.ts`.
 
 ## Sync procedure
 

+ 13 - 3
vendor/loader/src/config/entry.ts

@@ -3,7 +3,7 @@ import { deepEqual, isNullable } from '@deepseek-ai/cosmokit'
 import { Loader } from '../index.ts'
 import { EntryGroup } from './group.ts'
 import { EntryTree } from './tree.ts'
-import { evaluate } from './utils.ts'
+import { evaluate, isJsExpr } from './utils.ts'
 
 /** Serialized plugin entry options stored in loader config files. */
 export interface EntryOptions {
@@ -88,15 +88,25 @@ export class Entry {
   private _disabled(options: EntryOptions) {
     // group is always enabled
     if (options.group) return false
-    if (options.disabled) return true
+    if (this.disabledOf(options)) return true
     let entry = this.parent.ctx.fiber.entry
     while (entry) {
-      if (entry.options.disabled) return true
+      if (this.disabledOf(entry.options)) return true
       entry = entry.parent.ctx.fiber.entry
     }
     return false
   }
 
+  /**
+   * Effective disabled state: a `!!js` expression evaluates against the loader
+   * context. The raw node stays in the options, so write-back keeps the form.
+   */
+  private disabledOf(options: EntryOptions): boolean {
+    return isJsExpr(options.disabled)
+      ? Boolean(this.evaluate(options.disabled.__jsExpr))
+      : Boolean(options.disabled)
+  }
+
   evaluate(expr: string) {
     return evaluate(this.ctx, expr)
   }