Просмотр исходного кода

Merge remote-tracking branch 'origin/office/render-cache' into codex/office-decouple-preview

yudshj 1 неделя назад
Родитель
Сommit
8ed7d7fde5
100 измененных файлов с 1300 добавлено и 1067 удалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md
  4. 6 0
      .agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.i18n.yaml
  5. 31 0
      .agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.md
  6. 31 0
      .agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml
  8. 9 64
      .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md
  9. 13 68
      .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md
  10. 0 27
      .agents/notes/implemented/feature/2026-09-14-model-image-input-settings.md
  11. 0 27
      .agents/notes/implemented/feature/2026-09-14-model-image-input-settings.zh.md
  12. 6 0
      .agents/notes/implemented/feature/2026-09-15-sidebar-workspace-hierarchy.i18n.yaml
  13. 31 0
      .agents/notes/implemented/feature/2026-09-15-sidebar-workspace-hierarchy.md
  14. 31 0
      .agents/notes/implemented/feature/2026-09-15-sidebar-workspace-hierarchy.zh.md
  15. 6 0
      .agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.i18n.yaml
  16. 29 0
      .agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.md
  17. 29 0
      .agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.zh.md
  18. 3 3
      .agents/notes/implemented/process/2026-09-16-author-approval-credit.i18n.yaml
  19. 25 0
      .agents/notes/implemented/process/2026-09-16-author-approval-credit.md
  20. 25 0
      .agents/notes/implemented/process/2026-09-16-author-approval-credit.zh.md
  21. 2 2
      .agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml
  22. 3 3
      .agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md
  23. 2 2
      .agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md
  24. 1 1
      .github/review-ownership/README.md
  25. 4 4
      .github/review-ownership/author-weight.mjs
  26. 4 4
      .github/review-ownership/author-weight.test.mjs
  27. 27 12
      .github/review-ownership/check-approval.test.mjs
  28. 0 19
      apps/cli/config/examples/cordis/cordis.yml
  29. 3 0
      apps/cli/src/profile-boot.ts
  30. 14 6
      apps/cli/tests/desktop-host.e2e.ts
  31. 7 62
      apps/cli/tests/profiles/headless/tests/ptc.e2e.ts
  32. 105 0
      apps/cli/tests/profiles/web/tests/creator-plugin-manager.expected.e2e.ts
  33. 59 0
      apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs
  34. 12 4
      apps/cli/tests/web-agent-presets.e2e.ts
  35. 12 1
      apps/desktop-host/src/index.ts
  36. 2 2
      apps/desktop/README.i18n.yaml
  37. 2 0
      apps/desktop/README.md
  38. 2 0
      apps/desktop/README.zh.md
  39. 3 0
      apps/desktop/src/host-process.ts
  40. 1 1
      apps/desktop/src/main.ts
  41. 3 3
      apps/desktop/tests/host-process.spec.ts
  42. 1 0
      apps/desktop/tests/main-startup.spec.ts
  43. 1 0
      apps/web/tests/agent-preset-authoring.e2e.ts
  44. 31 187
      apps/web/tests/cordis-tool-round.e2e.ts
  45. 1 1
      apps/web/tests/expected/agent-preset-authoring/created.expected.md
  46. 1 1
      apps/web/tests/expected/agent-preset-authoring/damaged.expected.md
  47. 1 1
      apps/web/tests/expected/agent-preset-authoring/section.expected.md
  48. 1 1
      apps/web/tests/expected/agent-preset-selection/menu.expected.md
  49. 6 6
      apps/web/tests/expected/cordis-history/ui.expected.md
  50. 84 0
      apps/web/tests/expected/models-settings/catalog-inputs.expected.md
  51. 15 10
      apps/web/tests/expected/models-settings/declared-edit.expected.md
  52. 6 5
      apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md
  53. 6 5
      apps/web/tests/expected/onboarding-deepseek-config/models.expected.md
  54. 13 0
      apps/web/tests/expected/workspace-management/grouping-options.expected.md
  55. 11 0
      apps/web/tests/expected/workspace-management/parent-folders.expected.md
  56. 31 0
      apps/web/tests/model-input-layout.ts
  57. 89 11
      apps/web/tests/models-settings.e2e.ts
  58. 7 5
      apps/web/tests/onboarding-deepseek-config.e2e.ts
  59. 0 13
      apps/web/tests/scaffold.ts
  60. 1 1
      apps/web/tests/schedule-after.e2e.ts
  61. 93 3
      apps/web/tests/workspace-management.e2e.ts
  62. 2 2
      docs/config-catalog.i18n.yaml
  63. 1 1
      docs/config-catalog.md
  64. 1 1
      docs/config-catalog.zh.md
  65. 2 2
      docs/event-producer-consumer.i18n.yaml
  66. 1 1
      docs/event-producer-consumer.md
  67. 1 1
      docs/event-producer-consumer.zh.md
  68. 2 2
      docs/module-graph.i18n.yaml
  69. 5 5
      docs/module-graph.md
  70. 5 5
      docs/module-graph.zh.md
  71. 2 2
      docs/persistence-catalog.i18n.yaml
  72. 11 11
      docs/persistence-catalog.md
  73. 11 11
      docs/persistence-catalog.zh.md
  74. 11 11
      docs/persistence-schema.json
  75. 2 2
      docs/subsystems/llm-streaming.i18n.yaml
  76. 2 0
      docs/subsystems/llm-streaming.md
  77. 2 0
      docs/subsystems/llm-streaming.zh.md
  78. 2 2
      docs/tool-catalog.i18n.yaml
  79. 6 187
      docs/tool-catalog.md
  80. 8 189
      docs/tool-catalog.zh.md
  81. 2 2
      docs/user/develop/practice/dynamic-cordis.i18n.yaml
  82. 8 8
      docs/user/develop/practice/dynamic-cordis.md
  83. 8 8
      docs/user/develop/practice/dynamic-cordis.zh.md
  84. 2 2
      docs/user/guide/providers.i18n.yaml
  85. 11 7
      docs/user/guide/providers.md
  86. 11 7
      docs/user/guide/providers.zh.md
  87. 2 3
      packages/boot/app-boot/src/index.ts
  88. 9 0
      packages/boot/app-boot/src/profile-context.ts
  89. 2 2
      packages/boot/plugin-manager/README.i18n.yaml
  90. 4 2
      packages/boot/plugin-manager/README.md
  91. 4 2
      packages/boot/plugin-manager/README.zh.md
  92. 14 2
      packages/boot/plugin-manager/package.json
  93. 3 2
      packages/boot/plugin-manager/src/index.ts
  94. 12 4
      packages/boot/plugin-manager/src/operations.ts
  95. 13 3
      packages/boot/plugin-manager/src/tools.ts
  96. 27 2
      packages/boot/plugin-manager/tests/manager.spec.ts
  97. 14 0
      packages/boot/plugin-manager/tests/operations.spec.ts
  98. 118 3
      packages/boot/plugin-manager/tests/tools.spec.ts
  99. 9 0
      packages/boot/plugin-manager/tsconfig.json
  100. 2 2
      packages/client/ui-agent-preset/src/client/locales.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md
-2026-09-14-current-profile-plugin-management.md: a59a79022bce88dcee7b1629ec35c9759489e34a
-2026-09-14-current-profile-plugin-management.zh.md: 7c3e8d470fcf87fb32cda8d9f68dd2ffe4b7fe15
+2026-09-14-current-profile-plugin-management.md: 8d4d4c40f033e490f09ebf8d367e1fb621037a6d
+2026-09-14-current-profile-plugin-management.zh.md: a8dce606d00321bd96e643a9ba842581c961d6c1

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md

@@ -16,7 +16,7 @@ Configuration watches use Chokidar write stabilization by default. Its ordinary
 
 Profile files remain the persisted state: entry toggles edit only `disabled` in the last override matching the entry id and any module-name assertion, appending when none matches, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; a failed removal preserves the actual partial state and a diagnostic path, while a failed or cancelled installation restores the profile files it snapshotted.
 
-This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service. Management operations return results to callers without adding messages to live Agents. The agent tool is disabled by default in the base bundle and shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
+This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service. Management operations return results to callers without adding messages to live Agents. The agent tool is enabled in Creator mode and disabled by default in the base bundle and other shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
 
 CLI calls inherit the terminal and authentication environment; service calls retain the subprocess credential scrub and bounded diagnostics. Management records carry error codes and parameters for locale-owned Web presentation. Reconciliation compares entry identity, fiber identity, configuration and diagnostics before and after updating: unchanged inactive entries remain warnings, while newly affected failures reject the operation. Explicit enablement targets must activate.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md

@@ -16,7 +16,7 @@ Web 和 Agent 控件需要修改运行中的 profile,同时避免另建包安
 
 profile 文件保持为持久状态:条目开关只修改最后一条符合条目 id 及模块名称断言的覆盖项中的 `disabled`,没有匹配项时追加,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败的删除保留实际的部分状态与诊断路径,失败或被取消的安装则恢复它快照的 profile 文件。
 
-这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service。管理操作向调用方返回结果,不向存活 Agent 添加消息。base 组合包和内置预设默认禁用该 Agent 工具。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。
+这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service。管理操作向调用方返回结果,不向存活 Agent 添加消息。创造模式启用该 Agent 工具;base 组合包和其他内置预设默认禁用。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。
 
 CLI 调用继承终端和认证环境;service 调用保留子进程凭据清理与有界诊断。管理结果提供错误码和参数,由 Web 词典呈现文案。重载前后比较 entry、fiber、配置与诊断:未变化的已有故障保留为警告,本次影响到的新故障使操作失败。显式启用的目标必须成功激活。
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.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-09-16-creator-persistent-plugin-management.md
+2026-09-16-creator-persistent-plugin-management.md: 04ccbb83fe6d2f5c460f53100a9087cce119ea4d
+2026-09-16-creator-persistent-plugin-management.zh.md: 91afa00cd3f0adb8b21a52ee958bac61bc0912fc

+ 31 - 0
.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.md

@@ -0,0 +1,31 @@
+# Agent Note: Creator mode installs persistent plugin bundles
+
+Status: implemented
+
+English | [中文](2026-09-16-creator-persistent-plugin-management.zh.md)
+
+## Problem
+
+Agents need to install capabilities and use them during the same conversation. Generated-code tools create a second plugin lifecycle alongside ordinary installed bundles.
+
+## Decision
+
+Creator mode enables the existing `plugin_manager` tool. Agents author packages and Loader YAML patches as workspace files, then install them through `install_bundle`. An MCP connection is a configuration-only bundle inserting the installed `dsh-mcp-client`; a UI bundle includes a Host entry and a Client artifact. Profile locking, package installation, enablement and HMR remain owned by the existing manager. The entire management tool requires `danger-full-access` or approval for one call: profile changes can load code with host permissions and affect other sessions. Each execution, including inventory reads, uses the shared sandbox escalation helper and approval service before accessing the manager. Under lower sandbox modes, `ask` requests approval and `never` denies; a full-access session needs no additional approval. A grant does not change session permissions, but its profile changes persist. This keeps shell-equivalent Host access explicit without requiring a permanent session-wide elevation. Human-operated Web and CLI controls retain their existing behavior.
+
+The model sees two read-only Cordis inspection tools. Generated-code define/run/stop/undefine and dynamic self-inspection tool APIs are absent. Existing runtime and Client consumers retain their services; historical session cards remain readable. This supersedes only the model-facing mutation workflow in the [self-referential toolset decision](../feature/2026-07-08-self-referential-cordis-toolset.md); its runtime ownership and sandbox rationale remain independently relevant. The [profile transaction decision](2026-09-14-current-profile-plugin-management.md) continues to govern locking, package installation, and partial failures.
+
+Visual creation requests default to an installed Client plugin rendered in the current Web page unless the user names another destination. The development skill supplies a minimal package and effect-owned Client registration. Discovery ends when the required APIs are known; a working first version is installed before optional visual refinement. Verification uses the connected page where available. Browser authentication or operating-system setup is not a prerequisite for plugin installation, and a mock preview cannot establish an in-app result.
+
+Desktop supplies its bundled pnpm entry and Electron Node invocation through launcher-owned profile facts. Plugin Manager uses that invocation for installation, removal, and registry inspection, retaining the ordinary profile transaction and activation behavior. Its environment applies only to package subprocesses; ordinary Host subprocesses do not inherit the private Node launcher path. CLI profiles retain their configured PATH command.
+
+Historical Cordis sessions declare `retired-tools` coverage at their exact format version, including when it equals the current writer. They are immutable replay inputs; their tool call/result data is compared after persistence and their cards render without registering retired tools. This coverage does not count toward migration coverage. The retained runner write APIs remain for programmatic and browser consumers; removing them requires replacing those consumers together.
+
+## Alternatives considered
+
+Generic entry CRUD and an MCP-specific management API duplicate operations expressible as bundle files plus existing installation and enablement. They are unnecessary for prompt-driven installation. Moving generated-code versioning into Plugin Manager retains two lifecycles without providing ordinary package persistence.
+
+## Consequences
+
+Selected bundles affect all sessions in the profile and survive restart. HMR activates new bundles on live profiles; installed package replacement requires restart. The agent reports saved state separately from activation and verifies the requested capability. Side effects belong to the plugin lifecycle, including stylesheet cleanup.
+
+A built Web profile test installs an MCP bundle, checks existing and new Creator sessions, restarts the process, and verifies tool disposal after bundle removal. A recorded session replays manager enablement of a configured MCP entry followed by an actual local MCP request without model credentials. Historical-card tests retain the removed tools' saved presentation.

+ 31 - 0
.agents/notes/implemented/architecture/2026-09-16-creator-persistent-plugin-management.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: 创造模式安装持久化插件组合包
+
+Status: implemented
+
+[English](2026-09-16-creator-persistent-plugin-management.md) | 中文
+
+## Problem
+
+agent 需要安装能力并在同一段对话中使用。生成代码工具在普通已安装组合包之外引入了第二套插件生命周期。
+
+## Decision
+
+创造模式启用现有的 `plugin_manager` 工具。agent 将包和 Loader YAML patch 写入工作区文件,再通过 `install_bundle` 安装。MCP 连接是插入已安装 `dsh-mcp-client` 的纯配置组合包;UI 组合包包含 Host 入口和 Client 产物。profile 锁、包安装、启停和 HMR 继续由现有管理器负责。 整个管理工具要求 `danger-full-access` 或单次调用的批准:profile 变更能以宿主权限加载代码,并影响其他会话。每次执行(包括查询列表)都会先使用共享沙箱提权函数和审批服务,再访问管理器。在较低沙箱模式下,`ask` 请求审批,`never` 拒绝;完整权限会话无需额外审批。批准不改变会话权限,但本次操作的 profile 变更会持久化。这让与 shell 等价的宿主访问需要明确授权,同时不必永久提升整个会话的权限。由用户直接操作的 Web 和 CLI 控件保持原有行为。
+
+模型可见两个只读 Cordis 检查工具,不再提供生成代码的 define/run/stop/undefine 和动态自省工具 API。现有运行时和 Client 消费者保留其服务;历史会话卡片仍然可读。这仅取代[自引用工具集决策](../feature/2026-07-08-self-referential-cordis-toolset.zh.md)中的模型侧变更流程;其运行时所有权和沙箱依据仍有独立价值。[profile 事务决策](2026-09-14-current-profile-plugin-management.zh.md)继续规定锁、包安装和部分失败行为。
+
+除非用户指定其他目标,视觉创建请求默认通过已安装的 Client 插件显示在当前 Web 页面。开发 skill 提供最小包和由 effect 管理的 Client 注册示例。已知所需 API 后结束探查,在可选视觉优化之前先安装能工作的初版。有条件时使用已连接页面验证。浏览器认证或操作系统设置不是安装插件的前提,mock 预览不能证明应用内结果。
+
+Desktop 通过启动器提供的 profile 信息传入内置 pnpm 入口与 Electron Node 调用方式。Plugin Manager 使用该调用完成安装、移除和 registry 检查,保留通常的 profile 事务与激活行为。其环境仅应用于包管理子进程;普通 Host 子进程不继承私有 Node 启动器路径。CLI profile 保留其配置的 PATH 命令。
+
+历史 Cordis 会话以准确的格式版本声明 `retired-tools` 覆盖,即使该版本等于当前 writer 也如此。它们是不可改写的重放输入;持久化后的工具调用及结果数据会进行比较,卡片渲染不注册已移除工具。此覆盖不计入迁移覆盖。保留的 runner 写入 API 供程序和浏览器消费者使用;移除时必须一起替换这些消费者。
+
+## Alternatives considered
+
+通用条目增删改查和 MCP 专用管理 API 重复了组合包文件及现有安装、启停操作能够表达的能力,提示驱动的安装不需要这些接口。将生成代码的版本管理搬入 Plugin Manager 会保留两套生命周期,且无法提供普通包的持久化方式。
+
+## Consequences
+
+已选择的组合包影响 profile 中的所有会话,并在重启后保留。HMR 在实时 profile 中激活新组合包;替换已安装的包需要重启。agent 分别报告保存状态和激活结果,并验证所需能力。副作用归属于插件生命周期,包括样式表清理。
+
+构建后的 Web profile 测试安装 MCP 组合包,检查现有和新建创造模式会话,重启进程,并验证移除组合包后的工具释放。录制会话无需模型凭据即可回放管理器启用已配置 MCP 条目及后续真实本地 MCP 请求。历史卡片测试保留已移除工具的已保存展示。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.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-08-self-referential-cordis-toolset.md
-2026-07-08-self-referential-cordis-toolset.md: d8ae7d26666964c24245021249473d4160b35220
-2026-07-08-self-referential-cordis-toolset.zh.md: d6fac5b59d1d057ea11b607cba115ceb1c37664a
+2026-07-08-self-referential-cordis-toolset.md: 278c71051de1fb897c8735b39dff0b66c43a5626
+2026-07-08-self-referential-cordis-toolset.zh.md: 8a8e197e197fb6b53b52426d5ad9a33fd09e78c9

+ 9 - 64
.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md

@@ -1,4 +1,4 @@
-# Agent Note: The self-referential cordis toolset
+# Agent Note: Cordis runtime inspection and runner isolation
 
 Status: implemented
 
@@ -6,79 +6,24 @@ English | [中文](2026-07-08-self-referential-cordis-toolset.zh.md)
 
 ## Problem
 
-Everything in this harness is a cordis plugin, but the agent running inside that plugin runtime cannot see or touch it: it cannot enumerate the services and events around it, cannot extend itself with a new tool mid-session, and cannot compose capabilities it invents. Handing the model that power is worth exploring — a self-referential agent that inspects and modifies its own runtime — but it raises three correctness problems at once, and the design is about answering them rather than the raw "let the model run code" mechanic.
-
-First, model-written registration must be validated where it happens: a malformed tool schema has to fail at registration, not when a later request tries to assemble it into a prompt. Second, model-written code has to call service APIs whose source it has never seen — guessed method signatures and, worse, guessed return-value shapes cost many steps of blind probing. Third, everything the model mounts must be fully disposable, by the model on demand and by the ordinary plugin lifecycle when the host plugin reloads, or a long session accretes orphaned listeners and tools.
+Runtime API discovery must describe the APIs a plugin can actually call. Process-local generated definitions also need registration validation and complete effect disposal; isolating JavaScript globals alone does not constrain the authority of injected services.
 
 ## Decision
 
-The toolset ships as [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/extensions/tool-cordis/README.md), with its runnable overlay and usage in the [runtime Cordis guide](../../../../docs/user/develop/practice/dynamic-cordis.md). It gives the model three tools over the live Cordis runtime in the current DSH process: inspect it, mount an in-memory temporary Plugin, and unmount that Plugin to quiescence.
-
-The vm isolates accidental global pollution, and the context façade hides framework internals. Neither restricts the authority of exposed services: a temporary Plugin can call `ctx.shell` with the host executor's privileges and reach the real filesystem and web services. It runs in the shared DSH runtime and may affect other sessions in that process. This is an opt-in development tool with bash-equivalent trust, not a security boundary or product default.
-
-### The three tools
-
-| Tool | Contract |
-|---|---|
-| `cordis_inspect` | Read-only report over the live current-process runtime, one Markdown section per `what` value (omit `what` for all sections). `plugins` lists every live fiber; `temporary` lists only the temporary Plugins created by `cordis_mount`. An exact `name` with `what: "api"` or `what: "events"` narrows to one source-documented target. |
-| `cordis_mount` | Evaluates `code` now as an async JavaScript-function body in a `node:vm` sandbox and saves it nowhere. The returned Plugin is mounted under the internal `cordis-dynamic` group and tracked under a fresh process-local id (`dyn-1`, `dyn-2`, …). |
-| `cordis_unmount` | Unmounts one `cordis_mount` temporary Plugin by id and returns only after every owned tool, listener, service, timer, and effect reaches quiescence. It cannot remove Loader, configured, or installed Plugins. |
-
-`cordis_inspect` sections are `services` (every provided ctx service and owning fiber), `plugins` (every live plugin fiber), `tools` (what the model can call), `temporary` (the `cordis_mount` subset with id, running/pending state, provided and awaited services, and lifetime), `api` (live service signatures and referenced types), and `events` (harness events with dispatch mode and signature). Temporary Plugins remain active across later turns and disappear after `cordis_unmount`, toolset unload, or DSH restart; they are never restored automatically. Broad `api` and `events` reports omit full JSDoc to stay compact; an exact `name` returns one service or event with its original method/declaration JSDoc. A name is invalid with other sections, unknown targets fail, and an API target must be live. The model-facing tool descriptions carry the operational rules needed at call time; [the generated tool catalog](../../../../docs/tool-catalog.md) is their exhaustive rendering.
-
-### Sandbox semantics
-
-Mount code runs as an async-function body in a fresh vm realm. Its documented API steers file, network, process, and timer access through Cordis services so mounts remain inspectable and disposable. Host-realm helpers still make Node escape possible, consistent with the trusted posture. `vmTimeoutMs` bounds only synchronous evaluation.
-
-Sandbox globals are deliberately small: a tagged write-through `console` (`[cordis:<id>] …` on the host stdout/stderr, so a listener that fires long after the mount call still lands somewhere the user sees), the `harness.defineTool` / `harness.registerTool` registration pair, the encoding primitives fresh vm contexts lack (`btoa`/`atob` as host closures over `Buffer` — a sanctioned exception, `Buffer` itself is never exposed — plus `TextEncoder`/`TextDecoder`), and callable traps over the withheld Node APIs (`require`, `setTimeout`/`setInterval`/`setImmediate`/`clearTimeout`/`clearInterval`, `fetch`) that throw a redirect naming the cordis alternative. Only function-shaped globals are trapped; `process` and `Buffer` stay `undefined` so a `typeof` feature probe stays inert rather than detonating a throwing accessor.
-
-Mount code crosses the vm boundary through three controls. Dual-realm `instanceof` recognizes both host and vm objects. `harness.defineTool` rebuilds the output schema/projectors in the host realm, snapshots the body value as host-owned JSON, and lets the registry enforce the [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) before observation. The mounted plugin receives a whitelist context façade, not a raw or pass-through `Context`; framework plumbing and context-valued returns are rejected. Service reads require a declared `inject`, preserving Cordis activation and unload semantics. `ctx.tools.get` exposes only the schema view, so mounted code cannot bypass `ToolRuntime.execute` by calling a definition directly.
-
-The boundary normalizes unambiguous JSON-Schema forms into `ParameterSchemaSpec`, preserving `integer`, raw object openness, and required arrays. Direct DSL object nodes must declare `additionalProperties`; invalid vocabulary fails with the accepted alternatives. Parse, TypeScript, missing-return, Node-API, and duplicate-tool errors include the relevant source line or corrective contract without narrating implementation internals.
-
-### The internal group and temporary-Plugin lifecycle
+`cordis_inspect_list` and `cordis_inspect_query` expose read-only runtime discovery. Generated catalogs retain source-owned declarations and JSDoc, intersected with live providers. The catalog generator rejects freshness drift; detailed queries avoid charging every request for complete API declarations.
 
-Every temporary Plugin is a child of one internal `cordis-dynamic` group beneath the tool plugin, so ordinary fiber disposal handles toolset reload and unload. `cordis_mount` awaits settlement; startup failure disposes the fiber before returning an error. A settled pending Plugin remains visible with its missing injections. `cordis_unmount` awaits the Plugin fiber's disposal.
+The Host and Client runners retain their programmatic lifecycle and browser consumers. A Host definition evaluates in a fresh vm realm and receives a context façade: service access requires declared injection, framework internals are hidden, and registrations belong to the definition's fiber. Tool output normalization crosses back into the Host realm before validation. Disposal awaits the fiber's owned effects. The vm prevents accidental global pollution; injected filesystem, shell, and network services still have real authority, so it is not a security boundary.
 
-Temporary Plugins exist only in process memory. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and have no automatic save, promote, or install path. Keeping an experiment means asking the Agent to implement a normal project Plugin or installable profile bundle through the regular development workflow.
-
-### Cross-mount composition via provide/inject
-
-Mounts relate to each other through ordinary cordis service semantics, with their ids as the lifecycle handles: mount A calls `ctx.provide('foo', value)`, mount B declares `inject: ['foo']` and activates the moment `foo` exists; mounted first, B stays pending and names the missing service; unmounting A sends B back to pending (its registrations unwound) and a later re-provide re-runs B's `apply` through a fresh sandbox façade; a duplicate provide fails loud with the owning fiber named. One realm caveat: a service value provided by a mount is a vm-realm object — method calls on it work from anywhere, but consumers must not assume host prototypes on it.
-
-### The generated API catalog
-
-`cordis_inspect` serves API and event data from a generated catalog rather than a duplicated table. The generator reuses the Cordis catalog AST scan and emits service summaries, signatures, original service-method and event JSDoc, event modes, referenced type declarations, and the inherited context API. Ambiguous type names are omitted and oversized declarations are marked as truncated.
-
-Freshness is gated like every generated artifact: `pnpm run verify-cordis-api` (in `doc-sync`) regenerates in memory and fails on any diff, so a JSDoc or public-signature edit cannot ship without regenerating the catalog the model reads. At runtime the inspect tool intersects the catalog with the live runtime rather than dumping it: broad reports render live catalogued services as summary + signatures, live services without a catalog entry (mount-provided ones) as name + owning fiber, catalogued services with no live provider tersely, and then the referenced type shapes. Exact-name reports render one live service or event with the original JSDoc immediately before each signature; keeping that detail opt-in avoids charging its token cost on exploratory listings.
-
-### Configuration, rendering, and observability
-
-The plugin exposes one config field, validated by schemastery and documented in [the config catalog](../../../../docs/config-catalog.md): `vmTimeoutMs` (default 5000), the millisecond bound on the synchronous portion of code evaluation. The current model-facing names are `cordis_inspect`, `cordis_mount`, and `cordis_unmount`; the internal `cordis-dynamic` group name and `dyn-` id prefix remain structural vocabulary. All three tools render as `generic` cards per [the tool cookbook](../../../../docs/cookbook/adding-a-tool.md): inspect is `read`, mount is `execute` carrying code as `rawInput`, and unmount is `delete`. Web conversation rows preserve those generic mechanics while giving the tools the action titles `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` plus one shared Cordis accent; the mount row retains the shared JavaScript expansion and syntax highlighting.
-
-Model-visible ⟺ logged holds with no new session event type: mount and unmount are visible through their logged `tool/call` / `tool/result` pairs, and any changed tool set is logged by the full changed request header emitted when schemas change between steps. Temporary Plugins are process memory, not session state: session resume rehydrates conversation history but never recreates them.
+Definitions remain process-local. Restart and session resume do not recreate them from historical calls. The [Creator persistent plugin decision](../architecture/2026-09-16-creator-persistent-plugin-management.md) owns agent-authored installation, approval, and profile persistence. Shipped model tools do not create or mutate runner definitions.
 
 ## Alternatives considered
 
-**A structured per-capability registration tool instead of `cordis_mount`.** The most tempting alternative is a `cordis_register_tool` with explicit `name` / `description` / `parameters` / `code` fields (and siblings `cordis_register_listener`, `cordis_register_service`, …) rather than a single "mount a plugin" primitive. It was rejected because its one real win — no plugin boilerplate for the single commonest case — does not pay for its costs, while a single mount primitive answers every capability at once.
-
-| Dimension | Structured per-capability tools | Single `cordis_mount` |
-|---|---|---|
-| Schema correctness | `parameters` is still model-written JSON needing unified-schema validation, merely one step earlier | The same validation runs at the sandbox boundary, with the same instructive errors |
-| The code field | An `execute` body is still model-written JS in a vm; the realm and service-call correctness problems are unchanged | One sandbox, one normalization path, one guarded registration |
-| Capability coverage | Tools only; listeners, services, `inject` relations each need another structured tool — an API that grows without bound | One vocabulary (a cordis plugin) covers every effect, present and future |
-| Cross-mount composition | Not expressible in a tool-registration payload | Native `provide`/`inject`, ordinary cordis semantics |
-| Inspectability | Registers something the plugin list cannot show as a plugin | What the model mounts is exactly what `cordis_inspect` renders |
-| Model ergonomics | Wins for the single most common case (no plugin boilerplate) | Mitigated by the canonical recipe in the mount description plus boundary errors that teach the fix |
-
-The correctness investment therefore goes where it pays for every capability at once: the generated API catalog surfaced through `cordis_inspect`, and sandbox-boundary validation whose error messages teach the correct call. A structured registration tool remains addable later as sugar that synthesizes mount code; nothing here forecloses it.
-
-**A hand-maintained service/event reference in the tool.** The first cut of the inspect tool carried a hand-written table of service method signatures. It was replaced by the generated `api-catalog.ts` because a hand table drifts from the JSDoc the moment a signature changes and nothing gates the drift, whereas the generated artifact is freshness-checked against the same AST the docs use.
+**Hand-maintained API tables.** Rejected because they drift independently from service declarations; generated catalogs and freshness checks share one source.
 
-**A new `cordis/mount` session event.** A durable event recording each mount's source and name has clear precedent (`hook/invoked`, `compaction/start`). Rejected: mount and unmount are already visible as `tool/call` / `tool/result` pairs and the tool-set change is already logged as a full changed request header, so a dedicated event would only duplicate the record. It remains addable if an audit use case needs the mount source and name outside the tool call.
+**Treat the vm as a security sandbox.** Rejected because services exposed through the façade can reach the Host's real resources. Restricted globals improve lifecycle correctness, not permission enforcement.
 
-**A hardened / capability-restricted sandbox.** Trapping Node built-ins and handing mount code a whitelist façade rather than the raw context might suggest an intent to sandbox for safety. It is explicitly not that: the traps and the façade narrow the *API* mount code sees — steering it onto cordis services and away from leak-prone Node built-ins and framework internals — for correctness and to close the unguarded-context escape, but the capabilities the façade exposes (`ctx.shell`, `ctx.fs`, `ctx.web`) reach the real runtime, so it is not a security boundary. A real one (separate process, permission prompts) was out of scope for a dev/opt-in toolset and would fight the entire point — handing the model the live runtime.
+**Recreate definitions from session logs.** Rejected because replaying source would execute historical side effects. Historical cards display persisted source and outcomes without restoring a live definition.
 
 ## Consequences
 
-The toolset is a deliberate opt-in with a fully-privileged `ctx`, so a deployment adopts it as consciously as a bash tool. Several facts follow that the tool descriptions warn the model about directly: a waterfall listener (e.g. `tools/pre-execute`) that returns without calling `next()` short-circuits the chain, so a mounted listener can stop the agent's own tool dispatch ([waterfall semantics](../../../../docs/cordis-primer.md#cordis-waterfall-semantics)); mount code runs inside a tool call of the current turn, so awaiting anything that resolves only after the turn deadlocks; `vmTimeoutMs` bounds synchronous evaluation only; and mounts do not survive session resume.
+Inspection remains usable without Creator or Plugin Manager. Runner lifecycle tests cover guarded registration, startup failure cleanup, and awaited disposal. Existing browser consumers retain their lifecycle APIs; removing their write-side registry and activation machinery requires a coordinated replacement of those consumers, rather than deleting historical rendering or assuming session data recreates live state.

+ 13 - 68
.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md

@@ -1,84 +1,29 @@
-# Agent Note: 自引用 cordis 工具集
+# Agent Note:Cordis 运行时检查与 runner 隔离
 
 Status: implemented
 
 [English](2026-07-08-self-referential-cordis-toolset.md) | 中文
 
-## 问题
+## Problem
 
-本 harness 中的一切都是 cordis 插件,但运行在该插件运行时内部的 agent(智能体)既看不到也碰不到它:它无法枚举周围的服务和事件,无法在会话中途为自己添加新工具,也无法组合自己发明的能力。赋予模型这种能力值得探索——一个能审视并修改自身运行时的自引用 agent——但这同时引发三个正确性问题,本设计的核心正是回答这些问题,而非单纯的「让模型执行代码」机制
+运行时 API 发现必须描述插件实际能调用的 API。进程内生成定义还需要注册校验和完整的 effect 释放;仅隔离 JavaScript 全局变量并不能限制注入服务的权限
 
-第一,模型编写的注册必须在注册发生时就完成校验:格式错误的工具 schema 必须在注册时失败,而不是等到后续请求尝试将其组装进提示词时才报错。第二,模型编写的代码需要调用它从未见过源码的服务 API——靠猜测方法签名、更糟糕的是猜测返回值结构,会消耗大量盲目试探的步骤。第三,模型挂载的一切都必须完全可释放:模型可以按需释放,普通的插件生命周期在宿主插件重载时也会释放,否则长会话会积累遗留的监听器和工具。
+## Decision
 
-## 决策
+`cordis_inspect_list` 和 `cordis_inspect_query` 提供只读运行时发现。生成目录保留源码中的声明和 JSDoc,并按存活 provider 筛选。目录生成器拒绝过期产物;精确查询避免每次请求都承担完整 API 声明的 token 成本。
 
-该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/extensions/tool-cordis/README.zh.md) 发布,其可运行 overlay 与用法位于[运行时 Cordis 指南](../../../../docs/user/develop/practice/dynamic-cordis.zh.md)。它为模型提供三个工具,用于操作当前 DSH 进程中的活跃 Cordis 运行时:检查该运行时、挂载一个仅存于内存的临时插件,再将该插件卸载至完全停稳
+Host 和 Client runner 保留程序侧生命周期和浏览器消费者。Host 定义在新的 vm realm 中求值,并接收 context façade:服务访问需要声明注入,框架内部结构不可见,注册归定义的 fiber 所有。工具输出先回到 Host realm 进行归一化,再接受校验。释放会等待 fiber 所有的 effect。vm 防止意外污染全局变量;注入的文件系统、shell 和网络服务仍有真实权限,因此它不是安全边界
 
-vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节。但二者都不限制已暴露服务的权限:临时插件可以调用 `ctx.shell` 以宿主执行器的权限运行命令,也能访问真实的文件系统和网络服务。它运行在共享 DSH 运行时中,可能影响同一进程的其他会话。这是一个需要显式启用的开发工具,信任等级与 bash 相当,不是安全边界,也不是产品默认配置
+定义仅存在于进程中。重启和会话恢复不会从历史调用重建它们。[Creator 持久化插件决策](../architecture/2026-09-16-creator-persistent-plugin-management.zh.md)负责 agent 安装、审批和 profile 持久化。内置模型工具不创建或修改 runner 定义
 
-### 三个工具
+## Alternatives considered
 
-| 工具 | 约定 |
-|---|---|
-| `cordis_inspect` | 当前进程活跃运行时的只读报告,每个 `what` 值对应一个 Markdown 小节(省略 `what` 则输出全部小节)。`plugins` 列出全部存活 fiber,`temporary` 只列 `cordis_mount` 创建的临时插件。精确 `name` 搭配 `what: "api"` 或 `what: "events"` 可收窄到一个带源码文档的目标。 |
-| `cordis_mount` | 立即在 `node:vm` 沙箱中把 `code` 作为异步 JavaScript 函数体求值,且不保存到任何位置。返回的插件挂在内部 `cordis-dynamic` 分组下,并用新的进程内 id(`dyn-1`、`dyn-2`……)跟踪。 |
-| `cordis_unmount` | 按 id 卸载一个 `cordis_mount` 临时插件,并只在其自有工具、监听器、服务、定时器和其他 effect 完全停稳后返回。它不能删除 Loader、已配置或已安装的插件。 |
+**手写 API 表。** 拒绝,因为它会独立于服务声明漂移;生成目录与新鲜度检查共享同一源码。
 
-`cordis_inspect` 的小节是 `services`(每个已提供的 ctx 服务及所属 fiber)、`plugins`(全部存活插件 fiber)、`tools`(模型可调用的工具)、`temporary`(`cordis_mount` 子集,包含 id、running/pending 状态、提供与等待的服务和生命周期)、`api`(活跃服务签名及其引用类型)和 `events`(harness 事件及分发模式和签名)。临时插件可跨后续轮次保持活跃,并在 `cordis_unmount`、工具集卸载或 DSH 重启后消失;系统绝不会自动恢复它们。宽泛的 `api` 和 `events` 报告省略完整 JSDoc;精确 `name` 返回一个服务或事件及其原始 JSDoc。其他小节不能搭配 name,未知目标会失败,而 API 目标必须处于活跃状态。面向模型的工具描述包含调用时所需的操作规则;[生成的工具目录](../../../../docs/tool-catalog.zh.md)是这些规则的完整呈现
+**将 vm 视为安全沙箱。** 拒绝,因为 façade 暴露的服务可以访问 Host 的真实资源。受限全局变量改善生命周期正确性,不执行权限控制。
 
-### 沙箱语义
+**从会话日志重建定义。** 拒绝,因为重放源码会执行历史副作用。历史卡片展示持久化源码和结果,不恢复运行中的定义。
 
-挂载代码以异步函数体的形式在一个新的 vm realm 中运行。其文档化的 API 将文件、网络、进程和定时器访问引导至 Cordis 服务,使挂载保持可审视和可释放。宿主 realm 的辅助手段仍然使 Node 逃逸成为可能,这与信任姿态一致。`vmTimeoutMs` 仅约束同步执行部分。
+## Consequences
 
-沙箱全局变量刻意精简:一个带标签的直写 `console`(在宿主 stdout/stderr 上输出 `[cordis:<id>] …`,这样在挂载调用之后很久才触发的监听器输出仍能落到用户可见的地方)、`harness.defineTool`/`harness.registerTool` 注册对、新 vm 上下文缺少的编码原语(`btoa`/`atob` 作为基于 `Buffer` 的宿主闭包——这是一个明确允许的例外,`Buffer` 本身从不暴露——加上 `TextEncoder`/`TextDecoder`),以及对未暴露的 Node API 设置的可调用陷阱(`require`、`setTimeout`/`setInterval`/`setImmediate`/`clearTimeout`/`clearInterval`、`fetch`),这些陷阱会抛出一条重定向消息指明 cordis 替代方案。只有函数形态的全局变量才设陷阱;`process` 和 `Buffer` 保持 `undefined`,这样 `typeof` 特性探测仍然无害,而不会触发会抛出异常的访问器。
-
-挂载代码通过三道控制跨越 vm 边界。双 realm `instanceof` 同时识别宿主和 vm 对象。`harness.defineTool` 在宿主 realm 中重建输出 schema/投影器,将工具体返回值快照为宿主自有的 JSON,并让注册表在观测前强制执行[规范工具输出约定](../architecture/2026-07-20-canonical-tool-output-contract.zh.md)。挂载的插件接收的是一个白名单上下文门面,而非原始或透传的 `Context`;框架内部机制和以上下文为值的返回会被拒绝。服务读取需要声明 `inject`,保留 Cordis 的激活与卸载语义。`ctx.tools.get` 仅暴露 schema 视图,因此挂载代码无法绕过 `ToolRuntime.execute` 直接调用定义。
-
-边界将无歧义的 JSON Schema 形式规范化为 `ParameterSchemaSpec`,同时保留 `integer`、原始对象开放性和 required 数组。直接使用 DSL 的对象节点必须声明 `additionalProperties`;无效词汇会报错并给出可接受的替代方案。解析错误、TypeScript 错误、缺少 return、Node API 误用和重复工具名等错误信息包含相关源码行或纠正性约定,不叙述实现内部细节。
-
-### 内部分组与临时插件生命周期
-
-每个临时插件都是工具插件下方内部 `cordis-dynamic` 分组的子节点,因此普通的 fiber 释放即可处理工具集重载和卸载。`cordis_mount` 会等待 settlement;启动失败时在返回错误前释放 fiber。已 settle 但处于 pending 状态的插件仍然可见,并列出其缺失的注入。`cordis_unmount` 等待插件 fiber 的释放完成。
-
-临时 Plugin 只存在于进程内存中。它不会创建 Plugin 文件、安装 package、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不存在自动保存、转正式或安装路径。若要保留实验结果,应让 Agent 通过常规开发流程实现普通的项目 Plugin 或可安装的 profile 组合包。
-
-### 通过 provide/inject 实现跨挂载组合
-
-挂载之间通过普通的 cordis 服务语义相互关联,以各自的 id 作为生命周期句柄:挂载 A 调用 `ctx.provide('foo', value)`,挂载 B 声明 `inject: ['foo']` 并在 `foo` 存在的瞬间激活;如果 B 先挂载,它保持 pending 状态并列出缺失的服务;卸载 A 使 B 回到 pending(其注册被撤销),之后重新 provide 会通过一个新的沙箱门面重新运行 B 的 `apply`;重复 provide 会明确报错并指出拥有该服务的 fiber。一个 realm 注意事项:由挂载 provide 的服务值是 vm realm 对象——从任何地方调用其方法都能工作,但消费方不得假设它具有宿主原型。
-
-### 生成的 API 目录
-
-`cordis_inspect` 从生成的目录提供 API 和事件数据,而非维护一份重复的表格。生成器复用 Cordis 目录的 AST 扫描,输出服务摘要、签名、原始服务方法与事件 JSDoc、事件模式、引用的类型声明以及继承的上下文 API。有歧义的类型名被省略,过大的声明被标记为截断。
-
-新鲜度像所有生成产物一样受门禁约束:`pnpm run verify-cordis-api`(在 `doc-sync` 中)在内存中重新生成并在有任何 diff 时失败,因此 JSDoc 或公开签名变更如果不重新生成模型读取的目录就无法合入。运行时 inspect 工具将目录与活跃运行时取交集而非直接转储:宽泛报告把有目录条目的活跃服务渲染为摘要 + 签名,把没有目录条目的活跃服务(挂载提供的)渲染为名称 + 所属 fiber,简要列出有目录条目但无活跃提供方的服务,再附上引用的类型结构。精确名称报告渲染一个活跃服务或事件,并把原始 JSDoc 紧靠在每个签名之前;让该细节按需出现,避免探索性列表承担其 token 成本。
-
-### 配置、渲染与可观测性
-
-该插件暴露一个配置字段,由 schemastery 校验并记录在[配置目录](../../../../docs/config-catalog.zh.md)中:`vmTimeoutMs`(默认 5000),代码同步求值部分的毫秒上限。当前面向模型的名称是 `cordis_inspect`、`cordis_mount` 和 `cordis_unmount`;内部 `cordis-dynamic` 分组名和 `dyn-` id 前缀仍是结构性词汇。三个工具均按[工具实操手册](../../../../docs/cookbook/adding-a-tool.zh.md)渲染为 `generic` 卡片:inspect 为 `read`,mount 为携带代码 `rawInput` 的 `execute`,unmount 为 `delete`。Web 对话行保留这些通用机制,同时为各工具设置操作标题 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin` 以及统一的 Cordis 强调色;mount 行仍使用共用的 JavaScript 展开视图和语法高亮。
-
-「模型可见 ⟺ 已记录」成立,且无需新的会话事件类型:mount 与 unmount 通过已记录的 `tool/call`/`tool/result` 对可见,当步骤之间的 schema 发生变化时,系统发出的完整 request header 会记录工具集的任何变化。临时插件属于进程内存,而非会话状态:恢复持久化会话只会重建对话历史,绝不会重新创建它们。
-
-## 曾考虑的替代方案
-
-**用结构化的逐能力注册工具替代 `cordis_mount`。** 最具吸引力的替代方案是一个带有显式 `name`/`description`/`parameters`/`code` 字段的 `cordis_register_tool`(以及配套工具 `cordis_register_listener`、`cordis_register_service`……),而非单一的「挂载一个插件」原语。否决原因:它唯一的真正优势——对最常见的单一场景免去插件样板代码——不足以抵偿其代价,而单一的 mount 原语能一次性覆盖所有能力。
-
-| 维度 | 结构化逐能力工具 | 单一 `cordis_mount` |
-|---|---|---|
-| schema 正确性 | `parameters` 仍然是模型编写的 JSON,需要统一 schema 校验,只是提前了一步 | 同样的校验在沙箱边界运行,同样的指导性错误信息 |
-| 代码字段 | `execute` 函数体仍然是 vm 中模型编写的 JS;realm 和服务调用的正确性问题不变 | 一个沙箱、一条规范化路径、一处受保护的注册 |
-| 能力覆盖面 | 仅限工具;监听器、服务、`inject` 关系各需另一个结构化工具——API 无限增长 | 一套词汇(cordis 插件)覆盖当前和未来的所有效果 |
-| 跨挂载组合 | 在工具注册载荷中无法表达 | 原生 `provide`/`inject`,普通的 cordis 语义 |
-| 可审视性 | 注册的东西无法在插件列表中显示为插件 | 模型挂载的正是 `cordis_inspect` 渲染的 |
-| 模型易用性 | 对最常见的单一场景有优势(无插件样板) | 通过 mount 描述中的规范示例加边界错误信息教会正确调用来缓解 |
-
-因此正确性投入放在能一次性为所有能力带来回报的地方:通过 `cordis_inspect` 呈现的生成 API 目录,以及沙箱边界校验(其错误信息教会正确的调用方式)。结构化注册工具日后仍可作为语法糖添加,由它合成 mount 代码;本设计不排斥这一可能。
-
-**在工具中手工维护服务/事件参考。** inspect 工具的第一版携带了一份手写的服务方法签名表。它被生成的 `api-catalog.ts` 取代,因为手写表在签名变化的瞬间就会与 JSDoc 脱节且没有门禁约束这种漂移,而生成产物的新鲜度由文档使用的同一套 AST 检查。
-
-**新增 `cordis/mount` 会话事件。**一个持久事件记录每次挂载的源码和名称,有明确先例(`hook/invoked`、`compaction/start`)。不予采纳:挂载和卸载已经作为 `tool/call`/`tool/result` 对可见,工具集变化已经作为完整的变更 request header 被记录,因此专用事件只会重复记录。如果审计用例需要在工具调用之外取得挂载的源码和名称,日后仍可添加。
-
-**加固的/能力受限的沙箱。** 对 Node 内置模块设陷阱并向挂载代码提供白名单门面而非原始上下文,可能暗示意图是为安全而沙箱化。这里明确不是:陷阱和门面收窄的是挂载代码所见的 *API*——将其引导至 cordis 服务、远离易泄漏的 Node 内置模块和框架内部——目的是正确性和封堵未受保护的上下文逃逸,但门面暴露的能力(`ctx.shell`、`ctx.fs`、`ctx.web`)触及真实运行时,因此它不是安全边界。真正的安全边界(独立进程、权限提示)超出了一个开发/显式启用工具集的范围,且会与其核心目的——将活跃运行时交给模型——相冲突。
-
-## 后果
-
-该工具集是刻意的显式启用设计,具有完整权限的 `ctx`,因此部署方采用它的意识程度应与 bash 工具相当。以下几个事实由工具描述直接告知模型:一个 waterfall(瀑布式事件)监听器(如 `tools/pre-execute`)如果不调用 `next()` 就返回,会短路整条链,因此一个挂载的监听器可以阻止 agent 自身的工具分发([waterfall 语义](../../../../docs/cordis-primer.zh.md#cordis-waterfall-semantics));挂载代码在当前轮次的工具调用内运行,因此 await 任何只在该轮次结束后才 resolve 的东西会导致死锁;`vmTimeoutMs` 仅约束同步执行;挂载不会在会话恢复后存活。
+检查能力不依赖 Creator 或 Plugin Manager。Runner 生命周期测试覆盖受保护注册、启动失败清理和等待释放。现有浏览器消费者保留生命周期 API;移除其写入注册表与激活机制,需要协调替换这些消费者,不能删除历史渲染或假定会话数据会重建运行状态。

+ 0 - 27
.agents/notes/implemented/feature/2026-09-14-model-image-input-settings.md

@@ -1,27 +0,0 @@
-# Agent Note: Model image-input settings
-
-Status: implemented
-
-English | [中文](2026-09-14-model-image-input-settings.zh.md)
-
-## Problem
-
-Models settings can edit a model id without exposing the input capabilities that determine whether image attachments are accepted. A custom vision model can therefore appear in the picker while retaining a text-only declaration.
-
-## Decision
-
-Each model row exposes image input under Model options. Supported declares text and image; Not supported declares text only; Default removes the model's explicit input field. DeepSeek writes `inputModalities`, whose absent value means text only. Pi-ai writes `input`, whose absent or empty value inherits the installed model catalog or provider default. Opening a row preserves that inheritance without materializing an override.
-
-The shared field replaces one drafted row and preserves unrelated metadata. Selecting text only or default for DeepSeek also removes its image request limits, because the adapter rejects those limits without image input. Saving uses the existing catalog-array settings mutation and adapter validation. Configuration declares an upstream capability; it does not add image processing to a text-only model.
-
-## Alternatives considered
-
-**Keep `input` editable only in the settings document.** The [earlier pi-ai modality decision](../../archived/architecture/2026-08-12-pi-ai-route-default-input-modalities.md) kept this field outside the model-list editor. That leaves users who add custom vision models through the UI unable to enable their image input there. Per-row editing supplies that configuration while the default choice preserves catalog inheritance.
-
-**A two-state switch.** Treating an absent pi-ai declaration as disabled would misrepresent inherited vision support and encourage overwriting catalog defaults. The explicit Default choice preserves the adapter's existing resolution rules.
-
-**Keep image limits when disabling DeepSeek images.** This leaves a configuration that the adapter refuses to save. Clearing the image-specific limits makes the selected text-only state valid while preserving unrelated model fields.
-
-## Consequences
-
-Users can configure image input for DeepSeek and custom pi-ai model rows through the same control. Restoring defaults can change effective capabilities when the installed catalog or provider defaults change. DeepSeek image limits must be configured again after disabling images. Provider routing and the [catalog recovery rules](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md) remain owned by their existing decisions.

+ 0 - 27
.agents/notes/implemented/feature/2026-09-14-model-image-input-settings.zh.md

@@ -1,27 +0,0 @@
-# Agent Note:模型图片输入设置
-
-Status: implemented
-
-[English](2026-09-14-model-image-input-settings.md) | 中文
-
-## 问题
-
-模型设置可以编辑模型 ID,却没有展示决定图片附件是否被接受的输入能力。因此,自定义视觉模型虽然出现在选择器中,仍可能保留仅文本的声明。
-
-## 决策
-
-每个模型行在「模型选项」下提供图片输入设置。「支持」声明文本和图片;「不支持」声明仅文本;「默认」移除模型的显式输入字段。DeepSeek 写入 `inputModalities`,缺省时表示仅文本。Pi-ai 写入 `input`,缺省或空数组时继承已安装模型目录或提供方默认值。打开模型行保留这种继承,不会生成覆盖值。
-
-共享字段替换一个草稿模型行并保留无关元数据。DeepSeek 选择仅文本或默认时,还会移除图片请求限制,因为适配器在没有图片输入时拒绝这些限制。保存使用现有的模型目录数组设置变更和适配器校验。配置声明上游能力,不会为仅文本模型增加图片处理能力。
-
-## 考虑过的替代方案
-
-**仅允许在设置文档中编辑 `input`。** [早期 pi-ai 输入模态决策](../../archived/architecture/2026-08-12-pi-ai-route-default-input-modalities.md)将该字段留在模型列表编辑器之外。这使通过 UI 添加自定义视觉模型的用户无法在同一界面启用图片输入。逐行编辑提供了该配置,而默认选项保留模型目录继承。
-
-**两态开关。** 将缺省的 pi-ai 声明视为禁用,会错误表达继承的视觉能力,并促使用户覆盖模型目录的默认值。显式「默认」选项保留适配器现有的解析规则。
-
-**禁用 DeepSeek 图片时保留图片限制。** 这会留下适配器拒绝保存的配置。清除图片专属限制,使选择的仅文本状态有效,同时保留无关模型字段。
-
-## 影响
-
-用户可以通过相同控件配置 DeepSeek 和自定义 pi-ai 模型行的图片输入。恢复默认值后,有效能力可能随已安装模型目录或提供方默认值变化。禁用图片后,DeepSeek 图片限制需要重新配置。提供方路由和[模型目录恢复规则](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md)仍由现有决策负责。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-15-sidebar-workspace-hierarchy.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/feature/2026-09-15-sidebar-workspace-hierarchy.md
+2026-09-15-sidebar-workspace-hierarchy.md: 82e63684ec1d8c5d86e062b125aff6e1144ef8fa
+2026-09-15-sidebar-workspace-hierarchy.zh.md: 07f9ff89b6e33fd4d46b5e386b4240ad12c177c5

+ 31 - 0
.agents/notes/implemented/feature/2026-09-15-sidebar-workspace-hierarchy.md

@@ -0,0 +1,31 @@
+# Agent Note: Sidebar Workspace Hierarchy
+
+Status: implemented
+
+English | [中文](2026-09-15-sidebar-workspace-hierarchy.zh.md)
+
+## Problem
+
+A flat Workspace list makes related projects hard to browse when many directories share one parent. Treating that parent as the owner of every descendant Session would conflict with the Workspace requirement that a member Session's canonical working directory equals the registered path.
+
+## Decision
+
+The sidebar defaults to sibling Workspace sections. Selecting **Workspace Tree** in **View options → Group by** derives a recursive hierarchy from registered Workspace paths. The selected mode persists in the existing browser-local viewing store. Each Workspace appears under its nearest strict ancestor; path equality never creates a self-parent. Comparison respects directory separators and Host case spelling, without resolving symlink aliases. Siblings retain their Host order.
+
+Adding a directory registers a normal Workspace and opens its Session directly. Parent Workspaces retain their own Sessions and standard row actions, with child Workspaces preceding their own Sessions. The existing browser-local expansion state controls both child Workspaces and the parent's Session rows; ancestors default to expanded when no explicit preference exists. Explicit collapse also hides the current Session, while ancestor folder icons identify its containing subtree. All rows have equal-width fills and hit targets, with content indentation per level. Workspace drag stays among siblings; descendant drop targets delegate to the nearest compatible ancestor, and unchanged sibling positions do not write Host order. Search navigation expands every ancestor.
+
+## Alternatives considered
+
+**Tree grouping by default:** this hides projects under ancestors even when users want to browse every Workspace directly. An explicit viewing mode preserves the default layout without adding a confirmation to directory adoption.
+
+**Recursive Workspace membership:** this changes the working-directory invariant and makes a Session eligible for multiple accounts. Display nesting needs neither change.
+
+**Separate parent-folder records and an add-time choice:** this duplicates directories already represented by Workspaces and adds a confirmation to ordinary addition. Deriving hierarchy from the registry preserves one add flow and lets every directory own Sessions.
+
+**Automatic directory discovery:** this could show unregistered projects, but requires filesystem listing, refresh, and adoption behavior. The sidebar consumes the current Workspace list only.
+
+## Consequences
+
+Users can collapse related projects without changing Session ownership. Deleting an ancestor preserves child registrations; the independent [Workspace deletion semantics](2026-07-27-workspace-registration-deletion.md) still govern the deleted Workspace's own Sessions. That decision remains active. Browser-local collapse preferences do not synchronize, and registered path spelling after separator normalization determines nesting.
+
+Pure path tests cover strict ancestry, segment boundaries, POSIX backslashes, and Windows separators. Component and browser scenarios exercise default sibling grouping, tree-mode selection and restoration, direct addition, later registrations, independent parent Sessions, collapse restoration, search revelation, sibling sorting through descendant targets, parent-subtree moves, collapsed-current hints, and equal-width rows.

+ 31 - 0
.agents/notes/implemented/feature/2026-09-15-sidebar-workspace-hierarchy.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: 侧栏工作区层级
+
+Status: implemented
+
+[English](2026-09-15-sidebar-workspace-hierarchy.md) | 中文
+
+## 问题
+
+许多目录共享同一父目录时,平铺的 Workspace 列表不便浏览相关项目。若让父目录拥有所有后代 Session,则会违背 Workspace 成员 Session 的规范工作目录必须等于注册路径这一要求。
+
+## 决策
+
+侧栏默认将 Workspace 显示为同级分组。在**视图选项 → 分组方式**中选择**按工作区树**后,侧栏从已注册的 Workspace 路径派生递归层级。所选模式保存在现有的浏览器本地视图存储中。每个 Workspace 位于最近的严格祖先下;相同路径不会成为自己的父级。比较遵循目录分隔符和 Host 的大小写拼写,不解析符号链接别名。同级项目保留 Host 顺序。
+
+添加目录会直接注册普通 Workspace 并打开其 Session。父 Workspace 保留自己的 Session 和标准行操作,子 Workspace 位于父级自己的 Session 之前。现有的浏览器本地展开状态同时控制子 Workspace 和父级自己的 Session 行;没有显式偏好时,祖先默认展开。显式折叠也会隐藏当前 Session,但祖先文件夹图标会标识它所在的子树。所有行的高亮与点击区域等宽,内容按层级缩进。Workspace 拖拽限制在同级之间;后代落点交给最近的兼容祖先,同级位置未变时不写入 Host 顺序,搜索导航会展开全部祖先。
+
+## 考虑过的替代方案
+
+**默认采用树形分组:**这会把项目隐藏在祖先之下,即使用户希望直接浏览全部 Workspace。显式视图模式保留默认布局,也不为目录添加流程增加确认。
+
+**递归 Workspace 成员关系:**这会改变工作目录不变量,并让一个 Session 同时符合多个记账。展示嵌套不需要这两项改变。
+
+**独立父目录记录与添加时选择:**这会重复表示已有 Workspace 目录,并为普通添加增加确认。根据注册表派生层级可保留单一添加流程,并让每个目录都能拥有 Session。
+
+**自动发现目录:**这能展示尚未注册的项目,但需要文件系统列举、刷新和采用行为。侧栏仅使用当前 Workspace 列表。
+
+## 结果
+
+用户可以折叠相关项目,而不改变 Session 归属。删除祖先会保留子工作区注册;独立的 [Workspace 删除语义](2026-07-27-workspace-registration-deletion.zh.md)仍决定被删除 Workspace 自身 Session 的处理,该决策继续有效。浏览器本地折叠偏好不跨浏览器同步,嵌套取决于分隔符归一化后的注册路径拼写。
+
+纯路径测试覆盖严格祖先关系、路径段边界、POSIX 反斜杠和 Windows 分隔符。组件与浏览器场景覆盖默认同级分组、树形模式选择与恢复、直接添加、后续注册、父级独立 Session、折叠恢复、搜索展开、通过后代落点进行同级排序、父级子树移动、折叠当前会话提示及整行等宽。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.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/feature/2026-09-16-unified-model-input-controls.md
+2026-09-16-unified-model-input-controls.md: 73913880924577caf86b30990baef98efdbe751e
+2026-09-16-unified-model-input-controls.zh.md: 691ca5229c017919e708bf7df92285dee25ef6b4

+ 29 - 0
.agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.md

@@ -0,0 +1,29 @@
+# Agent Note: Unified model input controls
+
+Status: implemented
+
+English | [中文](2026-09-16-unified-model-input-controls.zh.md)
+
+## Problem
+
+Separate DeepSeek and pi-ai row renderers let labels, icons, and spacing diverge. An automatic column count moves image input between the capacity row and a second row with small width changes. The image-support selector also cannot express image-only input, although both adapters accept it.
+
+## Decision
+
+Both catalogs render a shared model row. The two capacity fields occupy two columns, and Input types occupies the full next row. Text and Image use the controlled native Checkbox in `ui-primitives`, with caller-owned localized labels and native keyboard behavior. At least one type remains selected; the last checked type is disabled until another is selected.
+
+An undeclared or empty input field displays the installed model input types, then the route default, then Text, without materializing an override on open. Pi-ai catalog discovery carries optional `inputModalities` through the LLM service and Remote response; adopted candidates copy it to `input`. Known provider editors read the installed catalog without endpoint I/O, so existing capacity-only rows also display inherited vision support. Catalog reads for a previous provider are discarded, and input controls wait for the current read to settle. The first checkbox edit writes the exact nonempty selection to DeepSeek's `inputModalities` or pi-ai's `input`. Existing explicit selections, including image-only input, remain visible. Unrelated metadata survives edits; disabling DeepSeek images removes its image request limits because the adapter rejects those limits without image input. Saving uses the existing catalog-array mutation and adapter validation.
+
+## Alternatives considered
+
+**Independent model-row renderers.** Adapter-specific defaults and model discovery remain with their editors, but duplicate presentation creates avoidable visual drift. Shared rows keep those behaviors independent of field layout.
+
+**Keep the image-support selector.** Settings-file-only capability editing leaves custom vision models effectively text-only. A selector with an explicit Default option preserves visible pi-ai catalog inheritance; a two-state image switch would misrepresent inherited vision support as disabled. The checkbox interface exposes both input types directly, resolving inherited capabilities before enabling edits. Merely opening a row still preserves inheritance; restoring it after a checkbox edit requires removing the input field in settings or resetting the catalog override.
+
+**Allow no checked types.** DeepSeek rejects an empty list and pi-ai treats it as inheritance. Keeping a nonempty selection prevents the same gesture from meaning different things across adapters.
+
+**Keep image limits when disabling DeepSeek images.** This leaves a configuration that the adapter refuses to save. Clearing the image-specific limits makes the selected text-only state valid while preserving unrelated model fields.
+
+## Consequences
+
+Users can edit text-only, image-only, and combined declarations through one layout. Configuration declares upstream capabilities; it does not add image processing to a text-only model. Removing a pi-ai input declaration can change effective capabilities as catalog or provider defaults change. DeepSeek image limits need reconfiguration after images are disabled. Provider routing and [catalog recovery](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md) retain their existing owners. Component checks cover defaults, exact selections, metadata preservation, and disabled controls; browser scenarios cover saved adapter capabilities, reopened selections, the separate input-type row, and installed vision metadata across discovery, adoption, and reopening.

+ 29 - 0
.agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.zh.md

@@ -0,0 +1,29 @@
+# Agent Note:统一模型输入控件
+
+Status: implemented
+
+[English](2026-09-16-unified-model-input-controls.md) | 中文
+
+## 问题
+
+DeepSeek 和 pi-ai 分别渲染模型行,导致标签、图标和间距不一致。自动列数会在宽度稍有变化时,将图片输入移到容量字段同一行或下一行。图片支持选择器也无法表达仅图片输入,而两个适配器都接受这种声明。
+
+## 决策
+
+两个模型目录渲染同一个模型行组件。两个容量字段占据两列,输入类型独占下一整行。文本和图片使用 `ui-primitives` 的受控原生 Checkbox(复选框),由调用方提供本地化标签,并保留原生键盘行为。至少保留一种输入类型;最后一个勾选项保持禁用,直到选中另一项。
+
+未声明或为空的输入字段优先显示已安装模型的输入类型,其次是路由默认值,最后回退为文本,打开时不会生成覆盖值。Pi-ai 目录发现将可选的 `inputModalities` 经 LLM 服务和 Remote(远程调用)响应传递给界面;采纳候选时将其复制到 `input`。已知提供方的编辑器读取已安装目录,不向端点发送请求,因此已有的仅容量模型行也能显示继承的视觉能力。上一个提供方的目录响应会被丢弃,输入控件会等待当前读取完成。首次修改复选框会将确切的非空选择写入 DeepSeek 的 `inputModalities` 或 pi-ai 的 `input`。已有显式选择保持可见,包括仅图片输入。编辑保留无关元数据;禁用 DeepSeek 图片时会移除图片请求限制,因为适配器在没有图片输入时拒绝这些限制。保存使用现有的模型目录数组变更和适配器校验。
+
+## 考虑过的替代方案
+
+**独立的模型行渲染器。** 适配器专属默认值和模型发现仍由各自编辑器负责,但重复的展示实现会造成可避免的视觉差异。共享模型行将这些行为与字段布局分离。
+
+**保留图片支持选择器。** 仅通过设置文件编辑能力,会让自定义视觉模型实际保持仅文本。带有显式「默认」选项的选择器会清楚展示 pi-ai 模型目录继承;两态图片开关则会将继承的视觉能力误表示为禁用。复选框界面直接展示两种输入类型,并在允许编辑前解析继承的能力。仅打开模型行仍保留继承;修改复选框后,要恢复继承需在设置中移除输入字段,或重置整个模型目录覆盖。
+
+**允许全部取消。** DeepSeek 拒绝空列表,pi-ai 则将其视为继承。保留非空选择,避免同一个操作在两个适配器中具有不同含义。
+
+**禁用 DeepSeek 图片时保留图片限制。** 这会留下适配器拒绝保存的配置。清除图片专属限制,可以让选中的仅文本状态有效,同时保留模型的无关字段。
+
+## 影响
+
+用户通过相同布局编辑仅文本、仅图片和组合声明。配置声明上游能力,不会为仅文本模型增加图片处理能力。移除 pi-ai 输入声明后,有效能力可能随模型目录或提供方默认值变化。禁用图片后,DeepSeek 图片限制需要重新配置。提供方路由和[模型目录恢复规则](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md)保留现有责任方。组件检查覆盖默认值、确切选择、元数据保留和禁用控件;浏览器场景覆盖保存后的适配器能力、重新打开后的选择,独占一行的输入类型,以及发现、采纳和重新打开过程中传递的内置视觉元数据。

+ 3 - 3
.agents/notes/implemented/feature/2026-09-14-model-image-input-settings.i18n.yaml → .agents/notes/implemented/process/2026-09-16-author-approval-credit.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-14-model-image-input-settings.md
-2026-09-14-model-image-input-settings.md: ef328400332aa58c02a450ead0195eff124c5fdf
-2026-09-14-model-image-input-settings.zh.md: 70ec427b138026124cad2ffdb1fc5f60597abe8a
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-16-author-approval-credit.md
+2026-09-16-author-approval-credit.md: ab7aba88801271c22a0f054d5b3888726b94e97a
+2026-09-16-author-approval-credit.zh.md: 316a09ea5d82f78915a9550fde7f29bd55761e44

+ 25 - 0
.agents/notes/implemented/process/2026-09-16-author-approval-credit.md

@@ -0,0 +1,25 @@
+# Agent Note: Author approval credit
+
+Status: implemented
+
+English | [中文](2026-09-16-author-approval-credit.zh.md)
+
+## Problem
+
+The approval policy needs author experience to contribute enough points for an established author and one ordinary reviewer to meet the two-point requirement.
+
+## Decision
+
+The [approval policy](../../../../.github/review-ownership/README.md) awards 0.011 points per merged PR in this repository, capped at 1.1 points after 100 PRs. History lookup stops at 100 matching PRs. Author credit alone remains insufficient, and blocking reviews still prevent approval.
+
+## Alternatives considered
+
+**Keep the 0.6-point cap at 150 PRs.** That cap cannot combine with an ordinary one-point review to meet the requirement without an ownership boost.
+
+## Consequences
+
+One ordinary approval with no ownership boost meets the requirement at 91 merged PRs; 90 yields only 1.99 points. The lower counting cap limits required history traversal for prolific authors. Merged PR count measures contribution history rather than review quality.
+
+## Verification
+
+Policy tests cover credit below, at, and above the cap; the 90/91-PR approval threshold; author-only rejection; pagination; and below-threshold scores whose display rounds to two.

+ 25 - 0
.agents/notes/implemented/process/2026-09-16-author-approval-credit.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: 作者审批积分
+
+Status: implemented
+
+[English](2026-09-16-author-approval-credit.md) | 中文
+
+## Problem
+
+审批策略需要让作者经验贡献足够的积分,使有经验的作者与一位普通审阅者能够共同满足两分要求。
+
+## Decision
+
+[审批策略](../../../../.github/review-ownership/README.md)为作者在本仓库中每个已合并 PR 计入 0.011 分,达到 100 个 PR 时以 1.1 分封顶。历史查询在找到 100 个匹配 PR 后停止。仅靠作者积分仍不足以通过审批,阻塞性审阅仍会阻止通过。
+
+## Alternatives considered
+
+**保留 150 个 PR 时封顶 0.6 分的规则。** 在没有代码所有权加分的情况下,该上限与普通审阅的一分相加仍无法满足要求。
+
+## Consequences
+
+没有代码所有权加分时,一次普通批准与 91 个已合并 PR 的作者积分相加即可满足要求;90 个则只有 1.99 分。较低的计数上限减少了高贡献作者所需的历史遍历。已合并 PR 数量衡量贡献历史,而非审阅质量。
+
+## Verification
+
+策略测试覆盖积分低于、达到及超过上限的情况、90/91 个 PR 的审批阈值、仅靠作者积分不能通过、分页,以及显示值舍入为两分但实际低于阈值的情况。

+ 2 - 2
.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.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/testing/2026-07-30-web-browser-snapshot-ci-gate.md
-2026-07-30-web-browser-snapshot-ci-gate.md: 719ac059d1208601e3fdd7ef10ae48464bd92877
-2026-07-30-web-browser-snapshot-ci-gate.zh.md: 57e23491e2123f9e14f478b98331dac2726ef2a2
+2026-07-30-web-browser-snapshot-ci-gate.md: d1c8dcdd859d0515804cc6aed0d2f3341beb6dd0
+2026-07-30-web-browser-snapshot-ci-gate.zh.md: 9d642819e09abc63384f4f4eb105d13261a9eb95

+ 3 - 3
.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md

@@ -16,7 +16,7 @@ The consumer job owns the [single Linux build](../../archived/process/2026-07-30
 
 Local `pnpm run test:web` continues to build first and then run the full browser suite serially; `test:web:built` is the serial entry point for existing build artifacts. Developers explicitly run `DSH_SNAPSHOT=refresh pnpm run test:web` only after confirming that user-visible output changed intentionally, review every expected-output diff, and then verify again in replay mode that no files are written.
 
-CI's `scripts/run-web-snapshots.ts` first runs `hmr-live.e2e.ts` and `cordis-tool-round.e2e.ts` as separate serial Vitest invocations. The HMR scenario mutates built workspace state, while the Cordis scenario owns a lifecycle-sensitive approval and steering sequence whose turn grouping is made deterministic by waiting for the initial turn to settle before approval. After both pass, one six-worker Vitest pool runs every remaining file. Every child inherits stdio, and the enclosing gate streams that output through `run-gates`.
+CI’s `scripts/run-web-snapshots.ts` runs `hmr-live.e2e.ts` separately because it mutates built workspace state. One six-worker Vitest pool then runs every remaining file, including historical Cordis-card rendering. Every child inherits stdio, and `run-gates` streams its output.
 
 For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision Chromium. The self-hosted default-branch Linux serial standby also includes the comparison, while the macOS and Windows serial jobs remain browser-free (there is no hosted Linux serial aggregate). A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name.
 
@@ -30,10 +30,10 @@ Completed local replays measured the six-worker browser command at about 65–71
 
 **Create a standalone browser job and rebuild the entire repository.** Rejected: it would duplicate dependency installation and the publishable build. The existing Linux consumer job already owns that build and is part of the unified required verdict.
 
-**Run HMR and Cordis inside the parallel pool.** Rejected because HMR mutates shared built state and the Cordis approval continuation requires a serial preflight. Every other file shares one bounded pool; dedicated long-file processes add scheduling code and leave part of a reduced worker budget idle after those files complete.
+**Run HMR inside the parallel pool.** Rejected because it mutates shared built state. Other browser fixtures own isolated state and share one bounded pool.
 
 **Replace real Chromium with jsdom snapshots.** Rejected: jsdom does not cover the browser, HTTP/SSE carriage, or the composition of real client plugin bundles. It remains useful for fast lower-layer feedback, but cannot replace the assembled browser chain.
 
 ## Consequences
 
-Before merge, every PR proves that the current web assembly matches all committed browser expected outputs; a missing refresh fails in the same PR that changes the assembly. The cost is Chromium provisioning, two serial scenarios, and one bounded six-worker pool in the consumer job; the consumer-owned build and browser cache avoid duplicate builds and downloads on reruns. Parallel-file failures stream immediately, but a worker-budget change still requires a completed end-to-end measurement rather than an elapsed-time guess. The gate makes no claim of cross-platform browser consistency, and if a Playwright/Chromium upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn.
+Before merge, every PR proves that the current web assembly matches all committed browser expected outputs; a missing refresh fails in the same PR that changes the assembly. The cost is Chromium provisioning, one serial scenario, and one bounded six-worker pool in the consumer job; the consumer-owned build and browser cache avoid duplicate builds and downloads on reruns. Parallel-file failures stream immediately, but a worker-budget change still requires a completed end-to-end measurement rather than an elapsed-time guess. The gate makes no claim of cross-platform browser consistency, and if a Playwright/Chromium upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn.

+ 2 - 2
.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md

@@ -16,7 +16,7 @@ Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览
 
 本地 `pnpm run test:web` 仍先构建,再串行运行完整浏览器套件;`test:web:built` 是已有构建产物的串行执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处预期输出 diff,再以 replay 模式复验不再写文件。
 
-CI 的 `scripts/run-web-snapshots.ts` 先用相互独立的 Vitest 调用串行运行 `hmr-live.e2e.ts` 与 `cordis-tool-round.e2e.ts`。HMR 场景会修改已构建工作区状态;Cordis 场景则拥有一条对生命周期时序敏感的批准与 steering(中途引导)序列,它通过在批准前等待初始轮次结束来确定轮次分组。两者通过后,其余全部文件进入同一个 6-worker Vitest 池。所有子进程都继承 stdio,外围门禁再通过 `run-gates` 流式传递输出。
+CI 的 `scripts/run-web-snapshots.ts` 单独运行会修改构建产物的 `hmr-live.e2e.ts`。随后由一个六 worker Vitest 池运行其余文件,包括历史 Cordis 卡片渲染。所有子进程继承 stdio,由 `run-gates` 流式转发输出。
 
 对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不安装 Chromium。自托管的默认分支 Linux 串行热备也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器(不存在托管的 Linux 串行聚合)。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。
 
@@ -30,7 +30,7 @@ CI 的 `scripts/run-web-snapshots.ts` 先用相互独立的 Vitest 调用串行
 
 **新建独立 browser job 并重新构建全仓。** 已否决:它会重复依赖安装和发布构建。现有 Linux 消费方 job 已负责该构建,并已被统一的 required verdict 聚合。
 
-**把 HMR 与 Cordis 也放进并行池。** 不予采用,因为 HMR 会修改共享的已构建状态,Cordis 批准 continuation 则需要串行预检。其余全部文件共用一个有界池;专用长文件进程会增加调度代码,并在这些文件结束后让缩减后的部分 worker 预算闲置
+**在并行池中运行 HMR。** 拒绝,因为它会修改共享构建产物。其他浏览器 fixture 拥有隔离状态,共用一个有界池
 
 **用 jsdom 快照代替真实 Chromium。** 已否决:jsdom 不覆盖浏览器、HTTP/SSE 承载及真实客户端插件包的组合;它仍可用于快速的下层反馈,但不能替代组装后的浏览器链路。
 

+ 1 - 1
.github/review-ownership/README.md

@@ -20,7 +20,7 @@ The weighted approval workflow exposes two pull-request checks. The `weighted ap
 
 Reviewers whose calculated base repository permission is `write` or `admin` count. The [approval policy](approval-policy.json) gives `@07akioni`, `@imccyu`, `@tianyicui`, `@tianyicui-bot`, `@turtle1999`, and `@turtle2099` two points each; every other write-capable reviewer gets one point. The pull-request author’s own review and reviewers without write permission do not count.
 
-The PR author contributes `min(0.6, mergedPRCount / 250)` points: 0 merged PRs → 0 points, 100 → 0.4, and 150 or more → 0.6. The publisher counts only merged PRs in this repository using the author’s immutable account ID, excluding the current PR. It stops at 150 matches and rejects incomplete history responses. Counts refresh at the next subscribed evaluation event. Author credit is separate from reviewer approvals and cannot satisfy the two-point requirement alone; the author’s own review remains excluded. Human and bot authors, including Dependabot, use the same rule. Drafts, blocking reviews, and sufficient reviewer points skip history lookup; logs mark credit as not evaluated. Otherwise, logs show author credit and the capped count separately. Below the cap, counting may scan the repository’s entire merged history; a failed required lookup publishes an error.
+The PR author contributes `min(100, mergedPRCount) × 11 / 1000` points: 0 merged PRs → 0 points, 50 → 0.55, and 100 or more → 1.1. The publisher counts only merged PRs in this repository using the author’s immutable account ID, excluding the current PR. It stops at 100 matches and rejects incomplete history responses. Counts refresh at the next subscribed evaluation event. Author credit is separate from reviewer approvals and cannot satisfy the two-point requirement alone; the author’s own review remains excluded. Human and bot authors, including Dependabot, use the same rule. Drafts, blocking reviews, and sufficient reviewer points skip history lookup; logs mark credit as not evaluated. Otherwise, logs show author credit and the capped count separately. Below the cap, counting may scan the repository’s entire merged history; a failed required lookup publishes an error.
 
 A one-point approval receives weight `min(2, 1 + 4 × ownedLines / totalLines)` from modified or deleted old production-code lines, attributed by `git blame` at the merge base of the live base branch and exact reviewed head. Ownership of 0%, 12.5%, and 25% gives 1, 1.5, and 2 points; higher ownership remains capped at 2. The success threshold remains 2 total points, without rounding the score. New lines do not enter the denominator, and an empty denominator gives no boost. Existing two-point weights remain unchanged. GitHub commit-author accounts identify reviewers across author emails; unlinked authors remain in the denominator without contributing to a reviewer. The publisher logs measured ownership. It skips attribution when reviewer points plus author credit already meet the threshold or a blocking review exists. Displayed scores use at most three decimal places; the decision uses unrounded scores with a tolerance of `1e-12` points for floating-point error. The curve endpoints come from the policy’s default and required points.
 

+ 4 - 4
.github/review-ownership/author-weight.mjs

@@ -1,20 +1,20 @@
 /** Author credit from merged pull requests in the same repository. */
-const CAP_COUNT = 150
+const CAP_COUNT = 100
 
 /**
- * Convert merged PR count to author points, capped at 0.6.
+ * Convert merged PR count to author points, capped at 1.1.
  * @param {number} mergedCount Merged PR count.
  * @returns {number} Author approval points.
  */
 export function authorCreditPoints(mergedCount) {
-  return Math.min(CAP_COUNT, mergedCount) / 250
+  return Math.min(CAP_COUNT, mergedCount) * 11 / 1000
 }
 
 /**
  * Count merged PRs by immutable author account, stopping at the credit cap.
  * @param {{repository: string, number: number, authorId: string}} pull Current pull request.
  * @param {(path: string, options: object) => Promise<unknown>} api GitHub API caller.
- * @returns {Promise<number>} Merged count, capped at 150; incomplete responses reject.
+ * @returns {Promise<number>} Merged count, capped at 100; incomplete responses reject.
  */
 export async function countMergedAuthorPulls(pull, api) {
   const [owner, name] = pull.repository.split('/')

+ 4 - 4
.github/review-ownership/author-weight.test.mjs

@@ -20,16 +20,16 @@ test('counts only the same account and excludes the current PR and deleted accou
   assert.equal(count, 1)
 })
 
-test('follows cursors, deduplicates overlapping pages, and stops at 150', async () => {
+test('follows cursors, deduplicates overlapping pages, and stops at 100', async () => {
   let calls = 0
   const count = await countMergedAuthorPulls(pull, async (path, { body }) => {
     calls++
-    if (calls === 1) return page(Array.from({ length: 100 }, (_, i) => entry(i + 1)), true, 'next')
+    if (calls === 1) return page(Array.from({ length: 100 }, (_, i) => entry(i + 1, i < 50 ? pull.authorId : 'another-id')), true, 'next')
     assert.equal(calls, 2)
     assert.equal(body.variables.after, 'next')
-    return page(Array.from({ length: 100 }, (_, i) => entry(i + 100)), true, 'unused')
+    return page(Array.from({ length: 100 }, (_, i) => entry(i + 51)), true, 'unused')
   })
-  assert.equal(count, 150)
+  assert.equal(count, 100)
   assert.equal(calls, 2)
 })
 

+ 27 - 12
.github/review-ownership/check-approval.test.mjs

@@ -485,7 +485,7 @@ test('publishes setup phases without evaluating or installing dependencies', asy
   await assert.rejects(publishApprovalPhase({ ...options, phase: 'success' }), /invalid approval setup phase/u)
 })
 
-for (const [mergedCount, credit] of [[0, 0], [1, 0.004], [50, 0.2], [100, 0.4], [125, 0.5], [150, 0.6], [200, 0.6]]) {
+for (const [mergedCount, credit] of [[0, 0], [1, 0.011], [50, 0.55], [99, 1.089], [100, 1.1], [101, 1.1], [200, 1.1]]) {
   test(`author with ${mergedCount} merged PRs contributes ${credit} points but cannot approve alone`, async () => {
     const result = await evaluateApproval({
       event: pullRequestEvent(), policySource, getMergedCount: async () => mergedCount,
@@ -499,16 +499,31 @@ for (const [mergedCount, credit] of [[0, 0], [1, 0.004], [50, 0.2], [100, 0.4],
 }
 
 test('combines author credit and reviewer ownership at the passing threshold', async () => {
-  for (let mergedCount = 0; mergedCount <= 150; mergedCount++) {
+  for (let mergedCount = 0; mergedCount <= 90; mergedCount++) {
     const result = await evaluateApproval({
       event: pullRequestEvent(), policySource, getMergedCount: async () => mergedCount,
-      getOwnership: async () => ({ totalLines: 1000, reviewerLines: { writer: 250 - mergedCount } }),
+      getOwnership: async () => ({ totalLines: 1000000, reviewerLines: { writer: 250000 - 2750 * mergedCount } }),
       api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' },
     })
     assert.equal(result.state, 'success', `author merged ${mergedCount}`)
   }
 })
 
+for (const [mergedCount, state] of [[90, 'pending'], [91, 'success'], [100, 'success']]) {
+  test(`one ordinary approval with ${mergedCount} merged PRs is ${state}`, async () => {
+    const result = await evaluateApproval({
+      event: pullRequestEvent(), policySource, getMergedCount: async () => mergedCount,
+      getOwnership: async () => {
+        assert.equal(mergedCount, 90, 'sufficient author credit must skip ownership lookup')
+        return { totalLines: 0, reviewerLines: {} }
+      },
+      api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' },
+    })
+    assert.equal(result.state, state)
+    assert.equal(result.approvals[0].points, 1)
+  })
+}
+
 test('blockers skip history and ignore non-write reviewers', async () => {
   const result = await evaluateApproval({
     event: pullRequestEvent(), policySource, getMergedCount: async () => { throw new Error('blockers must skip history') },
@@ -544,8 +559,8 @@ test('drafts skip history and history failures revoke success with error', async
 
 test('a score just below the threshold remains pending even if its display rounds to two', async () => {
   const result = await evaluateApproval({
-    event: pullRequestEvent(), policySource, getMergedCount: async () => 150,
-    getOwnership: async () => ({ totalLines: 1000000, reviewerLines: { writer: 99999 } }),
+    event: pullRequestEvent(), policySource, getMergedCount: async () => 50,
+    getOwnership: async () => ({ totalLines: 1000000, reviewerLines: { writer: 112499 } }),
     api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' },
   })
   assert.equal(result.state, 'pending')
@@ -558,13 +573,13 @@ test('the publisher counts merged history through the production API path', asyn
   const output = []
   const result = await runWithHistory({
     event, policySource, runUrl: 'https://github.example/run/1', write: line => output.push(line),
-    getOwnership: async () => ({ totalLines: 8, reviewerLines: { writer: 1 } }),
+    getOwnership: async () => ({ totalLines: 80, reviewerLines: { writer: 9 } }),
     api: async (path, options) => {
       if (path.includes('/comments?')) return []
       if (path === '/graphql') {
         assert.equal(options.body.variables.owner, 'deepseek-harness')
         return { data: { repository: { pullRequests: {
-          nodes: Array.from({ length: options.body.variables.after ? 25 : 100 }, (_, i) => ({
+          nodes: Array.from({ length: 25 }, (_, i) => ({
             number: i + (options.body.variables.after ? 200 : 100), author: { id: event.pull_request.user.node_id },
           })),
           pageInfo: { hasNextPage: !options.body.variables.after, endCursor: 'next' },
@@ -577,10 +592,10 @@ test('the publisher counts merged history through the production API path', asyn
     },
   })
   assert.equal(result.points, 2)
-  assert.equal(result.authorCredit.points, 0.5)
-  assert.equal(result.approvals[0].points, 1.5)
+  assert.equal(result.authorCredit.points, 0.55)
+  assert.equal(result.approvals[0].points, 1.45)
   assert.deepEqual(states, ['pending', 'success'])
-  assert.equal(output[0], 'Author credit: 0.5 (125 merged PRs).')
+  assert.equal(output[0], 'Author credit: 0.55 (50 merged PRs).')
 })
 
 test('sufficient reviewer points and drafts publish without querying author history', async () => {
@@ -617,11 +632,11 @@ test('bot authors receive the same history credit', async () => {
   const event = pullRequestEvent({ author: 'dependabot[bot]' })
   event.pull_request.user.type = 'Bot'
   const result = await evaluateApproval({
-    event, policySource, getMergedCount: async () => 150,
+    event, policySource, getMergedCount: async () => 100,
     getOwnership: async () => ({ totalLines: 10, reviewerLines: { writer: 1 } }),
     api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' },
   })
-  assert.equal(result.authorCredit.points, 0.6)
+  assert.equal(result.authorCredit.points, 1.1)
   assert.equal(result.state, 'success')
 })
 

+ 0 - 19
apps/cli/config/examples/cordis/cordis.yml

@@ -1,19 +0,0 @@
-# Opt-in Web composition for inspecting the self-referential Cordis tools.
-# Temporary Plugin code can reach every injected live capability; treat this
-# deployment like shell access, not as a security boundary.
-# This file is a PATCH OVERLAY over the web profile (dsh-base + dsh-web-app
-# bundle layers), not a tree: `dsh web --patch` applies it as one more sibling
-# patch list at the same include level, so these patches reach every bundle
-# row. A patch replaces the targeted row's whole `config`.
-
-# Pinning the port here keeps this demo off the default 3080.
-- id: webserver
-  config:
-    host: 127.0.0.1
-    port: 3081
-
-- insert:
-    - id: cordis-host-runner
-      name: '@deepseek-ai/dsh-cordis-host-runner'
-    - id: tool-cordis
-      name: '@deepseek-ai/dsh-tool-cordis'

+ 3 - 0
apps/cli/src/profile-boot.ts

@@ -235,6 +235,8 @@ export interface RunProfileOptions {
   patchFiles: readonly string[]
   /** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */
   args: readonly string[]
+  /** Application-owned package runtime, scoped to plugin package operations. */
+  packageManager?: ProfileContext['packageManager']
   /** Module fallback backend; pkg executables always use runtime resolution. */
   resolutionMode?: ProfileResolutionMode
 }
@@ -293,6 +295,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
     const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
     const profileContext: ProfileContext = {
       name: options.profile,
+      ...(options.packageManager === undefined ? {} : { packageManager: options.packageManager }),
       dir: composed.profile.dir, patchPath: composed.profile.patchPath,
       installAnchor: options.resolvedProfile?.installAnchor ?? INSTALL_ANCHOR,
       startedBundles: composed.profile.layers.map(layer => layer.packageName),

+ 14 - 6
apps/cli/tests/desktop-host.e2e.ts

@@ -3,7 +3,7 @@
 import { fork } from 'node:child_process'
 import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
-import { dirname, join } from 'node:path'
+import { delimiter, dirname, join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { finished } from 'node:stream/promises'
 import { expect, it, onTestFinished } from 'vitest'
@@ -33,8 +33,8 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
   writeFileSync(join(modules, 'dsh', 'package.json'), '{"type":"module","exports":{"./profile-boot":"./profile-boot.js"}}')
   writeFileSync(join(modules, 'dsh', 'profile-boot.js'), `
     import { writeFileSync } from 'node:fs';
-    export function runProfile() {
-      process.send({ type: 'booting' });
+    export function runProfile(options) {
+      process.send({ type: 'booting', packageManager: options.packageManager });
       return new Promise((resolve, reject) => process.once('disconnect', () => {
         if (${String(fail)}) { reject(new Error('fixture boot failure')); return; }
         resolve({ ctx: { plugin: async () => {}, connection: { authenticatedUrl: value => value }, webServer: { port: 19387 } },
@@ -44,7 +44,9 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
   `)
   const entry = join(root, 'index.js')
   copyFileSync(join(hostDirectory, 'lib', 'index.js'), entry)
-  const child = fork(entry, [root, root], { execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
+  const pnpm = join(root, 'bundled-pnpm.mjs')
+  const nodeBin = join(root, 'bin')
+  const child = fork(entry, [root, root, root, 'runtime', pnpm, nodeBin], { execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
   let stderr = ''
   child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk })
   const exited = new Promise<number | null>(resolve => child.once('exit', resolve))
@@ -55,11 +57,17 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
     rmSync(root, { recursive: true, force: true })
   })
   try {
-    await new Promise<void>((resolve, reject) => {
-      child.once('message', () => { resolve() })
+    const boot = await new Promise<{
+      packageManager: { command: string; args: string[]; env: Record<string, string> }
+    }>((resolve, reject) => {
+      child.once('message', resolve)
       child.once('error', reject)
       child.once('exit', (code) => { reject(new Error(`Host exited before booting: ${String(code)} ${stderr}`)) })
     })
+    expect(boot.packageManager.command).toBe(process.execPath)
+    expect(boot.packageManager.args).toEqual(['--expose-internals', pnpm])
+    expect(boot.packageManager.env.ELECTRON_RUN_AS_NODE).toBe('1')
+    expect(boot.packageManager.env.PATH).toBe(`${nodeBin}${delimiter}${process.env.PATH ?? ''}`)
     child.disconnect()
     expect(await exited).toBe(fail ? 1 : 0)
     await drained

+ 7 - 62
apps/cli/tests/profiles/headless/tests/ptc.e2e.ts

@@ -277,7 +277,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => {
     expect(ctx.jobs.list()).toEqual([])
   }, 15_000)
 
-  it('uses versioned Cordis DTO ids directly for running and pending Plugins, then confirms removal', async () => {
+  it('uses runtime inspection results directly through PTC', async () => {
     ctx = await typedPtcModeHarness()
     await ctx.plugin(CordisHostRunner)
     await ctx.plugin(ToolCordis)
@@ -287,70 +287,15 @@ describe('PTC mode typed values: keyless real-process contracts', () => {
     } as unknown as Agent
 
     const value = completion(await runCode(ctx, `
-      const activeDefinition = await tools.cordis_define({
-        plugin: { kind: 'new', idPrefix: 'active' },
-        name: 'active-ptc-plugin',
-        purpose: 'prove an active Host half',
-        code: { host: "return { name: 'active-ptc-plugin', apply(ctx) {} }" },
+      const listed = await tools.cordis_inspect_list({});
+      const provider = listed.providers.find(item => item.id === 'Tool');
+      const inspected = await tools.cordis_inspect_query({
+        platform: provider.platform, provider: provider.id, method: provider.methods[0].name,
       });
-      const active = await tools.cordis_run({
-        pluginId: activeDefinition.pluginId,
-        packageId: activeDefinition.packageId,
-        mode: 'run',
-      });
-      const pendingDefinition = await tools.cordis_define({
-        plugin: { kind: 'new', idPrefix: 'queue' },
-        name: 'pending-ptc-plugin',
-        purpose: 'prove a Host half waiting for a Service',
-        code: { host: "return { name: 'pending-ptc-plugin', inject: ['missing-ptc-service'], apply(ctx) {} }" },
-      });
-      const pending = await tools.cordis_run({
-        pluginId: pendingDefinition.pluginId,
-        packageId: pendingDefinition.packageId,
-        mode: 'run',
-      });
-      const before = await tools.cordis_inspect_self({});
-      const removed = await tools.cordis_undefine({ pluginId: active.pluginId });
-      const after = await tools.cordis_inspect_self({});
-      await tools.cordis_undefine({ pluginId: pending.pluginId });
-      return {
-        active: {
-          pluginId: active.pluginId,
-          packageId: active.packageId,
-          pluginRunId: active.pluginRunId,
-          status: active.host.status,
-        },
-        pending: {
-          pluginId: pending.pluginId,
-          packageId: pending.packageId,
-          pluginRunId: pending.pluginRunId,
-          status: pending.host.status,
-          waitingFor: pending.host.waitingFor,
-        },
-        removed,
-        beforeContainsId: before.plugins.some(plugin => plugin.pluginId === active.pluginId),
-        afterContainsId: after.plugins.some(plugin => plugin.pluginId === active.pluginId),
-      };
+      return { provider: provider.id, names: inspected.data.tools.map(tool => tool.name) };
     `, testToolSignal, agent))
 
-    expect(value).toEqual({
-      active: {
-        pluginId: 'active-1',
-        packageId: 'pkg-1',
-        pluginRunId: 'run-1',
-        status: 'running',
-      },
-      pending: {
-        pluginId: 'queue-2',
-        packageId: 'pkg-2',
-        pluginRunId: 'run-2',
-        status: 'waiting',
-        waitingFor: ['missing-ptc-service'],
-      },
-      removed: { pluginId: 'active-1', wasRunning: true },
-      beforeContainsId: true,
-      afterContainsId: false,
-    })
+    expect(value).toEqual({ provider: 'Tool', names: ['cordis_inspect_list', 'cordis_inspect_query', 'run_code'] })
   })
 })
 

+ 105 - 0
apps/cli/tests/profiles/web/tests/creator-plugin-manager.expected.e2e.ts

@@ -0,0 +1,105 @@
+/** Built Web profile: MCP reaches existing/new Creator sessions and survives a process restart. */
+import { spawn } from 'node:child_process'
+import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { expect, it } from 'vitest'
+import { readProfileManifest } from '@deepseek-ai/dsh-app-boot'
+import { startHttpMcpFixture } from '../../../../../../packages/mcp/mcp-client/tests/http-fixture.ts'
+
+interface Observation {
+  approvals: string[]
+  permission: string
+  before: string[]
+  denied: { isError: boolean; content: unknown }
+  afterDenied: string[]
+  bundlesAfterDenied: { name: string }[]
+  after: string[]
+  other: string[]
+  remaining: string[]
+  result: unknown
+  ping: unknown
+  removed?: unknown
+}
+
+const repo = fileURLToPath(new URL('../../../../../../', import.meta.url))
+
+it('configures MCP on a live profile, restores it on restart, and removes its tools', async (test) => {
+  const root = await mkdtemp(join(tmpdir(), 'creator-manager-'))
+  test.onTestFinished(() => rm(root, { recursive: true, force: true }))
+  const mcp = await startHttpMcpFixture()
+  test.onTestFinished(mcp.close)
+  await mkdir(join(root, 'workspace'))
+  const bundle = join(root, 'demo-mcp')
+  await mkdir(bundle)
+  await writeFile(join(bundle, 'package.json'), JSON.stringify({ name: '@test/creator-mcp', version: '1.0.0',
+    dsh: { bundle: { patch: './cordis.patch.yml' } } }))
+  await writeFile(join(bundle, 'cordis.patch.yml'), JSON.stringify([{ insert: [{ id: 'demo',
+    name: '@deepseek-ai/dsh-mcp-client', config: { serverName: 'demo', transport: 'streamable-http',
+      url: mcp.url, failOnStartupError: true },
+  }] }]))
+  const patch = join(root, 'test.patch.yml')
+  await writeFile(patch, JSON.stringify([{ insert: [{ id: 'creator-manager-observer',
+    name: new URL('./fixtures/creator-plugin-manager.mjs', import.meta.url).href, config: { bundle },
+  }] }]))
+  const start = async () => {
+    const child = spawn(process.execPath, [join(repo, 'apps/cli/lib/bin.js'), '--profile', 'web', '--patch', patch,
+      '--port', '0', '--no-open'], { cwd: join(root, 'workspace'),
+      env: { ...process.env, DSH_HOME: join(root, 'home'), DSH_AGENTS_HOME: join(root, 'agents'),
+        DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-no-model-calls' },
+      stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
+    })
+    let output = ''
+    const completion = new Promise<void>((resolve, reject) => {
+      child.once('close', () => { resolve() })
+      child.once('error', reject)
+    })
+    const stop = async () => { if (child.exitCode === null) child.kill('SIGTERM'); await completion }
+    test.onTestFinished(stop)
+    for (const stream of [child.stdout, child.stderr]) stream!.on('data', (data) => { output = (output + String(data)).slice(-30_000) })
+    await expect.poll(() => {
+      if (child.exitCode !== null) throw new Error(output)
+      return output.includes('dsh web: http://')
+    }, { timeout: 60_000 }).toBe(true)
+    return { stop, request: (phase: string): Promise<Observation> => new Promise((resolve, reject) => {
+      child.once('message', (value: { result: Observation; error?: string }) => {
+        if (value.error !== undefined) reject(new Error(value.error))
+        else resolve(value.result)
+      })
+      child.send(phase)
+    }) }
+  }
+  const first = await start()
+  const initial = await first.request('initial')
+  expect(initial.before).toContain('plugin_manager')
+  for (const retired of ['cordis_define', 'cordis_run', 'cordis_stop', 'cordis_undefine', 'cordis_inspect_self']) {
+    expect(initial.before).not.toContain(retired)
+  }
+  expect(initial.before).not.toContain('mcp__demo__ping')
+  expect(initial.denied.isError).toBe(true)
+  expect(JSON.stringify(initial.denied.content)).toContain('the user rejected escalating')
+  expect(initial.afterDenied).toEqual(initial.before)
+  expect(initial.bundlesAfterDenied.map(bundle => bundle.name)).not.toContain('@test/creator-mcp')
+  expect(initial.approvals).toEqual(['rejected', 'allowed-once'])
+  expect(initial.permission).toBe('workspace-write')
+  expect(initial.result).toMatchObject({ application: 'applied', changed: true })
+  expect(initial.after).toContain('mcp__demo__ping')
+  expect(initial.other).toContain('mcp__demo__ping')
+  expect(JSON.stringify(initial.ping)).toContain('pong')
+  const saved = readProfileManifest('dsh', join(root, 'home/profiles/web'))
+  expect(saved.dsh?.profile?.bundles).toContain('@test/creator-mcp')
+  expect(saved.dependencies).toHaveProperty('@test/creator-mcp')
+  await first.stop()
+  const second = await start()
+  const restarted = await second.request('restart')
+  expect(restarted.result).toEqual(expect.arrayContaining([expect.objectContaining({ name: '@test/creator-mcp' })]))
+  expect(restarted.approvals).toEqual(['rejected', 'allowed-once'])
+  expect(restarted.permission).toBe('workspace-write')
+  expect(restarted.before).toContain('mcp__demo__ping')
+  expect(JSON.stringify(restarted.ping)).toContain('pong')
+  expect(restarted.removed).toMatchObject({ application: 'applied', changed: true })
+  expect(restarted.remaining).not.toContain('mcp__demo__ping')
+  expect(mcp.calls).toEqual(['ping', 'ping'])
+  await second.stop()
+})

+ 59 - 0
apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs

@@ -0,0 +1,59 @@
+/** Test-only IPC assertions over real Creator presets and profile management. */
+export const inject = ['agents', 'agentPresets', 'tools', 'pluginManager', 'permissionPresets']
+
+export function apply(ctx, config) {
+  const receive = message => {
+    if (message !== 'initial' && message !== 'restart') return
+    void inspect(message).then(result => process.send({ result }), error => process.send({ error: String(error.stack ?? error) }))
+  }
+  ctx.effect(() => {
+    process.on('message', receive)
+    return () => process.off('message', receive)
+  })
+  async function inspect(phase) {
+    const handles = []
+    let activeSession
+    let answer = 'rejected'
+    const approvals = []
+    const disposeApproval = ctx.on('approval/request', (request, next) => {
+      if (request.toolName !== 'plugin_manager') return next()
+      approvals.push(answer)
+      return Promise.resolve(answer)
+    }, { prepend: true })
+    const make = async id => {
+      const handle = await ctx.agents.create({ sessionId: id, cwd: process.cwd(),
+        setup: scope => ctx.agentPresets.mount(scope, 'cordis').then(() => undefined) })
+      handles.push(handle)
+      return handle.agent
+    }
+    const names = agent => ctx.tools.schemas(agent).map(tool => tool.name)
+    try {
+      const first = await make(`${phase}-first`)
+      const before = names(first)
+      const manage = args => ctx.tools.execute({ name: 'plugin_manager', arguments: args, agent: first,
+        callId: `${phase}-manage`, signal: new AbortController().signal })
+      ctx.permissionPresets.set(first.session, 'workspace-write')
+      first.session.append('turn/start', { turn: 1 })
+      activeSession = first.session
+      const denied = await manage({ action: 'install_bundle', target: config.bundle })
+      const afterDenied = names(first)
+      const bundlesAfterDenied = await ctx.pluginManager.listBundles()
+      answer = 'allowed-once'
+      const result = phase === 'initial'
+        ? JSON.parse((await manage({ action: 'install_bundle', target: config.bundle })).value)
+        : await ctx.pluginManager.listBundles()
+      const second = await make(`${phase}-second`)
+      const after = names(first)
+      const other = names(second)
+      const ping = await ctx.tools.execute({ name: 'mcp__demo__ping', arguments: {}, agent: first,
+        callId: `${phase}-ping`, signal: new AbortController().signal })
+      const removed = phase === 'restart'
+        ? JSON.parse((await manage({ action: 'remove_bundle', target: '@test/creator-mcp' })).value) : undefined
+      return { approvals, permission: ctx.permissionPresets.current(first.session), before, denied, afterDenied, bundlesAfterDenied, result, after, other, ping, removed, remaining: names(first) }
+    } finally {
+      disposeApproval()
+      activeSession?.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+      await Promise.all(handles.map(handle => handle.dispose()))
+    }
+  }
+}

+ 12 - 4
apps/cli/tests/web-agent-presets.e2e.ts

@@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
 import {
   boot,
+  initProfile,
   createProfileResolutionGeneration,
   loadOverlayPatches,
   loadProfile,
@@ -79,6 +80,8 @@ async function bootWeb(
     // moved into the presets that a host row still waits for. The boot audit
     // is that assertion.
     { id: 'webserver', disabled: true },
+    // This composition has no application readiness or file-watching lifecycle.
+    { id: 'hmr', disabled: true },
     // The web bundle's runtime row injects `webServer`, so it cannot
     // activate without the bound port disabled above. It owns dist serving
     // and the URL prompt line — surface glue, not anything that decides an
@@ -122,6 +125,7 @@ async function bootWeb(
   const home = dirname(settingsFile)
   const profileDir = join(home, 'profiles', 'spec')
   await mkdir(profileDir, { recursive: true })
+  if (profileBundles === undefined) initProfile(profileDir, ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
   // Product Bundles are installed into the Profile, not the dsh app. Model
   // pnpm's package link for only the selected products; their own production
   // dependencies resolve from the linked workspace packages, while shared
@@ -156,6 +160,10 @@ async function bootWeb(
   const rootConfig = join(profileDir, 'cordis.yml')
   await writeFile(rootConfig, '[]\n')
   return await boot('dsh-test', rootConfig, [...bundlePatches, ...overrides], async (bootCtx) => {
+    bootCtx.provide('profileContext', { name: 'spec', dir: profileDir, patchPath: profile.patchPath,
+      installAnchor: INSTALL_ANCHOR, home, cwd: home,
+      startedBundles: profileBundles ?? ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
+      overlays: overrides, telemetryDisabledEnv: '1' })
     await bootCtx.plugin(PluginPackages, { generation: resolution })
     bootCtx.provide('connection', {
       fetch: { register: () => () => {} },
@@ -351,12 +359,12 @@ describe('the shipped Web composition', () => {
     })
     try {
       const tools = toolNames(ctx, handle.agent)
-      // The self-referential toolset is what distinguishes this preset.
       expect(tools).toEqual(expect.arrayContaining([
-        'cordis_inspect_list', 'cordis_inspect_query', 'cordis_inspect_self',
-        'cordis_define', 'cordis_run', 'cordis_stop', 'cordis_undefine',
+        'cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager',
       ]))
-      // And it keeps the standard agent's own tools rather than replacing them.
+      for (const removed of ['cordis_define', 'cordis_run', 'cordis_stop', 'cordis_undefine', 'cordis_inspect_self']) {
+        expect(tools).not.toContain(removed)
+      }
       expect(tools).toEqual(expect.arrayContaining(['bash', 'read', 'edit', 'skill']))
       expect(tools).not.toContain('str_replace_editor')
       expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined()

+ 12 - 1
apps/desktop-host/src/index.ts

@@ -1,6 +1,6 @@
 /** Launch the Desktop profile through the Web application and report its URL to Electron. */
 
-import { join } from 'node:path'
+import { delimiter, join } from 'node:path'
 import { loadLayeredEnv, loadProfileDirectory } from '@deepseek-ai/dsh-app-boot'
 import { runProfile } from '@deepseek-ai/dsh/profile-boot'
 import type {} from '@deepseek-ai/dsh-client-connection'
@@ -20,6 +20,17 @@ async function main(): Promise<void> {
     resolvedProfile: { profile, installAnchor },
     patchFiles: [],
     args: ['--no-open', '--port', '19387'],
+    ...(process.argv[6] === undefined ? {} : {
+      packageManager: {
+        command: process.execPath,
+        args: ['--expose-internals', process.argv[6]],
+        env: {
+          ELECTRON_RUN_AS_NODE: '1',
+          DSH_DESKTOP_NODE_EXECUTABLE: process.execPath,
+          PATH: `${process.argv[7] ?? ''}${delimiter}${process.env.PATH ?? ''}`,
+        },
+      },
+    }),
   })
   const stop = async (): Promise<void> => {
     // Startup failure is reported by main; shutdown only owns a tree that booted.

+ 2 - 2
apps/desktop/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/desktop/README.md
-README.md: cd303f6557143e14aece0dd323286fbee41e54da
-README.zh.md: 55f70a092cbd42d3fc8802891014f5d147a7d2ca
+README.md: 8fe85a24ff3c544bf9bf4ae6477141c38eb95a4d
+README.zh.md: 456f96976bbfb899063e9b50fa24af851b4ace71

+ 2 - 0
apps/desktop/README.md

@@ -6,6 +6,8 @@ The desktop application is an Electron shell around the complete dsh Web applica
 
 Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog.
 
+Creator and the Web Plugin Manager use Desktop’s bundled pnpm under Electron Node mode without requiring pnpm on PATH. The private Node launcher environment applies only to package operations.
+
 ## Key technical decisions
 
 The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation.

+ 2 - 0
apps/desktop/README.zh.md

@@ -6,6 +6,8 @@
 
 Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。
 
+Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 pnpm,无需 PATH 中存在 pnpm。私有 Node 启动器环境仅应用于包操作。
+
 ## 关键技术决策
 
 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。

+ 3 - 0
apps/desktop/src/host-process.ts

@@ -74,6 +74,7 @@ export class DesktopHostProcess {
    * @param onFailure - Receives the first unexpected child failure, including after readiness.
    * @param primaryRuntime - Optional bundled dependency payload; when supplied, missing sibling
    *   `office-skills` resources fail Host startup.
+   * @param packageManager - Bundled pnpm entry and Node launcher directory, scoped to package operations.
    * @param profileResolution - Package resolution mode for the application-owned profile.
    */
   constructor(
@@ -85,6 +86,7 @@ export class DesktopHostProcess {
     private readonly onFailure?: (error: Error) => void,
     private readonly primaryRuntime?: string,
     private readonly profileResolution: 'link' | 'runtime' = 'link',
+    private readonly packageManager?: { readonly pnpm: string; readonly nodeBin: string },
   ) {}
 
   /**
@@ -102,6 +104,7 @@ export class DesktopHostProcess {
       this.projectDir,
       this.primaryRuntime ?? join(this.runtimeDir, '..', 'runtime', 'primary-runtime'),
       this.profileResolution,
+      ...this.packageManager === undefined ? [] : [this.packageManager.pnpm, this.packageManager.nodeBin],
     ], {
       cwd: this.projectDir,
       env: desktopNodeEnvironment(this.node, undefined, this.environment),

+ 1 - 1
apps/desktop/src/main.ts

@@ -219,7 +219,7 @@ async function main(): Promise<void> {
       hostInspectPort, process.env, onFailure,
       development ? join(app.getAppPath(), '.desktop-build', 'targets', `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`, 'runtime', 'primary-runtime')
         : join(process.resourcesPath, 'runtime', 'primary-runtime'),
-      development ? 'link' : 'runtime')
+      development ? 'link' : 'runtime', resources)
     return {
       start: async () => {
         const ready = await host.start()

+ 3 - 3
apps/desktop/tests/host-process.spec.ts

@@ -79,13 +79,13 @@ describe('desktop host process', () => {
 
   it('passes external dependencies and runtime profile resolution to the Host', async () => {
     const runtime = projectWithHost(HTTP_HOST.replace('runtime: process.argv[2]',
-      'primaryRuntime: process.argv[4], profileResolution: process.argv[5], runtime: process.argv[2]'))
+      'pnpm: process.argv[6], nodeBin: process.argv[7], primaryRuntime: process.argv[4], profileResolution: process.argv[5], runtime: process.argv[2]'))
     const primaryRuntime = join(runtime, 'external-primary-runtime')
     const host = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env,
-      undefined, primaryRuntime, 'runtime')
+      undefined, primaryRuntime, 'runtime', { pnpm: join(runtime, 'pnpm.mjs'), nodeBin: join(runtime, 'bin') })
     hosts.push(host)
     const { url } = await host.start()
-    expect(await (await fetch(url)).json()).toMatchObject({ primaryRuntime, profileResolution: 'runtime' })
+    expect(await (await fetch(url)).json()).toMatchObject({ primaryRuntime, profileResolution: 'runtime', pnpm: join(runtime, 'pnpm.mjs'), nodeBin: join(runtime, 'bin') })
   })
 
   it('reports a fatal event after readiness once', async () => {

+ 1 - 0
apps/desktop/tests/main-startup.spec.ts

@@ -64,6 +64,7 @@ const harness = await vi.hoisted(async () => {
       readonly node: string, readonly runtime: string, readonly profile: string,
       readonly inspectPort?: number, readonly environment?: NodeJS.ProcessEnv, readonly onFailure?: (error: Error) => void,
       readonly primaryRuntime?: string, readonly profileResolution?: string,
+      readonly packageManager?: { pnpm: string; nodeBin: string },
     ) { hosts.push(this) }
   }
   const app = Object.assign(new EventEmitter(), {

+ 1 - 0
apps/web/tests/agent-preset-authoring.e2e.ts

@@ -48,6 +48,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
     userRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-')))
     scaffold = await launchWebScaffold({
       extraOverlayPath: OVERLAY,
+      profile: { packages: [] },
       agentPresets: {
         // The shipped root is the plugin's own, prepended before this.
         roots: [{ path: userRoot, trust: 'user' }],

+ 31 - 187
apps/web/tests/cordis-tool-round.e2e.ts

@@ -1,99 +1,36 @@
-// Web e2e scenario for the opt-in Cordis tools. Record mode drives a real
-// model through inspect, define, run, and stop; replay pins the same shipped Web
-// composition, durable calls, Cordis-owned rows, the define card's own source view,
-// and conversation accessibility tree.
-//
-// The approval is never in the fixture. The fixture pins what the MODEL said;
-// tools execute for real, and this test answers the approval before starting the
-// stop turn. The package therefore carries a browser half whose only
-// job is to be visible (`[data-snapshot-probe]`): its absence before the answer
-// and presence after it is the v3 user gate, proven rather than described.
+/** Historical generated-plugin cards remain readable after their tool APIs are removed. */
 import { readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
-import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import { SessionId } from '@deepseek-ai/dsh-session/types'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
 import {
-  captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
-  launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
+  captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, readPersistedEvents, parseSeedFixture, realizeSeedFixture,
+  watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
-import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
+import { expandOwningTurnProcess, newEnglishPage } from './support.ts'
 
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/cordis-tool-round/session.v3.jsonl', import.meta.url))
-const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/cordis-tool-round/ui.expected.md', import.meta.url))
+const UI_EXPECTED = fileURLToPath(new URL('./expected/cordis-history/ui.expected.md', import.meta.url))
 const MODE = webSnapshotMode()
-const CORDIS_TOOLS = ['cordis_inspect_self', 'cordis_define', 'cordis_run', 'cordis_stop'] as const
-const PACKAGE_CODE = 'return { name: "snapshot-noop", apply(ctx) {} }'
-// The browser half is the PROBE this scenario turns on: it renders a marker into
-// the frame-wide overlay, so "did the plugin actually run in this page" becomes a
-// DOM fact. A host-only package would sidestep the approval round trip entirely
-// (the host runs those immediately), which would drop the v3 user gate out of
-// coverage — the one thing this scenario exists to prove.
-const CLIENT_CODE = 'return { inject: ["slots"], apply(ctx) { ctx.slots.register('
-  + '{ name: "shell.overlay", id: "snapshot-probe" }, '
-  + '() => React.createElement("div", { "data-snapshot-probe": "loaded" })) } }'
-const PROMPT = 'Use only Cordis tools. First call cordis_inspect_self with no arguments. '
-  + 'Then call cordis_define with plugin kind "new", idPrefix "snap", name "snapshot noop", '
-  + 'purpose "does nothing, for the snapshot", '
-  + `code.host exactly ${JSON.stringify(PACKAGE_CODE)} and code.client exactly ${JSON.stringify(CLIENT_CODE)}. `
-  + 'Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode "run". '
-  + 'After the run request returns, reply exactly CORDIS_UI_READY and stop.'
-const STOP_PROMPT = 'Use only Cordis tools. Call cordis_stop with pluginId "snap-1". '
-  + 'After it succeeds, reply exactly CORDIS_UI_DONE and stop.'
+const SEED_ID = 'cordis-history'
 
-function assertCompleteCordisLifecycle(events: readonly SessionEvent[]): void {
-  const turnEnd = events.findLast(
-    (event): event is Extract<SessionEvent, { type: 'turn/end' }> => event.type === 'turn/end',
-  )
-  const reason = turnEnd?.data.reason
-  expect(reason).toEqual({ kind: 'completed' })
-
-  const calls = events.filter(
-    (event): event is Extract<SessionEvent, { type: 'tool/call' }> => event.type === 'tool/call',
-  )
-  expect(calls.map(event => event.data.name)).toEqual(CORDIS_TOOLS)
-
-  const callIds = new Set(calls.map(event => String(event.data.callId)))
-  const results = events.filter(
-    (event): event is Extract<SessionEvent, { type: 'tool/result' }> =>
-      event.type === 'tool/result' && callIds.has(String(event.data.message.source.callId)),
-  )
-  expect(results).toHaveLength(CORDIS_TOOLS.length)
-  expect(results.every(event => !event.data.message.content[0].isError)).toBe(true)
-}
-
-describe('web e2e: Cordis tools use their owned cards', () => {
+describe.skipIf(MODE === 'record')('web e2e: historical Cordis cards', () => {
   let scaffold: WebScaffold
   let browser: Browser
   let page: Page
   let tripwire: ReturnType<typeof watchConsole>
-  const sessionEvents: SessionEvent[] = []
-  const modelFrames: string[] = []
-  const modelChanges: string[] = []
 
   beforeAll(async () => {
-    scaffold = await launchWebScaffold({
-      cordisTools: true,
-      compareReplaySession: true,
-      ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }),
-    })
-    scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
-    scaffold.ctx.sessionProjections.onChanged((_session, key, value, seq) => {
-      if (key === 'modelSelection') modelChanges.push(`${String(seq)}:${JSON.stringify(value)}`)
-    })
+    scaffold = await launchWebScaffold({})
+    await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID)
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
-    page.on('websocket', (socket) => {
-      socket.on('framereceived', (frame) => {
-        const payload = String(frame.payload)
-        if (payload.includes('modelSelection')) modelFrames.push(payload)
-      })
-    })
     tripwire = watchConsole(page)
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
-    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
-    await connectFreshWorkspace(page, scaffold.workspaceCwd)
+    await page.locator('[role="treeitem"]').first().click()
+    await page.locator('[role="treeitem"]').nth(1).click()
   }, 120_000)
 
   afterAll(async () => {
@@ -101,118 +38,25 @@ describe('web e2e: Cordis tools use their owned cards', () => {
     await scaffold?.close()
   })
 
-  it('drives the recorded Cordis lifecycle to a settled turn (all modes)', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-drive'))
-    if (MODE !== 'record') {
-      expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT, STOP_PROMPT])
-    }
-    const input = page.locator('[data-composer-input]').first()
-    await input.waitFor({ timeout: 10_000 })
-    const runTurnSettled = scaffold.whenTurnSettled()
-    await input.fill(PROMPT)
-    await input.press('Enter')
-
-    // The approval is the TEST's action in every mode: the fixture pins what the
-    // model said, and the gate is a real round trip through the real panel.
-    const approve = page.locator('[data-cordis-approve]').first()
-    await approve.waitFor({ timeout: 90_000 })
-    // The one assertion this scenario cannot give up: the model asking to run is
-    // NOT the plugin running. Until a person answers, the browser half has not
-    // been fetched, evaluated, or mounted anywhere on this page.
-    expect(await page.locator('[data-snapshot-probe]').count()).toBe(0)
-    const sessionId = await runTurnSettled
-    // Approving from idle makes the run-outcome steer a distinct continuation
-    // turn, matching the recorded replay and keeping turn grouping deterministic.
-    const approvalTurnSettled = scaffold.whenTurnSettled()
-    await approve.click()
-    await expect.poll(() => page.locator('[data-snapshot-probe]').count(), { timeout: 30_000 }).toBe(1)
-    await approvalTurnSettled
-    await expect.poll(() => page.getByText('The Cordis Plugin is running.', { exact: true }).count(), { timeout: 15_000 })
-      .toBeGreaterThanOrEqual(1)
-    await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true)
-    const stopTurnSettled = scaffold.whenTurnSettled()
-    await input.fill(STOP_PROMPT)
-    await input.press('Enter')
-    await stopTurnSettled
-    await expect.poll(() => {
-      const stop = sessionEvents.find(
-        (event): event is Extract<SessionEvent, { type: 'tool/call' }> =>
-          event.type === 'tool/call' && event.data.name === 'cordis_stop',
-      )
-      return stop !== undefined && sessionEvents.some(
-        event => event.type === 'tool/result'
-          && String(event.data.message.source.callId) === String(stop.data.callId),
-      )
-    }, { timeout: 15_000 }).toBe(true)
-    if (MODE === 'record') {
-      assertCompleteCordisLifecycle(sessionEvents)
-      await expect.poll(() => page.getByText('CORDIS_UI_DONE', { exact: true }).count(), { timeout: 15_000 })
-        .toBeGreaterThanOrEqual(1)
-      await recordFixture(scaffold, sessionId, FIXTURE)
-    }
-  }, 200_000)
-
-  it.skipIf(MODE === 'record')('the durable log carries one complete Cordis lifecycle', () => {
-    assertCompleteCordisLifecycle(sessionEvents)
-  })
-
-  it.skipIf(MODE === 'record')('renders localized Cordis lifecycle cards', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-rows'))
-    await expect.poll(() => page.getByText('CORDIS_UI_DONE', { exact: true }).count(), { timeout: 15_000 })
-      .toBeGreaterThanOrEqual(1)
-
-    const inspectRow = page.locator('[data-tool="cordis_inspect_self"]').filter({ hasText: 'Inspect' }).first()
-    await expandOwningTurnProcess(page, inspectRow)
-    await inspectRow.waitFor({ timeout: 10_000 })
-
-    // cordis_define does NOT go through the generic row: ui-cordis registers a
-    // keyed toolview for it, and a keyed hit replaces the generic card. So the
-    // title here is the CARD's ("Cordis Plugin"), and the expanded body is the
-    // card's own two code sections rather than a generic args dump.
-    const defineRow = page.locator('[data-tool="cordis_define"]').filter({ hasText: 'Cordis Plugin' }).first()
-    await expandOwningTurnProcess(page, defineRow)
-    await defineRow.waitFor({ timeout: 10_000 })
-    // The whole summary row is the expand toggle (unified tool-row interaction).
-    await defineRow.locator('[aria-expanded]').first().click()
-    await expect.poll(() => defineRow.textContent(), { timeout: 10_000 }).toContain('data-snapshot-probe')
-    await defineRow.getByRole('tab', { name: 'Host' }).click()
-    await expect.poll(() => defineRow.textContent()).toContain(PACKAGE_CODE)
-
-    const runRow = page.locator('[data-tool="cordis_run"]').filter({ hasText: 'Run Cordis Plugin' }).first()
-    await expandOwningTurnProcess(page, runRow)
-    await runRow.waitFor({ timeout: 10_000 })
-    await expect.poll(() => runRow.textContent()).toContain('snap-')
-
-    const stopRow = page.locator('[data-tool="cordis_stop"]').filter({ hasText: 'Stop Cordis Plugin' }).first()
-    await expandOwningTurnProcess(page, stopRow)
-    await stopRow.waitFor({ timeout: 10_000 })
-    await expect.poll(() => stopRow.textContent()).toContain('snap-')
-    await expect(stopRow.getAttribute('data-state')).resolves.toBe('ok')
-    // Stopping withdraws the browser half from every page, probe included.
-    await expect.poll(() => page.locator('[data-snapshot-probe]').count(), { timeout: 15_000 }).toBe(0)
-  })
-
-  it.skipIf(MODE === 'record')('matches the conversation aria golden', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-aria'))
-    console.log('MODEL_TRACE', { modelChanges, frameCount: modelFrames.length, modelFrames })
-    // Final Assistant text precedes turn/end. Three footers prove every turn
-    // reached the render state covered by the ARIA golden.
-    await expect.poll(
-      () => page.getByRole('button', { name: 'Branch into a new conversation', exact: true }).count(),
-      { timeout: 15_000 },
-    ).toBe(3)
-    await page.locator('[data-conversation-scroll]').evaluate((host) => { host.scrollTop = host.scrollHeight })
-    await expect.poll(
-      async () => page.getByRole('button', { name: 'Back to bottom', exact: true }).count(),
-      { timeout: 10_000 },
-    ).toBe(0)
-    await page.mouse.move(0, 0)
-    const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+  it('renders recorded definition source and lifecycle results without registering retired tools', async () => {
+    const persisted = await readPersistedEvents(scaffold, SessionId(SEED_ID))
+    const expected = parseSeedFixture(realizeSeedFixture(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID)).events
+    const tools = (events: typeof persisted) => events.filter(event => event.type === 'tool/call' || event.type === 'tool/result')
+      .map(event => ({ type: event.type, data: event.data }))
+    expect(tools(persisted)).toEqual(tools(expected))
+    const names = scaffold.ctx.tools.schemas().map(tool => tool.name)
+    expect(names).not.toEqual(expect.arrayContaining(['cordis_define']))
+    const define = page.locator('[data-tool="cordis_define"]').first()
+    await expandOwningTurnProcess(page, define)
+    await define.locator('[aria-expanded]').first().click()
+    await define.getByRole('tab', { name: 'Host' }).click()
+    await expect.poll(() => define.textContent()).toContain('snapshot-noop')
+    const stop = page.locator('[data-tool="cordis_stop"]').first()
+    await expandOwningTurnProcess(page, stop)
+    await expect.poll(() => stop.getAttribute('data-state')).toBe('ok')
+    const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
+      .split(SEED_ID).join('{{seededId}}')
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
-  })
-
-  it.skipIf(MODE === 'record')('stayed clean: no page errors or reconnect churn', () => {
     expect(tripwire.pageErrors).toEqual([])
-    expect(tripwire.warnings).toEqual([])
   })
 })

+ 1 - 1
apps/web/tests/expected/agent-preset-authoring/created.expected.md

@@ -59,7 +59,7 @@
         - text: 复制
     - listitem:
       - 'button "设为默认: 创造模式"':
-        - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。
+        - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。
         - code: cordis
       - 'button "查看: 创造模式"':
         - img

+ 1 - 1
apps/web/tests/expected/agent-preset-authoring/damaged.expected.md

@@ -59,7 +59,7 @@
         - text: 复制
     - listitem:
       - 'button "设为默认: 创造模式"':
-        - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。
+        - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。
         - code: cordis
       - 'button "查看: 创造模式"':
         - img

+ 1 - 1
apps/web/tests/expected/agent-preset-authoring/section.expected.md

@@ -59,7 +59,7 @@
         - text: 复制
     - listitem:
       - 'button "设为默认: 创造模式"':
-        - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。
+        - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。
         - code: cordis
       - 'button "查看: 创造模式"':
         - img

+ 1 - 1
apps/web/tests/expected/agent-preset-selection/menu.expected.md

@@ -4,5 +4,5 @@
     - img
   - menuitem "PTC mode Full coding agent without the workflow tool; other tools are exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program."
   - menuitem "Minimal mode Single-tool coding agent with a persistent shell."
-  - menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance."
+  - menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, persistent plugin management, and preset-authoring guidance."
   - menuitem "Refusing mode Resolves, then refuses to start."

+ 6 - 6
snapshots/web/cordis-tool-round/ui.expected.md → apps/web/tests/expected/cordis-history/ui.expected.md

@@ -1,8 +1,6 @@
 - banner:
   - navigation "Session hierarchy":
     - button "Use only Cordis tools. First" [disabled]
-  - img
-  - text: Standard mode
   - button "More actions":
     - img
   - button "Open right sidebar":
@@ -18,7 +16,7 @@
   - img
   - img
   - text: System prompt
-- text: "Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop. {{clock}}"
+- text: "Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop. 9/1 {{clock}}"
 - button "Copy":
   - img
 - button "3 tool calls" [expanded]:
@@ -76,7 +74,7 @@
 - button "Ran for {{duration}}":
   - img
   - text: Ran for {{duration}}
-- text: {{clock}}
+- text: 9/1 {{clock}}
 - button "Thought for a while":
   - text: Thought for a while
   - img
@@ -92,7 +90,7 @@
 - button "Ran for {{duration}}":
   - img
   - text: Ran for {{duration}}
-- text: {{clock}} Use only Cordis tools. Call cordis_stop with pluginId "snap-1". After it succeeds, reply exactly CORDIS_UI_DONE and stop. {{clock}}
+- text: 9/1 {{clock}} Use only Cordis tools. Call cordis_stop with pluginId "snap-1". After it succeeds, reply exactly CORDIS_UI_DONE and stop. 9/1 {{clock}}
 - button "Copy":
   - img
 - button "1 tool call" [expanded]:
@@ -114,7 +112,9 @@
 - button "Ran for {{duration}}":
   - img
   - text: Ran for {{duration}}
-- text: {{clock}}
+- text: 9/1 {{clock}}
+- button "Back to bottom":
+  - img
 - textbox "Message or run a task, / commands, @ files or sessions"
 - button "Add files or run commands":
   - img

+ 84 - 0
apps/web/tests/expected/models-settings/catalog-inputs.expected.md

@@ -0,0 +1,84 @@
+- dialog "设置":
+  - navigation:
+    - text: 设置
+    - button "通用设置":
+      - img
+      - text: 通用设置
+    - button "模型":
+      - img
+      - text: 模型
+    - button "插件":
+      - img
+      - text: 插件
+    - button "Agent 预设":
+      - img
+      - text: Agent 预设
+    - button "已归档会话":
+      - img
+      - text: 已归档会话
+  - button "打开配置文件"
+  - button "关闭":
+    - img
+    - text: 关闭
+  - heading "模型" [level=2]
+  - paragraph: 填入各提供方的 API 密钥即可使用其模型。
+  - list:
+    - listitem:
+      - text: minimax-cn
+      - img "API 密钥已配置"
+      - button "编辑 minimax-cn": 编辑
+      - button "删除 minimax-cn": 删除
+    - listitem:
+      - text: openai
+      - button "编辑 openai": 编辑
+      - button "删除 openai": 删除
+      - text: openai API 密钥
+      - textbox "API 密钥":
+        - /placeholder: 输入 API 密钥,或留空使用环境认证
+      - group:
+        - text: 自定义设置 API 地址
+        - textbox "API 地址":
+          - /placeholder: 提供方默认
+        - region "模型目录":
+          - text: 模型目录 已自定义模型目录
+          - button "恢复默认模型"
+          - button "获取可用模型"
+          - textbox "模型 ID 1":
+            - /placeholder: 模型 ID
+            - text: gpt-6-astra
+          - textbox "显示名称 1":
+            - /placeholder: 显示名称
+            - text: GPT-6 Astra
+          - button "模型选项 1" [expanded]:
+            - img
+          - button "删除模型 1":
+            - img
+          - text: 上下文窗口
+          - textbox "上下文窗口 1":
+            - /placeholder: 256K
+            - text: 272K
+          - text: 最大输出 token 数
+          - textbox "最大输出 token 数 1":
+            - /placeholder: 32K
+            - text: 128K
+          - group "输入类型 1":
+            - text: 输入类型
+            - checkbox "文本" [checked]
+            - text: 文本
+            - checkbox "图片" [checked]
+            - text: 图片
+          - button "添加模型":
+            - img
+            - text: 添加模型
+      - button "取消"
+      - button "保存"
+    - listitem:
+      - text: Acme 网关 自定义
+      - button "编辑 Acme 网关 (acme-gateway)": 编辑
+      - button "删除 Acme 网关 (acme-gateway)": 删除
+  - button "添加提供方":
+    - img
+    - text: 添加提供方
+  - button "添加自定义提供方":
+    - img
+    - text: 添加自定义提供方

+ 15 - 10
apps/web/tests/expected/models-settings/declared-edit.expected.md

@@ -58,20 +58,25 @@
             - text: acme-large
           - textbox "显示名称 1":
             - /placeholder: 显示名称
-          - button "模型选项 1" [expanded]
-          - button "删除模型 1"
+          - button "模型选项 1" [expanded]:
+            - img
+          - button "删除模型 1":
+            - img
           - text: 上下文窗口
           - textbox "上下文窗口 1":
             - /placeholder: 256K
-          - text: 最大输出 token
-          - textbox "最大输出 token 1":
+          - text: 最大输出 token
+          - textbox "最大输出 token 1":
             - /placeholder: 32K
-          - text: 图片输入
-          - combobox "图片输入 1":
-            - option "使用默认值"
-            - option "支持" [selected]
-            - option "不支持"
-          - button "添加模型"
+          - group "输入类型 1":
+            - text: 输入类型
+            - checkbox "文本" [checked]
+            - text: 文本
+            - checkbox "图片" [checked]
+            - text: 图片
+          - button "添加模型":
+            - img
+            - text: 添加模型
       - button "取消"
       - button "保存"
   - button "添加提供方":

+ 6 - 5
apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md

@@ -54,11 +54,12 @@
           - text: 最大输出 token 数
           - textbox "最大输出 token 数 1":
             - /placeholder: 256K
-          - text: 图片输入
-          - combobox "图片输入 1":
-            - option "默认(仅文本)"
-            - option "支持" [selected]
-            - option "不支持"
+          - group "输入类型 1":
+            - text: 输入类型
+            - checkbox "文本" [checked]
+            - text: 文本
+            - checkbox "图片" [checked]
+            - text: 图片
           - textbox "模型 ID 2":
             - /placeholder: 模型 ID
             - text: deepseek-v4-pro

+ 6 - 5
apps/web/tests/expected/onboarding-deepseek-config/models.expected.md

@@ -56,11 +56,12 @@
           - textbox "最大输出 token 数 1":
             - /placeholder: 256K
             - text: 64K
-          - text: 图片输入
-          - combobox "图片输入 1":
-            - option "默认(仅文本)"
-            - option "支持" [selected]
-            - option "不支持"
+          - group "输入类型 1":
+            - text: 输入类型
+            - checkbox "文本" [checked]
+            - text: 文本
+            - checkbox "图片" [checked]
+            - text: 图片
           - button "添加模型":
             - img
             - text: 添加模型

+ 13 - 0
apps/web/tests/expected/workspace-management/grouping-options.expected.md

@@ -0,0 +1,13 @@
+- menu:
+  - text: Group by
+  - menuitem "WorkSpace":
+    - text: WorkSpace
+    - img
+  - menuitem "Workspace Tree"
+  - menuitem "In one list"
+  - separator
+  - text: Order by
+  - menuitem "Manual"
+  - menuitem "Last updated":
+    - text: Last updated
+    - img

+ 11 - 0
apps/web/tests/expected/workspace-management/parent-folders.expected.md

@@ -0,0 +1,11 @@
+- treeitem "folder-group" [expanded]:
+  - img
+  - text: folder-group
+- group:
+  - treeitem "project-two" [expanded]:
+    - img
+    - text: project-two
+  - treeitem "New Session" [selected]
+  - treeitem "project-one" [expanded]:
+    - img
+    - text: project-one

+ 31 - 0
apps/web/tests/model-input-layout.ts

@@ -0,0 +1,31 @@
+/** Model input types stay below both capacity fields across editor widths. */
+import type { Locator, Page } from 'playwright'
+import { expect } from 'vitest'
+
+/**
+ * Verify the expanded row at the current viewport and a narrow viewport.
+ * @param page - page with an explicit viewport, restored even when an assertion fails.
+ * @param editor - expanded model editor containing the capacity and input-type fields.
+ * @returns after checking both widths and restoring the caller's size.
+ */
+export async function assertModelInputLayout(page: Page, editor: Locator): Promise<void> {
+  const original = page.viewportSize()
+  if (original === null) throw new Error('Model input layout checks require an explicit viewport')
+  try {
+    for (const viewport of [original, { width: 760, height: 1000 }]) {
+      await page.setViewportSize(viewport)
+      const context = await editor.getByLabel('上下文窗口 1', { exact: true }).boundingBox()
+      const output = await editor.getByLabel('最大输出 token 数 1', { exact: true }).boundingBox()
+      const types = await editor.getByRole('group', { name: '输入类型 1' }).boundingBox()
+      expect(context).not.toBeNull()
+      expect(output).not.toBeNull()
+      expect(types).not.toBeNull()
+      if (context === null || output === null || types === null) throw new Error('Expanded model fields are not visible')
+      expect(Math.abs(context.y - output.y)).toBeLessThan(1)
+      expect(types.y).toBeGreaterThanOrEqual(Math.max(context.y + context.height, output.y + output.height))
+      expect(types.width).toBeGreaterThan(context.width)
+    }
+  } finally {
+    await page.setViewportSize(original)
+  }
+}

+ 89 - 11
apps/web/tests/models-settings.e2e.ts

@@ -13,6 +13,7 @@
 // never shadow the derived reference. The deletion dialog distinguishes a
 // reference-free profile from a page-managed key before the credential and
 // settings unsets reach the wire.
+import { assertModelInputLayout } from './model-input-layout.ts'
 import { readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
@@ -56,6 +57,18 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     await scaffold?.close()
   })
 
+  it('rejects layout checks without an explicit viewport before resizing the page', async () => {
+    const context = await browser.newContext({ viewport: null })
+    try {
+      const unsized = await context.newPage()
+      await expect(assertModelInputLayout(unsized, unsized.locator('body')))
+        .rejects.toThrow('Model input layout checks require an explicit viewport')
+      expect(unsized.viewportSize()).toBeNull()
+    } finally {
+      await context.close()
+    }
+  })
+
   it('opens the add card over the dormant directory vocabulary', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-models-empty'))
     await page.getByRole('button', { name: '设置', exact: true }).click()
@@ -239,8 +252,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     await dialog.getByRole('button', { name: '添加模型' }).click()
     await dialog.getByLabel('模型 ID 1').fill('acme-large')
     await dialog.getByRole('button', { name: '模型选项 1' }).click()
-    expect(await dialog.getByLabel('图片输入 1').inputValue()).toBe('default')
-    await dialog.getByLabel('图片输入 1').selectOption('enabled')
+    expect(await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false)
+    await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check()
     await dialog.getByRole('button', { name: '创建提供方', exact: true }).click()
 
     const row = dialog.getByText('Acme Gateway', { exact: true }).first()
@@ -276,13 +289,14 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     const name = dialog.getByLabel('显示名称', { exact: true })
     expect(await name.inputValue()).toBe('Acme Gateway')
     await dialog.getByRole('button', { name: '模型选项 1' }).click()
-    expect(await dialog.getByLabel('图片输入 1').inputValue()).toBe('enabled')
+    expect(await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(true)
+    await assertModelInputLayout(page, dialog)
     const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(DECLARED_EDIT_EXPECTED, snapshot, MODE)
 
     await protocol.selectOption('anthropic-messages')
     await name.fill('Acme 网关')
-    await dialog.getByLabel('图片输入 1').selectOption('disabled')
+    await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).uncheck()
     await dialog.getByRole('button', { name: '保存', exact: true }).click()
     await expect.poll(async () => dialog.getByLabel('API 协议').count(), { timeout: 10_000 }).toBe(0)
     // The adapter re-resolved the route under the new protocol and re-registered
@@ -302,29 +316,93 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
-  it('restores the provider default for image input', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-models-image-default'))
+  it('saves image-only input and reopens the same selection', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-models-input-types'))
     const dialog = page.getByRole('dialog', { name: '设置' })
     await dialog.getByRole('button', { name: '编辑 Acme 网关 (acme-gateway)' }).click()
     await dialog.getByText('自定义设置').click()
     await dialog.getByRole('button', { name: '模型选项 1' }).click()
-    expect(await dialog.getByLabel('图片输入 1').inputValue()).toBe('disabled')
-    await dialog.getByLabel('图片输入 1').selectOption('default')
+    expect(await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false)
+    const inputs = dialog.getByRole('group', { name: '输入类型 1' })
+    expect(await inputs.getByRole('checkbox', { name: '文本' }).isChecked()).toBe(true)
+    expect(await inputs.getByRole('checkbox', { name: '文本' }).isDisabled()).toBe(true)
+    await inputs.getByRole('checkbox', { name: '图片' }).check()
+    await inputs.getByRole('checkbox', { name: '文本' }).uncheck()
     await dialog.getByRole('button', { name: '保存', exact: true }).click()
     await dialog.getByLabel('模型 ID 1').waitFor({ state: 'detached', timeout: 10_000 })
     await expect(scaffold.ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).resolves.toMatchObject({
-      inputModalities: ['text'],
+      inputModalities: ['image'],
     })
     await dialog.getByRole('button', { name: '编辑 Acme 网关 (acme-gateway)' }).click()
     await dialog.getByText('自定义设置').click()
     await dialog.getByRole('button', { name: '模型选项 1' }).click()
-    expect(await dialog.getByLabel('图片输入 1').inputValue()).toBe('default')
+    expect(await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(true)
+    expect(await inputs.getByRole('checkbox', { name: '文本' }).isChecked()).toBe(false)
+    expect(await inputs.getByRole('checkbox', { name: '图片' }).isDisabled()).toBe(true)
     await dialog.getByRole('button', { name: '取消', exact: true }).click()
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
+  it('inherits installed vision input and retains it when adopting a discovered model', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-models-catalog-inputs'))
+    await scaffold.ctx.settings.mutate('llm-pi-ai', [{
+      op: 'set', path: ['providers', 'openai'],
+      value: { models: [{ id: 'gpt-6-astra', name: 'GPT-6 Astra', contextWindow: 272000, maxTokens: 128000 }] },
+    }])
+    const dialog = page.getByRole('dialog', { name: '设置' })
+    const edit = dialog.getByRole('button', { name: '编辑 openai', exact: true })
+    try {
+      await edit.click()
+      await dialog.getByText('自定义设置').click()
+      await dialog.getByRole('button', { name: '模型选项 1' }).click()
+      const types = dialog.getByRole('group', { name: '输入类型 1' })
+      const image = types.getByRole('checkbox', { name: '图片', exact: true })
+      await expect.poll(() => image.isChecked()).toBe(true)
+      expect(await types.getByRole('checkbox', { name: '文本', exact: true }).isChecked()).toBe(true)
+      const before = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+      await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'catalog-inputs.expected.md'),
+        await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE)
+      await dialog.getByRole('button', { name: '保存', exact: true }).click()
+      await types.waitFor({ state: 'detached' })
+      expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toBe(before)
+
+      await edit.click()
+      await dialog.getByText('自定义设置').click()
+      await dialog.getByRole('button', { name: '模型选项 1' }).click()
+      await expect.poll(() => image.isEnabled()).toBe(true)
+      await image.uncheck()
+      await dialog.getByRole('button', { name: '保存', exact: true }).click()
+      await types.waitFor({ state: 'detached' })
+      await expect(scaffold.ctx.llm.resolveModelInfo('openai', 'gpt-6-astra')).resolves.toMatchObject({ inputModalities: ['text'] })
+      await edit.click()
+      await dialog.getByText('自定义设置').click()
+      await dialog.getByRole('button', { name: '模型选项 1' }).click()
+      await expect.poll(() => image.isEnabled()).toBe(true)
+      expect(await image.isChecked()).toBe(false)
+
+      await dialog.getByRole('button', { name: '删除模型 1' }).click()
+      await dialog.getByRole('button', { name: '获取可用模型' }).click()
+      const picker = page.getByRole('dialog', { name: '选择要添加的模型' })
+      await picker.getByRole('button', { name: '取消全选' }).click()
+      await picker.getByRole('searchbox', { name: '搜索模型' }).fill('gpt-6-astra')
+      await picker.getByRole('checkbox', { name: 'gpt-6-astra', exact: true }).check()
+      await picker.getByRole('button', { name: '添加所选' }).click()
+      await dialog.getByRole('button', { name: '模型选项 1' }).click()
+      expect(await image.isChecked()).toBe(true)
+      await dialog.getByRole('button', { name: '保存', exact: true }).click()
+      await types.waitFor({ state: 'detached' })
+      await expect(scaffold.ctx.llm.resolveModelInfo('openai', 'gpt-6-astra')).resolves.toMatchObject({ inputModalities: ['text', 'image'] })
+    } finally {
+      await scaffold.ctx.settings.mutate('llm-pi-ai', [{ op: 'unset', path: ['providers', 'openai'] }])
+      await edit.waitFor({ state: 'detached' })
+      await page.getByRole('dialog', { name: '选择要添加的模型' }).waitFor({ state: 'detached' })
+    }
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
   it('confirms an identified provider deletion before removing its profile and key', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
+    expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).not.toContain('openai:')
     const settingsDialog = page.getByRole('dialog', { name: '设置' })
     await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
     const deleteDialog = page.getByRole('dialog', { name: '删除 minimax-cn?' })
@@ -359,7 +437,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
       'configured.expected.md', 'declared-edit.expected.md', 'declared.expected.md',
       'delete.expected.md', 'empty.expected.md', 'model-picker.expected.md',
-      'native-delete.expected.md',
+      'native-delete.expected.md', 'catalog-inputs.expected.md',
     ])
   })
 })

+ 7 - 5
apps/web/tests/onboarding-deepseek-config.e2e.ts

@@ -3,6 +3,7 @@
 // and the inline key write lands in an isolated harness home without a reload
 // or model call.
 import { randomBytes } from 'node:crypto'
+import { assertModelInputLayout } from './model-input-layout.ts'
 import { readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
@@ -208,11 +209,12 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     expect(await settings.getByLabel('模型 ID 2').inputValue()).toBe('deepseek-v4-pro')
     expect(await settings.getByRole('button', { name: /删除模型/ }).count()).toBe(2)
     await settings.getByRole('button', { name: '模型选项 1' }).click()
-    expect(await settings.getByLabel('图片输入 1').inputValue()).toBe('enabled')
+    expect(await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(true)
+    await assertModelInputLayout(page, settings)
     const defaultModels = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(DEFAULT_MODELS_EXPECTED, defaultModels, MODE)
     await settings.getByLabel('显示名称 1').fill('Configured Flash')
-    await settings.getByLabel('图片输入 1').selectOption('disabled')
+    await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).uncheck()
     await settings.getByRole('button', { name: '保存', exact: true }).click()
     await settings.getByLabel('模型 ID 1').waitFor({ state: 'detached', timeout: 15_000 })
     const savedDefaults = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
@@ -238,8 +240,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     await settings.getByRole('button', { name: '模型选项 1' }).click()
     await settings.getByLabel('上下文窗口 1').fill('131072')
     await settings.getByLabel('最大输出 token 数 1').fill('64K')
-    expect(await settings.getByLabel('图片输入 1').inputValue()).toBe('default')
-    await settings.getByLabel('图片输入 1').selectOption('enabled')
+    expect(await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false)
+    await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check()
 
     await expect.poll(
       () => settings.getByLabel('API 密钥', { exact: true }).getAttribute('placeholder'),
@@ -262,7 +264,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
     await settings.getByText('自定义设置').click()
     await settings.getByRole('button', { name: '模型选项 1' }).click()
-    expect(await settings.getByLabel('图片输入 1').inputValue()).toBe('enabled')
+    expect(await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(true)
     await settings.getByRole('button', { name: '取消', exact: true }).click()
 
     await page.keyboard.press('Escape')

+ 0 - 13
apps/web/tests/scaffold.ts

@@ -378,12 +378,6 @@ export interface LaunchOptions {
    * insertion is needed.
    */
   toolsMode?: 'native' | 'ptc' | 'both'
-  /**
-   * Insert the opt-in model-facing Cordis tool provider into the shipped tree.
-   * Record and replay use the same tool surface, so captured request headers
-   * remain reconstructable without making the tools a product default.
-   */
-  cordisTools?: boolean
   /**
    * Keep the shipped DeepSeek adapter mounted while masking the process
    * environment's DEEPSEEK_API_KEY for this scaffold lifetime. This is the
@@ -658,13 +652,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
       // 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 } }],
-    // The shipped Web bundle already owns both runners and the Cordis UI. This
-    // scenario adds only the model-facing tools that exercise those services.
-    ...options.cordisTools === true
-      ? [{ insert: [
-        { id: 'tool-cordis', name: '@deepseek-ai/dsh-tool-cordis' },
-      ] }]
-      : [],
     ...options.deepSeekSearch === undefined
       ? []
       : [{

+ 1 - 1
apps/web/tests/schedule-after.e2e.ts

@@ -699,7 +699,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
     expect(await flatRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1)
 
     await page.getByRole('button', { name: 'View options' }).click()
-    await page.getByRole('menuitem', { name: 'WorkSpace' }).click()
+    await page.getByRole('menuitem', { name: 'WorkSpace', exact: true }).click()
     await catalogRow.waitFor({ timeout: 15_000 })
     expect(await catalogRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1)
 

+ 93 - 3
apps/web/tests/workspace-management.e2e.ts

@@ -4,7 +4,7 @@
 // typed draft, same-basename directory adoption, the rename round
 // trip over the real wire (workspace.rename RPC + durable registry), the
 // duplicate-name pre-check, the
-// flat "In one list" view with its persisted group-by preference, the session
+// flat "In one list" and opt-in Workspace tree views with persisted grouping, the session
 // hover card and row action menu, and the session archive round trip (row
 // menu → workspace.archiveSession RPC → durable global set → row hidden
 // across reload). Zero model calls: workspace.create/rename/archiveSession
@@ -37,7 +37,7 @@ const SEED_ID = 'workspace-management-web-e2e'
 const POINTER_TRANSIT_MS = 300
 const POINTER_HOLD_MS = 600
 
-describe('web e2e: workspace management (create / rename / flat view / hover affordances)', () => {
+describe('web e2e: workspace management (create / rename / grouping / hover affordances)', () => {
   let scaffold: WebScaffold
   let browser: Browser
   let page: Page
@@ -430,7 +430,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     acknowledgeReloadConnectionLoss(tripwire, warningStart)
     await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 15_000 }).toBe(0)
     await page.getByRole('button', { name: 'View options' }).click()
-    await page.getByRole('menuitem', { name: 'WorkSpace' }).click()
+    await page.getByRole('menuitem', { name: 'WorkSpace', exact: true }).click()
     await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
     expect(tripwire.pageErrors).toEqual([])
   }, 90_000)
@@ -662,6 +662,96 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     expect(tripwire.pageErrors).toEqual([])
   }, 90_000)
 
+  it('opts into Workspace tree grouping and preserves the parent Workspace session', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-parent-folders'))
+    const parentPath = join(scaffold.workspaceCwd, 'folder-group')
+    await mkdir(parentPath)
+    await addNewFolderWorkspace(parentPath, 'project-one')
+    const childWorkspace = (await scaffold.ctx.workspaceRegistry.resolveByPath(join(parentPath, 'project-one')))!
+    const childSessionIds = [...childWorkspace.sessionIds]
+    const workspaceCount = scaffold.ctx.workspaceRegistry.list().length
+    const agentCount = scaffold.ctx.agents.list().length
+    await adoptDirectory(parentPath, { waitForAgent: true })
+    expect(await page.getByRole('dialog', { name: 'Add workspace', exact: true }).count()).toBe(0)
+    expect(scaffold.ctx.workspaceRegistry.list()).toHaveLength(workspaceCount + 1)
+    expect(scaffold.ctx.agents.list()).toHaveLength(agentCount + 1)
+    expect([...childWorkspace.sessionIds]).toEqual(childSessionIds)
+    const parentWorkspace = (await scaffold.ctx.workspaceRegistry.resolveByPath(parentPath))!
+    expect(parentWorkspace.sessionIds).toHaveLength(1)
+    const parent = page.getByRole('treeitem').filter({ has: page.getByText('folder-group', { exact: true }) })
+    const section = parent.locator('xpath=ancestor::*[contains(@class, "groupSection")][1]')
+    await page.getByRole('tree', { name: 'Sessions', exact: true }).getByText('project-one', { exact: true }).waitFor()
+    expect(await section.getByText('project-one', { exact: true }).count()).toBe(0)
+    await page.getByRole('button', { name: 'View options', exact: true }).click()
+    const optionsExpected = fileURLToPath(new URL('./expected/workspace-management/grouping-options.expected.md', import.meta.url))
+    await compareOrRefreshGolden(optionsExpected, await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), MODE)
+    await page.getByRole('menuitem', { name: 'Workspace Tree', exact: true }).click()
+    await section.getByText('project-one', { exact: true }).waitFor()
+    await addNewFolderWorkspace(parentPath, 'project-two')
+    const project = section.getByRole('treeitem', { name: 'project-two', exact: true })
+    const session = section.locator('[aria-selected="true"]')
+    await session.waitFor()
+    const parentBounds = (await parent.boundingBox())!
+    for (const row of [project, session]) {
+      const bounds = (await row.boundingBox())!
+      expect(bounds.x).toBeCloseTo(parentBounds.x, 0)
+      expect(bounds.width).toBeCloseTo(parentBounds.width, 0)
+    }
+    const parentLabel = (await parent.getByText('folder-group', { exact: true }).boundingBox())!
+    const projectLabel = (await project.getByText('project-two', { exact: true }).boundingBox())!
+    expect(projectLabel.x - parentLabel.x).toBeCloseTo(12, 0)
+    const expected = fileURLToPath(new URL('./expected/workspace-management/parent-folders.expected.md', import.meta.url))
+    await compareOrRefreshGolden(expected, await captureStableAria(
+      page, '[aria-label="Workspace actions for folder-group"] >> xpath=ancestor::*[contains(@class, "groupSection")][1]',
+      scaffold.workspaceCwd,
+    ), MODE)
+    const sourceWorkspace = scaffold.ctx.workspaceRegistry.list().find(workspace => workspace.title === 'xx')!
+    await sourceWorkspace.setTitle('drag-source')
+    const dragSource = page.getByRole('treeitem').filter({ has: page.getByText('drag-source', { exact: true }) })
+    await dragSource.waitFor()
+    await dragSource.dragTo(parent, { targetPosition: { x: 10, y: 3 } })
+    await expect.poll(() => {
+      const ordered = scaffold.ctx.workspaceRegistry.list()
+      return ordered.findIndex(workspace => workspace.id === sourceWorkspace.id)
+        < ordered.findIndex(workspace => workspace.id === parentWorkspace.id)
+    }, { timeout: 10_000 }).toBe(true)
+    const lastChild = section.getByRole('treeitem', { name: 'project-one', exact: true })
+    const targetBounds = (await lastChild.boundingBox())!
+    const sectionBounds = (await section.boundingBox())!
+    expect(targetBounds.y + targetBounds.height / 2).toBeGreaterThan(sectionBounds.y + sectionBounds.height / 2)
+    await dragSource.dragTo(lastChild)
+    await expect.poll(() => {
+      const ordered = scaffold.ctx.workspaceRegistry.list()
+      return ordered.findIndex(workspace => workspace.id === sourceWorkspace.id)
+        > ordered.findIndex(workspace => workspace.id === parentWorkspace.id)
+    }, { timeout: 10_000 }).toBe(true)
+    await section.getByText('project-two', { exact: true }).waitFor()
+    await parent.click()
+    expect(await parent.locator('[class*="folderActive"]').count()).toBe(1)
+    expect(await section.getByText('project-two', { exact: true }).count()).toBe(0)
+    const warningStart = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    await parent.waitFor()
+    acknowledgeReloadConnectionLoss(tripwire, warningStart)
+    expect(await parent.getAttribute('aria-expanded')).toBe('false')
+    await parent.click()
+    await section.getByText('project-two', { exact: true }).waitFor()
+    await page.getByRole('button', { name: 'View options', exact: true }).click()
+    await page.getByRole('menuitem', { name: 'WorkSpace', exact: true }).click()
+    await page.getByRole('tree', { name: 'Sessions', exact: true }).getByText('project-two', { exact: true }).waitFor()
+    expect(await section.getByText('project-two', { exact: true }).count()).toBe(0)
+    await page.getByRole('button', { name: 'View options', exact: true }).click()
+    await page.getByRole('menuitem', { name: 'Workspace Tree', exact: true }).click()
+    await section.getByText('project-two', { exact: true }).waitFor()
+    await clickHoverAction(parent, 'New session in folder-group')
+    await expect.poll(() => section.locator('[aria-selected="true"]').evaluate(row =>
+      row.closest('[class*="groupSection"]')?.querySelector('[role="treeitem"]')?.textContent,
+    ), { timeout: 10_000 }).toBe('folder-group')
+    expect(parentWorkspace.sessionIds).toHaveLength(1)
+    expect([...childWorkspace.sessionIds]).toEqual(childSessionIds)
+    expect(tripwire.pageErrors).toEqual([])
+  })
+
   it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
     expect(tripwire.warnings).toEqual([])
     // The directory-browser aria golden is this spec's one owned artifact;

+ 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: 0899c12e1763e74bdac2bac8b51e8f714c0b8dc9
-config-catalog.zh.md: b36ed58ddd1d88415849112290b80368fac6164a
+config-catalog.md: 7605e21b25c21f3b84e47f134915d40fcd109ba4
+config-catalog.zh.md: 56e7292a97d38a2b7921f1daa90ff241e49f1dc1

+ 1 - 1
docs/config-catalog.md

@@ -3815,7 +3815,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-terminal` ([`packages/terminal/terminal/src/index.ts`](../packages/terminal/terminal/src/index.ts))
 - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userQuestions` ([`packages/interaction/tool-ask-user/src/index.ts`](../packages/interaction/tool-ask-user/src/index.ts))
 - `@deepseek-ai/dsh-tool-call-timeout-policy` — requires `tools` ([`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts))
-- `@deepseek-ai/dsh-tool-cordis` — requires `tools` · `systemPrompt` · `dynamicCordisRunner` · `cordisInspect` ([`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts))
+- `@deepseek-ai/dsh-tool-cordis` — requires `tools` · `systemPrompt` · `cordisInspect` ([`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts))
 - `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts))
 - `@deepseek-ai/dsh-user-questions` ([`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts))
 - `@deepseek-ai/dsh-webhook` — requires `agents` · `agentDefaultModel` · `agentPresets` · `permissionPresets` · `sessionTitle` · `workspaceRegistry` ([`packages/webhook/webhook/src/index.ts`](../packages/webhook/webhook/src/index.ts))

+ 1 - 1
docs/config-catalog.zh.md

@@ -3817,7 +3817,7 @@ export interface Config {
 - `@deepseek-ai/dsh-terminal`([`packages/terminal/terminal/src/index.ts`](../packages/terminal/terminal/src/index.ts))
 - `@deepseek-ai/dsh-tool-ask-user` — 需要 `tools` · `userInteraction`([`packages/interaction/tool-ask-user/src/index.ts`](../packages/interaction/tool-ask-user/src/index.ts))
 - `@deepseek-ai/dsh-tool-call-timeout-policy` — 需要 `tools`([`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts))
-- `@deepseek-ai/dsh-tool-cordis` — 需要 `tools` · `systemPrompt` · `dynamicCordisRunner` · `cordisInspect`([`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts))
+- `@deepseek-ai/dsh-tool-cordis` — 需要 `tools` · `systemPrompt` · `cordisInspect`([`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts))
 - `@deepseek-ai/dsh-tool-subagent-control` — 需要 `tools` · `subagents`([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts))
 - `@deepseek-ai/dsh-user-questions`([`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts))
 - `@deepseek-ai/dsh-webhook` — 需要 `agents` · `agentDefaultModel` · `agentPresets` · `permissionPresets` · `sessionTitle` · `workspaceRegistry`([`packages/webhook/webhook/src/index.ts`](../packages/webhook/webhook/src/index.ts))

+ 2 - 2
docs/event-producer-consumer.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/event-producer-consumer.md
-event-producer-consumer.md: 9b47e2b1fff172da5eab747b79d6fc2ab41e023a
-event-producer-consumer.zh.md: 02b38b500ee93c329d83b482f213c493708cd611
+event-producer-consumer.md: adcae08de7a8ed31c4ffd930f447a47c5a5d20cd
+event-producer-consumer.zh.md: d9fe22c41b67dd4b03c9a9f104015f6031ccc766

+ 1 - 1
docs/event-producer-consumer.md

@@ -16,7 +16,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
 | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
 | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
 | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |

+ 1 - 1
docs/event-producer-consumer.zh.md

@@ -18,7 +18,7 @@
 | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
 | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
 | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
 | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |

+ 2 - 2
docs/module-graph.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/module-graph.md
-module-graph.md: 468bbfbbc372c77578f160f9a891dffd3a6ebd75
-module-graph.zh.md: 383ea5a2d423a8fe24a782973317771d07fa03e1
+module-graph.md: fb59dcc2767bc0e30df8d6b8a0e87b98ffe2187a
+module-graph.zh.md: 60b2a822cfdd4004a417de823e12c1c7ea220eac

+ 5 - 5
docs/module-graph.md

@@ -987,9 +987,6 @@ flowchart TD
   pkg_experimental_auto_review --> pkg_tools
   pkg_tool_cordis --> pkg_agent
   pkg_tool_cordis --> pkg_cordis_host_runner
-  pkg_tool_cordis --> pkg_llm
-  pkg_tool_cordis --> pkg_scope
-  pkg_tool_cordis --> pkg_session
   pkg_tool_cordis --> pkg_system_prompt
   pkg_tool_cordis --> pkg_tools
   pkg_host_plugin_inventory --> pkg_agent_presets
@@ -1096,9 +1093,12 @@ flowchart TD
   pkg_plugin_manager --> pkg_brand
   pkg_plugin_manager --> pkg_hmr
   pkg_plugin_manager --> pkg_host_plugin_inventory
+  pkg_plugin_manager --> pkg_sandbox
+  pkg_plugin_manager --> pkg_sandbox_policy
   pkg_plugin_manager --> pkg_subprocess
   pkg_plugin_manager --> pkg_tools
   pkg_plugin_manager --> pkg_typert_protocol
+  pkg_plugin_manager --> pkg_user_approval
   pkg_headless --> pkg_agent
   pkg_headless --> pkg_agent_default_model
   pkg_headless --> pkg_fs
@@ -1541,7 +1541,7 @@ flowchart TD
 | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) |
 | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) |
 | [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
-| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
+| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
 | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
@@ -1556,7 +1556,7 @@ flowchart TD
 | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
 | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
-| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
+| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) |
 | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |

+ 5 - 5
docs/module-graph.zh.md

@@ -989,9 +989,6 @@ flowchart TD
   pkg_experimental_auto_review --> pkg_tools
   pkg_tool_cordis --> pkg_agent
   pkg_tool_cordis --> pkg_cordis_host_runner
-  pkg_tool_cordis --> pkg_llm
-  pkg_tool_cordis --> pkg_scope
-  pkg_tool_cordis --> pkg_session
   pkg_tool_cordis --> pkg_system_prompt
   pkg_tool_cordis --> pkg_tools
   pkg_host_plugin_inventory --> pkg_agent_presets
@@ -1098,9 +1095,12 @@ flowchart TD
   pkg_plugin_manager --> pkg_brand
   pkg_plugin_manager --> pkg_hmr
   pkg_plugin_manager --> pkg_host_plugin_inventory
+  pkg_plugin_manager --> pkg_sandbox
+  pkg_plugin_manager --> pkg_sandbox_policy
   pkg_plugin_manager --> pkg_subprocess
   pkg_plugin_manager --> pkg_tools
   pkg_plugin_manager --> pkg_typert_protocol
+  pkg_plugin_manager --> pkg_user_approval
   pkg_headless --> pkg_agent
   pkg_headless --> pkg_agent_default_model
   pkg_headless --> pkg_fs
@@ -1543,7 +1543,7 @@ flowchart TD
 | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) |
 | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) |
 | [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
-| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
+| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
 | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
@@ -1558,7 +1558,7 @@ flowchart TD
 | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
 | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
-| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
+| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) |
 | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |

+ 2 - 2
docs/persistence-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/persistence-catalog.md
-persistence-catalog.md: c01193930901e1b01def017ee5529b0e66efa5c1
-persistence-catalog.zh.md: fe11795b919e1f88cfc358896fbceb1388e22edc
+persistence-catalog.md: e5aef0390d3529ff6cd6c2faf16bc68ea0d1dc0e
+persistence-catalog.zh.md: 31e385e165324a9b3d9962e0d6b448ce9bb3fc5a

+ 11 - 11
docs/persistence-catalog.md

@@ -2166,7 +2166,7 @@ Sources: [`packages/llm/llm/src/assistant-stream.ts:44`](../packages/llm/llm/src
 
 SHA-256: `647cb2e3aeb58e27d1d4d60011f208bb66dce257e8654193c533b47c4c22c253`
 
-Sources: [`packages/llm/llm/src/types.ts:425`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:427`](../packages/llm/llm/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -2180,7 +2180,7 @@ Sources: [`packages/llm/llm/src/types.ts:425`](../packages/llm/llm/src/types.ts)
 
 SHA-256: `95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65`
 
-Sources: [`packages/llm/llm/src/types.ts:426`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:428`](../packages/llm/llm/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -2194,7 +2194,7 @@ Sources: [`packages/llm/llm/src/types.ts:426`](../packages/llm/llm/src/types.ts)
 
 SHA-256: `2abb711460e6238d851c30f3e4bdd87a2aa1887ee70575b3d3b7b25610673095`
 
-Sources: [`packages/llm/llm/src/types.ts:427`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:429`](../packages/llm/llm/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -2208,7 +2208,7 @@ Sources: [`packages/llm/llm/src/types.ts:427`](../packages/llm/llm/src/types.ts)
 
 SHA-256: `88a610b831be6d92612a464be5c7d0b88ec24d97bd299837bf74bf7672183c9a`
 
-Sources: [`packages/llm/llm/src/types.ts:428`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:430`](../packages/llm/llm/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -2224,7 +2224,7 @@ Sources: [`packages/llm/llm/src/types.ts:428`](../packages/llm/llm/src/types.ts)
 
 SHA-256: `90c811b8c2668898515e69a24e82caffeb08517f9c7aa193867ff0380f3b535d`
 
-Sources: [`packages/llm/llm/src/types.ts:429`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:431`](../packages/llm/llm/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -2238,7 +2238,7 @@ Sources: [`packages/llm/llm/src/types.ts:429`](../packages/llm/llm/src/types.ts)
 
 SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515`
 
-Sources: [`packages/llm/llm/src/types.ts:430`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:432`](../packages/llm/llm/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -2251,7 +2251,7 @@ Sources: [`packages/llm/llm/src/types.ts:430`](../packages/llm/llm/src/types.ts)
 
 SHA-256: `a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1`
 
-Sources: [`packages/llm/llm/src/types.ts:431`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:433`](../packages/llm/llm/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -4205,7 +4205,7 @@ SHA-256: `ebb768d85ec87fb68e42fc14fd7d8535b5ac11791b14d04c6770180f0a719bf8`
 
 SHA-256: `f311671e07bc7fa645a0b6597de9966b97147956565296ba76a36543801b66c5`
 
-Sources: [`packages/llm/llm/src/types.ts:381`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:383`](../packages/llm/llm/src/types.ts)
 
 `"in-history"`
 
@@ -5345,7 +5345,7 @@ Sources: [`packages/llm/llm/src/types.ts:67`](../packages/llm/llm/src/types.ts)
 
 SHA-256: `0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555`
 
-Sources: [`packages/llm/llm/src/types.ts:402`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:404`](../packages/llm/llm/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|
@@ -5358,7 +5358,7 @@ Sources: [`packages/llm/llm/src/types.ts:402`](../packages/llm/llm/src/types.ts)
 
 SHA-256: `071d92b9dac661fcc826c678cf64d188f26fb93aab79c627b11a03acdd34dfca`
 
-Sources: [`packages/llm/llm/src/types.ts:424`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:426`](../packages/llm/llm/src/types.ts)
 
 One of:
 
@@ -5423,7 +5423,7 @@ Sources: [`packages/llm/llm/src/types.ts:114`](../packages/llm/llm/src/types.ts)
 
 SHA-256: `7f5f2a8618c2ece530a18b1358b890fb57fb8c35705e47b66bff2296f8c6e889`
 
-Sources: [`packages/llm/llm/src/types.ts:445`](../packages/llm/llm/src/types.ts)
+Sources: [`packages/llm/llm/src/types.ts:447`](../packages/llm/llm/src/types.ts)
 
 | Property | Presence | Type |
 |---|---|---|

+ 11 - 11
docs/persistence-catalog.zh.md

@@ -2168,7 +2168,7 @@ SHA-256: `c93b6f51b399607349b4bcc66b58e848a44250ffc1e2537207bd092dfff94192`
 
 SHA-256: `647cb2e3aeb58e27d1d4d60011f208bb66dce257e8654193c533b47c4c22c253`
 
-来源:[`packages/llm/llm/src/types.ts:425`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:427`](../packages/llm/llm/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -2182,7 +2182,7 @@ SHA-256: `647cb2e3aeb58e27d1d4d60011f208bb66dce257e8654193c533b47c4c22c253`
 
 SHA-256: `95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65`
 
-来源:[`packages/llm/llm/src/types.ts:426`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:428`](../packages/llm/llm/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -2196,7 +2196,7 @@ SHA-256: `95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65`
 
 SHA-256: `2abb711460e6238d851c30f3e4bdd87a2aa1887ee70575b3d3b7b25610673095`
 
-来源:[`packages/llm/llm/src/types.ts:427`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:429`](../packages/llm/llm/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -2210,7 +2210,7 @@ SHA-256: `2abb711460e6238d851c30f3e4bdd87a2aa1887ee70575b3d3b7b25610673095`
 
 SHA-256: `88a610b831be6d92612a464be5c7d0b88ec24d97bd299837bf74bf7672183c9a`
 
-来源:[`packages/llm/llm/src/types.ts:428`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:430`](../packages/llm/llm/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -2226,7 +2226,7 @@ SHA-256: `88a610b831be6d92612a464be5c7d0b88ec24d97bd299837bf74bf7672183c9a`
 
 SHA-256: `90c811b8c2668898515e69a24e82caffeb08517f9c7aa193867ff0380f3b535d`
 
-来源:[`packages/llm/llm/src/types.ts:429`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:431`](../packages/llm/llm/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -2240,7 +2240,7 @@ SHA-256: `90c811b8c2668898515e69a24e82caffeb08517f9c7aa193867ff0380f3b535d`
 
 SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515`
 
-来源:[`packages/llm/llm/src/types.ts:430`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:432`](../packages/llm/llm/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -2253,7 +2253,7 @@ SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515`
 
 SHA-256: `a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1`
 
-来源:[`packages/llm/llm/src/types.ts:431`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:433`](../packages/llm/llm/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -4207,7 +4207,7 @@ SHA-256: `ebb768d85ec87fb68e42fc14fd7d8535b5ac11791b14d04c6770180f0a719bf8`
 
 SHA-256: `f311671e07bc7fa645a0b6597de9966b97147956565296ba76a36543801b66c5`
 
-来源:[`packages/llm/llm/src/types.ts:381`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:383`](../packages/llm/llm/src/types.ts)
 
 `"in-history"`
 
@@ -5347,7 +5347,7 @@ SHA-256: `814d9434e1ddf9078612b3b34322f26e9363b469213335007f7e9ba7fee9464e`
 
 SHA-256: `0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555`
 
-来源:[`packages/llm/llm/src/types.ts:402`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:404`](../packages/llm/llm/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -5360,7 +5360,7 @@ SHA-256: `0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555`
 
 SHA-256: `071d92b9dac661fcc826c678cf64d188f26fb93aab79c627b11a03acdd34dfca`
 
-来源:[`packages/llm/llm/src/types.ts:424`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:426`](../packages/llm/llm/src/types.ts)
 
 以下类型之一:
 
@@ -5425,7 +5425,7 @@ SHA-256: `d61574d68558c5a184429e70b04da393dd5460bf5cc3ddef814546e659bd5127`
 
 SHA-256: `7f5f2a8618c2ece530a18b1358b890fb57fb8c35705e47b66bff2296f8c6e889`
 
-来源:[`packages/llm/llm/src/types.ts:445`](../packages/llm/llm/src/types.ts)
+来源:[`packages/llm/llm/src/types.ts:447`](../packages/llm/llm/src/types.ts)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|

+ 11 - 11
docs/persistence-schema.json

@@ -14533,7 +14533,7 @@
         "packages/llm/llm/src/types.ts#StreamChunk"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:424"
+        "packages/llm/llm/src/types.ts:426"
       ]
     },
     {
@@ -14793,7 +14793,7 @@
         "packages/llm/llm/src/types.ts#ReplayEnvelope"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:402"
+        "packages/llm/llm/src/types.ts:404"
       ]
     },
     {
@@ -23535,7 +23535,7 @@
         "event:assistant/attempt.data.stream[0][3].chunk[2]"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:427"
+        "packages/llm/llm/src/types.ts:429"
       ]
     },
     {
@@ -36760,7 +36760,7 @@
         "event:assistant/attempt.data.stream[0][3].chunk[0]"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:425"
+        "packages/llm/llm/src/types.ts:427"
       ]
     },
     {
@@ -40656,7 +40656,7 @@
         "packages/llm/llm/src/types.ts#ToolSchema"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:445"
+        "packages/llm/llm/src/types.ts:447"
       ]
     },
     {
@@ -42233,7 +42233,7 @@
         "event:assistant/attempt.data.stream[0][3].chunk[3]"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:428"
+        "packages/llm/llm/src/types.ts:430"
       ]
     },
     {
@@ -43908,7 +43908,7 @@
         "event:assistant/attempt.data.stream[0][3].chunk[4]"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:429"
+        "packages/llm/llm/src/types.ts:431"
       ]
     },
     {
@@ -44283,7 +44283,7 @@
         "event:assistant/attempt.data.stream[0][3].chunk[1]"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:426"
+        "packages/llm/llm/src/types.ts:428"
       ]
     },
     {
@@ -46661,7 +46661,7 @@
         "event:assistant/attempt.data.stream[0][3].chunk[6]"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:431"
+        "packages/llm/llm/src/types.ts:433"
       ]
     },
     {
@@ -59727,7 +59727,7 @@
         "event:assistant/attempt.data.stream[0][3].chunk[5]"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:430"
+        "packages/llm/llm/src/types.ts:432"
       ]
     },
     {
@@ -59808,7 +59808,7 @@
         "packages/llm/llm/src/types.ts#SystemPromptUpdate"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:381"
+        "packages/llm/llm/src/types.ts:383"
       ]
     },
     {

+ 2 - 2
docs/subsystems/llm-streaming.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/llm-streaming.md
-llm-streaming.md: 6d29664f4362a3196d3ba7526c6fb18e20b034ad
-llm-streaming.zh.md: 3c60ae3935f745e11b850990c98a75208fb1c9f3
+llm-streaming.md: 30dd26042b4894b294a67918006d9b607eadc6b2
+llm-streaming.zh.md: 6e5f1ff268e0c22a37c64a8f954c134bf71b3b73

+ 2 - 0
docs/subsystems/llm-streaming.md

@@ -704,6 +704,8 @@ interface LlmDiscoveredModel {
   contextWindow?: number
   /** Maximum output tokens, when disclosed. */
   maxTokens?: number
+  /** Accepted input types when disclosed by the catalog or endpoint; absent means unknown. */
+  inputModalities?: readonly ModelModality[]
 }
 ```
 

+ 2 - 0
docs/subsystems/llm-streaming.zh.md

@@ -710,6 +710,8 @@ interface LlmDiscoveredModel {
   contextWindow?: number
   /** Maximum output tokens, when disclosed. */
   maxTokens?: number
+  /** Accepted input types when disclosed by the catalog or endpoint; absent means unknown. */
+  inputModalities?: readonly ModelModality[]
 }
 ```
 

+ 2 - 2
docs/tool-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/tool-catalog.md
-tool-catalog.md: 0166c7dd9829f0774c277508299726aac8426969
-tool-catalog.zh.md: 7722301c04febbd7b8d1c5509bfdb9e3881d0b76
+tool-catalog.md: 6a65943dbeaf300e3256e9477715f4cf44147c13
+tool-catalog.zh.md: 4f868b0c90b3a6b7f8e2ad854bcf166ec479e064

+ 6 - 187
docs/tool-catalog.md

@@ -15,7 +15,7 @@ This table connects model-visible tool names to the plugin package and service s
 
 | Tool package | Model-visible names | Requires | Writes / affects | Shipped aliases | Deployment note |
 | --- | --- | --- | --- | --- | --- |
-| `@deepseek-ai/dsh-plugin-manager` | `plugin_manager` | `ctx.tools`, `ctx.pluginManager` | `tool/call`, `tool/result`, `user/message` | - | - |
+| `@deepseek-ai/dsh-plugin-manager` | `plugin_manager` | `ctx.tools`, `ctx.pluginManager`, `ctx.sandboxPolicy` | `tool/call`, `tool/result`, `user/message` | - | - |
 | `@deepseek-ai/dsh-mcp-resources` | `list_mcp_resource_templates`, `list_mcp_resources`, `read_mcp_resource` | `ctx.tools`, `ctx.mcpResources` | `tool/call`, `tool/result` | - | - |
 | `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`, `stagehand_extract`, `stagehand_navigate`, `stagehand_observe`, `stagehand_screenshot`, `stagehand_tabs` | `ctx.browserUse`, `ctx.agents`, `ctx.tools`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | - |
 | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userQuestions` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. |
@@ -24,7 +24,7 @@ This table connects model-visible tool names to the plugin package and service s
 | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.jobs` runtime and is collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. |
 | `@deepseek-ai/dsh-tool-present` | `present` | `ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented after a successful final result`, `tool/result` | - | Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards. |
 | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.shell`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.jobs` runtime and are collected/stopped through the `job_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-shell-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. |
-| `@deepseek-ai/dsh-tool-cordis` | `cordis_define`, `cordis_inspect_list`, `cordis_inspect_query`, `cordis_inspect_self`, `cordis_run`, `cordis_stop`, `cordis_undefine` | `ctx.tools`, `ctx.dynamicCordisRunner` | `tool/call`, `tool/result`, `process-local dynamic package lifecycle` | - | Not in any shipped tree (a deliberate opt-in — dynamic package code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). The toolset injects `ctx.dynamicCordisRunner` from `@deepseek-ai/dsh-cordis-host-runner`, which owns the definition registry and the vm sandbox; a composition missing it never activates the tools. A running package may register ADDITIONAL model-visible tools until it is stopped, undefined, or DSH restarts; a full changed request header logs those tool-set changes. |
+| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect_list`, `cordis_inspect_query` | `ctx.tools`, `ctx.cordisInspect` | `tool/call`, `tool/result` | - | Creator mode provides two read-only runtime inspection tools. The Cordis host runner supplies the inspection registry; Client queries require a connected page. Author persistent changes as bundles and install them with plugin_manager. |
 | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. |
 | `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description. |
 | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after view presence/absence, edit absence, or successful mutation`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal API. |
@@ -51,7 +51,7 @@ This table connects model-visible tool names to the plugin package and service s
 
 ### `plugin_manager`
 
-List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart.
+List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart.
 
 ```json
 {
@@ -712,91 +712,9 @@ The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for W
 
 ## `@deepseek-ai/dsh-tool-cordis`
 
-### `cordis_define`
-
-Define an immutable Cordis Package. For a new Plugin, use kind:"new" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:"existing" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.
-
-```json
-{
-  "type": "object",
-  "properties": {
-    "plugin": {
-      "oneOf": [
-        {
-          "type": "object",
-          "additionalProperties": false,
-          "properties": {
-            "kind": {
-              "type": "string",
-              "const": "new"
-            },
-            "idPrefix": {
-              "type": "string",
-              "description": "Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."
-            }
-          },
-          "required": [
-            "kind",
-            "idPrefix"
-          ]
-        },
-        {
-          "type": "object",
-          "additionalProperties": false,
-          "properties": {
-            "kind": {
-              "type": "string",
-              "const": "existing"
-            },
-            "pluginId": {
-              "type": "string",
-              "description": "Exact ID of an existing Plugin; the new Package is appended to that instance."
-            }
-          },
-          "required": [
-            "kind",
-            "pluginId"
-          ]
-        }
-      ]
-    },
-    "name": {
-      "type": "string",
-      "description": "Short, readable Package name."
-    },
-    "purpose": {
-      "type": "string",
-      "description": "One-sentence, user-facing description of the Package purpose."
-    },
-    "code": {
-      "type": "object",
-      "additionalProperties": false,
-      "properties": {
-        "host": {
-          "type": "string",
-          "description": "Plain JavaScript function body that returns the Host-half Cordis Plugin."
-        },
-        "client": {
-          "type": "string",
-          "description": "Plain JavaScript function body that returns the browser Client-half Cordis Plugin."
-        }
-      }
-    }
-  },
-  "required": [
-    "plugin",
-    "name",
-    "purpose",
-    "code"
-  ]
-}
-```
-
-Source: [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)
-
 ### `cordis_inspect_list`
 
-List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.
+List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before writing or configuring a plugin, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.
 
 ```json
 {
@@ -809,7 +727,7 @@ Source: [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/
 
 ### `cordis_inspect_query`
 
-Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.
+Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.
 
 ```json
 {
@@ -845,106 +763,7 @@ Run a read-only query explicitly declared by an Inspect Provider. platform, prov
 
 Source: [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)
 
-### `cordis_inspect_self`
-
-Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.
-
-```json
-{
-  "type": "object",
-  "properties": {
-    "pluginId": {
-      "type": "string",
-      "description": "Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."
-    },
-    "packageId": {
-      "type": "string",
-      "description": "Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."
-    }
-  }
-}
-```
-
-Source: [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)
-
-### `cordis_run`
-
-Activate one exact Package of a dynamic Plugin. Use mode:"run" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:"update" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.
-
-```json
-{
-  "type": "object",
-  "properties": {
-    "pluginId": {
-      "type": "string",
-      "description": "Stable Plugin ID returned by cordis_define."
-    },
-    "packageId": {
-      "type": "string",
-      "description": "Exact immutable Package ID to activate under that Plugin."
-    },
-    "mode": {
-      "type": "string",
-      "description": "Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.",
-      "enum": [
-        "run",
-        "update"
-      ]
-    }
-  },
-  "required": [
-    "pluginId",
-    "packageId",
-    "mode"
-  ]
-}
-```
-
-Source: [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)
-
-### `cordis_stop`
-
-Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.
-
-```json
-{
-  "type": "object",
-  "properties": {
-    "pluginId": {
-      "type": "string",
-      "description": "Stable dynamic Plugin ID to stop."
-    }
-  },
-  "required": [
-    "pluginId"
-  ]
-}
-```
-
-Source: [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)
-
-### `cordis_undefine`
-
-Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a "Plugin removed" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.
-
-```json
-{
-  "type": "object",
-  "properties": {
-    "pluginId": {
-      "type": "string",
-      "description": "Stable dynamic Plugin ID to remove permanently."
-    }
-  },
-  "required": [
-    "pluginId"
-  ]
-}
-```
-
-Source: [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)
-
-Not in any shipped tree (a deliberate opt-in — dynamic package code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). The toolset injects `ctx.dynamicCordisRunner` from `@deepseek-ai/dsh-cordis-host-runner`, which owns the definition registry and the vm sandbox; a composition missing it never activates the tools. A running package may register ADDITIONAL model-visible tools until it is stopped, undefined, or DSH restarts; a full changed request header logs those tool-set changes.
+Creator mode provides two read-only runtime inspection tools. The Cordis host runner supplies the inspection registry; Client queries require a connected page. Author persistent changes as bundles and install them with plugin_manager.
 
 <a id="deepseek-aidsh-tool-bash-persistent"></a>
 

+ 8 - 189
docs/tool-catalog.zh.md

@@ -19,7 +19,7 @@
 
 | 工具包 | 模型可见名称 | 依赖 | 写入/影响 | 随产品发布的别名 | 部署说明 |
 | --- | --- | --- | --- | --- | --- |
-| `@deepseek-ai/dsh-plugin-manager` | `plugin_manager` | `ctx.tools`, `ctx.pluginManager` | `tool/call`, `tool/result`, `user/message` | - | - |
+| `@deepseek-ai/dsh-plugin-manager` | `plugin_manager` | `ctx.tools`, `ctx.pluginManager`, `ctx.sandboxPolicy` | `tool/call`, `tool/result`, `user/message` | - | - |
 | `@deepseek-ai/dsh-mcp-resources` | `list_mcp_resource_templates`, `list_mcp_resources`, `read_mcp_resource` | `ctx.tools`, `ctx.mcpResources` | `tool/call`, `tool/result` | - | - |
 | `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`、`stagehand_extract`、`stagehand_navigate`、`stagehand_observe`、`stagehand_screenshot`、`stagehand_tabs` | `ctx.browserUse`、`ctx.agents`、`ctx.tools`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | - |
 | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`、`ctx.userQuestions` | `tool/call`、`tool/result after a UI/provider answers the question` | - | ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类答案。 |
@@ -28,7 +28,7 @@
 | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;禁用 `enableRunInBackground` 配置(默认为 true)后,该参数会被完全移除。 |
 | `@deepseek-ai/dsh-tool-present` | `present` | `ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented 在成功的最终结果之后`, `tool/result` | - | 交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。 |
 | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 |
-| `@deepseek-ai/dsh-tool-cordis` | `cordis_define`、`cordis_inspect_list`、`cordis_inspect_query`、`cordis_inspect_self`、`cordis_run`、`cordis_stop`、`cordis_undefine` | `ctx.tools`、`ctx.dynamicCordisRunner` | `tool/call`、`tool/result`、`process-local dynamic package lifecycle` | - | 不在任何随产品发布的树中,需要显式选择启用;动态 Package 代码可以访问真实运行时,见 .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md。该工具集注入 `@deepseek-ai/dsh-cordis-host-runner` 提供的 `ctx.dynamicCordisRunner`,后者拥有定义注册表和 vm 沙箱;组合缺少它时这些工具不会激活。运行中的 Package 在停止、undefine 或 DSH 重启前可以注册**额外的**模型可见工具;发生这类工具集变化时,系统会记录完整且有变动的请求头。 |
+| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect_list`, `cordis_inspect_query` | `ctx.tools`, `ctx.cordisInspect` | `tool/call`, `tool/result` | - | 创造模式提供两个只读运行时检查工具。Cordis host runner 提供检查注册表;Client 查询需要已连接页面。持久化变更编写为组合包,再通过 plugin_manager 安装。 |
 | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 |
 | `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 PTY 后端,并可覆盖面向模型的环境描述。 |
 | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`、`ctx.fs` | `tool/call`、`fs/observed after view presence/absence, edit absence, or successful mutation`、`tool/result` | - | 基于文件系统 seam 的独立查看/创建/唯一字面量替换/按行插入工具;可与任何 shell 或终端接口组合。 |
@@ -55,7 +55,7 @@
 
 ### `plugin_manager`
 
-列出当前 profile 的插件或组合包、启用或禁用它们、安装组合包或删除已安装的组合包。改动影响该 profile 中的每个会话。先查询列表以获取准确标识。包安装可能执行获准的构建脚本。live profile 立即应用配置变化;startup profile 需要重启。
+列出当前 profile 中的插件或组合包,启用或禁用它们,安装组合包或移除已安装的组合包。每项操作都要求 danger-full-access 权限或本次调用的批准。批准不改变会话权限模式。变更影响该 profile 的所有会话。先列出条目以获取准确标识。包安装可能运行已获批准的构建脚本。支持热更新的 profile 立即应用变更;仅启动时加载的 profile 需要重启。
 
 ```json
 {
@@ -716,91 +716,9 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
 
 ## `@deepseek-ai/dsh-tool-cordis`
 
-### `cordis_define`
-
-定义一个不可变的 Cordis Package。新建 Plugin 时使用 kind:"new",只提供 3 至 6 位小写英文字母组成的语义前缀;Host 返回最终 pluginId 和 packageId。修改现有 Plugin 时使用 kind:"existing" 并传入精确 pluginId,以追加 Package 而不覆盖旧版本。code.host 与 code.client 至少提供一个;每个值都是返回 Cordis Plugin 的 plain JavaScript 函数体,不经过 TypeScript、JSX 或 import 转换。依赖 Service、Event、Builtin、Slot 或 token 前先查询 Inspect。Define 只校验参数和语法并记录源码,不申请审批、不执行 apply,也不改变 currentPackageId。成功后用返回的 ID 调用 cordis_run。
-
-```json
-{
-  "type": "object",
-  "properties": {
-    "plugin": {
-      "oneOf": [
-        {
-          "type": "object",
-          "additionalProperties": false,
-          "properties": {
-            "kind": {
-              "type": "string",
-              "const": "new"
-            },
-            "idPrefix": {
-              "type": "string",
-              "description": "Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."
-            }
-          },
-          "required": [
-            "kind",
-            "idPrefix"
-          ]
-        },
-        {
-          "type": "object",
-          "additionalProperties": false,
-          "properties": {
-            "kind": {
-              "type": "string",
-              "const": "existing"
-            },
-            "pluginId": {
-              "type": "string",
-              "description": "Exact ID of an existing Plugin; the new Package is appended to that instance."
-            }
-          },
-          "required": [
-            "kind",
-            "pluginId"
-          ]
-        }
-      ]
-    },
-    "name": {
-      "type": "string",
-      "description": "Short, readable Package name."
-    },
-    "purpose": {
-      "type": "string",
-      "description": "One-sentence, user-facing description of the Package purpose."
-    },
-    "code": {
-      "type": "object",
-      "additionalProperties": false,
-      "properties": {
-        "host": {
-          "type": "string",
-          "description": "Plain JavaScript function body that returns the Host-half Cordis Plugin."
-        },
-        "client": {
-          "type": "string",
-          "description": "Plain JavaScript function body that returns the browser Client-half Cordis Plugin."
-        }
-      }
-    }
-  },
-  "required": [
-    "plugin",
-    "name",
-    "purpose",
-    "code"
-  ]
-}
-```
-
-来源:[`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)
-
 ### `cordis_inspect_list`
 
-列出 Host 当前已知的全部 Cordis Inspect Provider,包括本地 Host Provider 和 Client 最近同步的 manifest。每项包含所属平台、用途、只读方法及输入/输出 schema。创建或修改 Package 前先调用本 Tool,再从结果中选择 cordis_inspect_query 的 provider 和 method。不要猜测名称,也不要把 Inspect method 当作 Plugin 代码可调用的业务 Service。
+列出 Host 当前已知的所有 Cordis Inspect Provider,包括本地 Host Provider 和 Client 同步的最新清单。每项包含平台、用途、只读方法以及输入输出 schema。编写或配置插件前先调用本工具,再从结果选择 cordis_inspect_query 的 provider 和方法。不要猜测名称,也不要把 Inspect 方法当作插件代码可调用的业务 Service。
 
 ```json
 {
@@ -809,11 +727,11 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
 }
 ```
 
-来源:[`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)
+来源: [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)
 
 ### `cordis_inspect_query`
 
-执行 Inspect Provider 显式声明的只读查询。platform、provider 和 method 必须来自 cordis_inspect_list,input 必须符合该方法的 schema。在 cordis_define 前用本 Tool 读取精确 Service 方法、Event mode、Builtin 签名、Tool schema、主题 token,或实时 Slot 树及 props。Host 查询在本地执行;Client 查询等待首个有效页面响应,在页面回答或 Tool 被取消前保持 pending。本 Tool 不能调用业务 Service 方法或修改运行时。查询 Service.listService 和 Event.listEvents 时,先不传 input 浏览紧凑签名目录,再查询精确 service 或 event 获取结构化约定和引用类型。查询 Slots.listSubTree 时,先不传 root 浏览紧凑树,再查询精确 root 获取完整注册约定和 props。
+执行 Inspect Provider 明确声明的只读查询。platform、provider 和 method 必须来自 cordis_inspect_list,input 必须符合该方法的 schema。编写插件代码前,用本工具读取准确的 Service 方法、Event 模式、Builtin 签名、Tool schema、主题 token,或实时 Slot 树与 props。Host 查询在本地运行。Client 查询等待页面首个有效响应,直到页面回应或工具取消。本工具不能调用业务 Service 方法或修改运行时。对于 Service.listService 和 Event.listEvents,不传 input 可浏览精简签名目录,再查询准确服务或事件以获得完整约定及引用类型。对于 Slots.listSubTree,不传 root 可浏览精简树,再查询准确 root 以获得完整注册约定和 props。
 
 ```json
 {
@@ -847,108 +765,9 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
 }
 ```
 
-来源:[`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)
-
-### `cordis_inspect_self`
-
-按逐层增加的详细程度检查当前 Session 拥有的动态 Cordis 对象。不传 ID 时只列 Plugin 摘要;只传 pluginId 时返回版本指针、最新 Run 和全部 Package 摘要;只有同时传 pluginId 与 packageId 才返回该不可变 Package 的 Host/Client 源码和运行诊断。packageId 不能单独传入。处理 @pluginId、修复异步失败或定义更新版本前,先查询精确 Package。本 Tool 只读,不执行代码,也不改变版本指针。
-
-```json
-{
-  "type": "object",
-  "properties": {
-    "pluginId": {
-      "type": "string",
-      "description": "Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."
-    },
-    "packageId": {
-      "type": "string",
-      "description": "Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."
-    }
-  }
-}
-```
-
-来源:[`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)
-
-### `cordis_run`
-
-激活动态 Plugin 的一个精确 Package。首次激活、重启 currentPackageId 或回退使用 mode:"run";已有 current 时,即使 Plugin 当前已停止,切换到其他 Package 也使用 mode:"update"。未授权的 Client Package 创建审批请求并返回 awaiting-approval;已授权的 Package 返回 starting,并在浏览器中异步继续。两种结果都不会在 Tool 内等待最终结局。currentPackageId 只在完整成功后改变;失败时保留旧 current 和目标 next。异步成功、拒绝或技术失败通过状态与 steering 报告。技术失败后,用 cordis_inspect_self 读取诊断,修正同一 Plugin 并自主重试。用户拒绝后不要再次申请审批。
-
-```json
-{
-  "type": "object",
-  "properties": {
-    "pluginId": {
-      "type": "string",
-      "description": "Stable Plugin ID returned by cordis_define."
-    },
-    "packageId": {
-      "type": "string",
-      "description": "Exact immutable Package ID to activate under that Plugin."
-    },
-    "mode": {
-      "type": "string",
-      "description": "Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.",
-      "enum": [
-        "run",
-        "update"
-      ]
-    }
-  },
-  "required": [
-    "pluginId",
-    "packageId",
-    "mode"
-  ]
-}
-```
-
-来源:[`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)
-
-### `cordis_stop`
-
-停止动态 Plugin 的当前 Run,并取消尚未完成的审批或激活请求。保留 Plugin、全部不可变 Package、授权、currentPackageId 和 nextPackageId,以便之后直接运行或更新。停止已处于停止状态的 Plugin 会幂等成功。临时禁用副作用使用本 Tool;永久移除使用 cordis_undefine。
-
-```json
-{
-  "type": "object",
-  "properties": {
-    "pluginId": {
-      "type": "string",
-      "description": "Stable dynamic Plugin ID to stop."
-    }
-  },
-  "required": [
-    "pluginId"
-  ]
-}
-```
-
-来源:[`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)
-
-### `cordis_undefine`
-
-永久移除当前 Session 拥有的动态 Plugin。如果它正在运行或等待审批,先停止并取消请求,再删除全部 Package、授权和版本指针。返回后,其 pluginId、packageIds、@ 引用和 Package 业务视图均失效;历史卡片只保留“Plugin 已移除”记录。需要保留版本以便重启或回退时不要调用本 Tool,应改用 cordis_stop。
-
-```json
-{
-  "type": "object",
-  "properties": {
-    "pluginId": {
-      "type": "string",
-      "description": "Stable dynamic Plugin ID to remove permanently."
-    }
-  },
-  "required": [
-    "pluginId"
-  ]
-}
-```
-
-来源:[`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)
+来源: [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)
 
-不在任何随产品发布的树中,需要显式选择启用;动态 Package 代码可以访问真实运行时,见 .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md。该工具集注入 `@deepseek-ai/dsh-cordis-host-runner` 提供的 `ctx.dynamicCordisRunner`,后者拥有定义注册表和 vm 沙箱;组合缺少它时这些工具不会激活。运行中的 Package 在停止、undefine 或 DSH 重启前可以注册**额外的**模型可见工具;发生这类工具集变化时,系统会记录完整且有变动的请求头
+创造模式提供两个只读运行时检查工具。Cordis host runner 提供检查注册表;Client 查询需要已连接页面。持久化变更编写为组合包,再通过 plugin_manager 安装。
 
 <a id="deepseek-aidsh-tool-bash-persistent"></a>
 

+ 2 - 2
docs/user/develop/practice/dynamic-cordis.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/user/develop/practice/dynamic-cordis.md
-dynamic-cordis.md: 5e324f88c9fc5ac8f770749cf4ab2c97d175121d
-dynamic-cordis.zh.md: 69486cd0d2bacf7c0831e12801128be22fb209ef
+dynamic-cordis.md: c9065027f3449355aa7a19e16672fa5812cab633
+dynamic-cordis.zh.md: 21d0a410d8e27931786a76b9383230a3d30004e5

+ 8 - 8
docs/user/develop/practice/dynamic-cordis.md

@@ -1,15 +1,15 @@
-# Extend a running agent with Cordis tools
+# Configure persistent plugins from a prompt
 
 English | [中文](dynamic-cordis.zh.md)
 
-This practice guide enables [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/extensions/tool-cordis/README.md). The agent can inspect its current Cordis process and mount or unmount model-authored plugins in memory. Temporary plugins disappear when they are unmounted or the process exits and may affect other sessions in the same process.
+Creator mode provides [Plugin Manager](../../../../packages/boot/plugin-manager/README.md) and read-only [runtime inspection](../../../../packages/extensions/tool-cordis/README.md). Plugin configuration belongs to the current profile, affects its sessions, and survives process restarts.
 
-## Run it
+## Connect an MCP server
 
-Start the browser interface with the checked-in overlay:
+Start the Web profile and select Creator mode. With a reachable Streamable HTTP MCP server that exposes `ping`, send this prompt using its actual endpoint:
 
-```sh
-pnpm dsh web --patch apps/cli/config/examples/cordis/cordis.yml
-```
+> Configure the MCP server at `<endpoint>` in this profile as `demo`. Make its tools available now, then call its ping tool and tell me the result.
 
-The command requires a model credential. The [Cordis tool reference](../../../../packages/extensions/tool-cordis/README.md) defines the tool arguments, lifetime, cleanup, and safety contracts.
+The agent writes a configuration-only bundle whose patch inserts `@deepseek-ai/dsh-mcp-client`, then installs it with `plugin_manager install_bundle`. With HMR enabled, the tools appear in the same running session. Verify both the management result (`application: applied`) and a successful `mcp__demo__ping` call. A saved entry with `restart-required` has not activated yet; a failed entry needs configuration repair.
+
+Read the bundle patch before editing its configuration. Use Plugin Manager to disable entries or remove the bundle. See the [MCP client reference](../../../../packages/mcp/mcp-client/README.md) for accepted configuration and connection failure behavior.

+ 8 - 8
docs/user/develop/practice/dynamic-cordis.zh.md

@@ -1,15 +1,15 @@
-# 用 Cordis 工具扩展运行中的智能体
+# 通过提示词配置持久化插件
 
 [English](dynamic-cordis.md) | 中文
 
-本实战指南启用 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/extensions/tool-cordis/README.zh.md)。智能体可以检查当前 Cordis 进程,并在内存中挂载或卸载模型编写的插件。临时插件会在卸载或进程退出时消失,并可能影响同一进程中的其他会话
+创造模式提供 [Plugin Manager](../../../../packages/boot/plugin-manager/README.zh.md) 和只读[运行时检查](../../../../packages/extensions/tool-cordis/README.zh.md)。插件配置属于当前 profile,影响其会话,并在进程重启后保留
 
-## 运行
+## 连接 MCP 服务器
 
-使用仓库内 overlay 启动浏览器界面
+启动 Web profile 并选择创造模式。准备一个可访问且提供 `ping` 的 Streamable HTTP MCP 服务器,将其实际端点填入以下提示词
 
-```sh
-pnpm dsh web --patch apps/cli/config/examples/cordis/cordis.yml
-```
+> 将 `<endpoint>` 处的 MCP 服务器配置到当前 profile,命名为 `demo`。立即启用它的工具,然后调用它的 ping 工具并告诉我结果。
 
-该命令需要模型凭据。[Cordis 工具参考](../../../../packages/extensions/tool-cordis/README.zh.md)定义了四类约定:工具参数、存续时间、清理行为和安全性。
+agent 编写纯配置组合包,在 patch 中插入 `@deepseek-ai/dsh-mcp-client`,再通过 `plugin_manager install_bundle` 安装。启用 HMR 时,工具会出现在同一个运行中的会话里。同时检查管理结果(`application: applied`)和成功的 `mcp__demo__ping` 调用。返回 `restart-required` 的已保存条目尚未激活;失败条目需要修复配置。
+
+修改配置前先读取组合包 patch。使用 Plugin Manager 停用条目或移除组合包。可接受的配置及连接失败行为见 [MCP client 参考](../../../../packages/mcp/mcp-client/README.zh.md)。

+ 2 - 2
docs/user/guide/providers.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/user/guide/providers.md
-providers.md: 90d3bb2c96a80185fbc5e3e70f1ff577f6923acc
-providers.zh.md: 2937d676253ee45dad48655398736d36fc37dff2
+providers.md: e578ff96a373c96ebe646126d6c60d7bcc62bbc1
+providers.zh.md: fd0859f0b235efb5fe8ecc3c248a2ac2bbaeb495

+ 11 - 7
docs/user/guide/providers.md

@@ -42,15 +42,15 @@ If a saved default names a provider that was deleted, the composer displays **Se
 
 The generated [plugin configuration catalog](../../config-catalog.md) lists every supported field and default for every plugin; [`dsh-llm-pi-ai`](../../config-catalog.md#deepseek-aidsh-llm-pi-ai) is the provider section this page configures. The [`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) and [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) references own direct `settings.yaml` configuration, catalog resolution, reasoning controls, credentials, and adapter errors.
 
-::: tip The form is deliberately small
-The Models page exposes only what a route needs to exist: the API key, display name, base URL, API protocol, and for each model its id, display name, context window, and max output tokens. Every other field — reasoning effort levels, image input, request-compatibility switches, headers, timeouts, retry policy — is set in `$DSH_HOME/settings.yaml`, the same document the page writes. Edit it directly, or, when the browser runs on the same machine as the server, open it with **Open configuration file** in the Settings header; the adapters re-read it on the next request, so nothing needs a restart. The subsections below cover the fields most gateways need.
+::: tip Additional settings
+The Models page exposes the API key, display name, base URL, API protocol, and each model's id, display name, context window, max output tokens, and input types. Configure reasoning effort levels, request-compatibility switches, headers, timeouts, and retry policy in `$DSH_HOME/settings.yaml`, the same document the page writes. Edit it directly, or, when the browser runs on the same machine as the server, open it with **Open configuration file** in the Settings header; the adapters re-read it on the next request, so nothing needs a restart. The subsections below cover the fields most gateways need.
 :::
 
 ### Image input
 
-A model you enter by hand is treated as text-only until it says otherwise, because nothing can ask an endpoint which modalities it accepts. Attaching an image to such a model is refused before it is sent, naming the model.
+In **Settings → Models**, edit the provider, open **Customized settings**, and expand the model's **Model options**. **Input types** occupies its own row below the capacity fields. Select **Image** for a model that accepts images, and save. **Text** starts selected for a new custom model with no inherited image capability. At least one type must remain selected; select Image before clearing Text for an image-only model.
 
-A vision model on a custom provider therefore needs one line. The form has no field for it; add `input` to the model in `$DSH_HOME/settings.yaml`:
+The checkboxes save `input` for pi-ai models and `inputModalities` for the direct DeepSeek adapter. You can also edit the model in `$DSH_HOME/settings.yaml`; for example, this custom pi-ai provider declares one text-only model and one vision model:
 
 ```yaml
 llm-pi-ai:
@@ -65,7 +65,11 @@ llm-pi-ai:
           input: [text, image]
 ```
 
-`input` accepts `text` and `image`, and applies to that model alone, so one route can serve both kinds. Omitting it — or writing an empty list, which means the same thing — keeps whatever the installed catalog records for that model, and falls back to the route's `defaultInput` for a model the catalog does not describe.
+Pi-ai's `input` accepts `text` and `image` and applies to that model alone. An explicit nonempty selection takes priority. An omitted or empty `input` inherits the installed catalog's input types, then the route's `defaultInput`, which defaults to `[text]`. The checkboxes display these inherited values without saving an override when you merely open the row.
+
+DeepSeek treats an omitted `inputModalities` as text-only and rejects an empty list. Clearing Image also removes that model's `imagePixelBudget` and `imageMaxBytes`, because DeepSeek rejects image limits on a text-only model. Set those limits again if you later enable images and need custom limits.
+
+To restore inheritance after editing the checkboxes, remove the model's `input` or `inputModalities` field from `settings.yaml`. **Restore defaults** removes the entire model-catalog override, including other model edits, so use it only when you want to restore the whole catalog.
 
 If every model you entered by hand takes images, set the fallback once on the route instead of on each of them:
 
@@ -82,7 +86,7 @@ llm-pi-ai:
         - id: second-model
 ```
 
-`defaultInput` is a fallback, not an override, and defaults to `[text]`: on a built-in provider it answers only for models its catalog does not describe, so it never removes images from a catalog model that has them. Narrow one of those with that model's own `input`. A built-in provider has no `models` list to put it in, so write it under `modelOverrides`, keyed by model id:
+`defaultInput` is a fallback, not an override, and defaults to `[text]`: on a built-in provider it answers only for models its catalog does not describe, so it never removes images from a catalog model that has them. Narrow one of those with that model's own `input`. When a built-in provider has no explicit `models` list, write it under `modelOverrides`, keyed by model id:
 
 ```yaml
 llm-pi-ai:
@@ -93,7 +97,7 @@ llm-pi-ai:
           input: [text]
 ```
 
-Every list must name at least one modality except a model's own, where an empty list means the same as omitting it. An unknown modality is refused wherever it is written.
+In pi-ai configuration, every list must name at least one modality except a model's own `input`, where an empty list means the same as omitting it. An unknown modality is refused wherever it is written.
 
 Both fields state a claim about your endpoint rather than checking it. A model that declares images its endpoint does not serve is not caught here; the provider rejects the request instead.
 

+ 11 - 7
docs/user/guide/providers.zh.md

@@ -42,15 +42,15 @@ Provider ID 是永久的,因为请求、已保存会话、模型默认值和
 
 自动生成的[插件配置目录](../../config-catalog.zh.md)列出每个插件的所有受支持字段与默认值;[`dsh-llm-pi-ai`](../../config-catalog.zh.md#deepseek-aidsh-llm-pi-ai) 就是本页所配置的那个提供方段落。[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.zh.md) 和 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.zh.md) 参考文档负责直接 `settings.yaml` 配置、目录解析、推理控制、凭据与适配器错误。
 
-::: tip 表单刻意保持精简
-模型页只开放让一条路由得以存在的字段:API 密钥、显示名称、API 地址、API 协议,以及每个模型的 ID、显示名称、上下文窗口和最大输出 token 数。其余所有字段——推理等级、图片输入、请求兼容性开关、请求头、超时、重试策略——都在 `$DSH_HOME/settings.yaml` 中设置,也就是模型页写入的同一份文档。可以直接编辑它;浏览器与服务器在同一台机器时,也可以点击设置页顶部的**打开配置文件**打开它。适配器会在下一次请求时重新读取,无需重启任何东西。下面各小节介绍多数网关会用到的字段。
+::: tip 其他设置
+模型页提供 API 密钥、显示名称、API 地址、API 协议,以及每个模型的 ID、显示名称、上下文窗口、最大输出 token 数和输入类型。推理等级、请求兼容性开关、请求头、超时和重试策略在 `$DSH_HOME/settings.yaml` 中设置,也就是模型页写入的同一份文档。可以直接编辑它;浏览器与服务器在同一台机器时,也可以点击设置页顶部的**打开配置文件**打开它。适配器会在下一次请求时重新读取,无需重启任何东西。下面各小节介绍多数网关会用到的字段。
 :::
 
 ### 图片输入
 
-手动输入的模型在自己声明之前一律按纯文本对待,因为没有任何环节能去询问端点接受哪些模态。给这类模型附加图片,会在发送前就被拒绝,并点名该模型
+在**设置 → 模型**中编辑提供方,打开**自定义设置**并展开该模型的**模型选项**。**输入类型**独占容量字段下方的一行。对于支持图片的模型,勾选**图片**并保存。没有继承图片能力的新自定义模型默认勾选**文本**。至少保留一种输入类型;仅图片模型需先勾选图片,再取消文本
 
-因此自定义提供方下的视觉模型需要加一行。表单没有对应字段;请在 `$DSH_HOME/settings.yaml` 中给该模型加上 `input`
+复选框将 pi-ai 模型的选择保存为 `input`,将直连 DeepSeek 适配器的选择保存为 `inputModalities`。也可以在 `$DSH_HOME/settings.yaml` 中编辑模型;例如,以下自定义 pi-ai 提供方声明了一个纯文本模型和一个视觉模型
 
 ```yaml
 llm-pi-ai:
@@ -65,7 +65,11 @@ llm-pi-ai:
           input: [text, image]
 ```
 
-`input` 接受 `text` 和 `image`,且只作用于该模型,因此一条路由可以同时服务两类模型。省略它——或写成空列表,两者同义——则保留已安装目录为该模型记录的模态;目录未描述的模型则回退到该路由的 `defaultInput`。
+Pi-ai 的 `input` 接受 `text` 和 `image`,且只作用于该模型。显式的非空选择优先。省略或为空的 `input` 先继承已安装目录的输入类型,再回退到路由的 `defaultInput`,后者默认为 `[text]`。复选框会显示这些继承值,仅打开模型行不会保存覆盖值。
+
+DeepSeek 将省略的 `inputModalities` 视为纯文本,并拒绝空列表。取消图片还会移除该模型的 `imagePixelBudget` 和 `imageMaxBytes`,因为 DeepSeek 拒绝纯文本模型上的图片限制。以后重新启用图片且需要自定义限制时,需再次设置这些限制。
+
+修改复选框后如需恢复继承,可在 `settings.yaml` 中移除模型的 `input` 或 `inputModalities` 字段。**恢复默认模型**会移除整个模型目录覆盖,包括其他模型编辑,因此仅在需要恢复整个目录时使用。
 
 如果你手动录入的模型全都接受图片,可以在路由上设置一次回退值,不必逐个模型写:
 
@@ -82,7 +86,7 @@ llm-pi-ai:
         - id: second-model
 ```
 
-`defaultInput` 是回退值而不是覆盖值,默认为 `[text]`:在内置提供方上,它只为其目录未描述的模型作答,因此绝不会把目录中本就具备图片能力的模型的该能力去掉。要收窄这类模型,请用它自己的 `input`。内置提供方没有可供填写的 `models` 列表,因此写在 `modelOverrides` 下,以模型 id 为键:
+`defaultInput` 是回退值而不是覆盖值,默认为 `[text]`:在内置提供方上,它只为其目录未描述的模型作答,因此绝不会把目录中本就具备图片能力的模型的该能力去掉。要收窄这类模型,请用它自己的 `input`。内置提供方没有显式 `models` 列表时,写在 `modelOverrides` 下,以模型 id 为键:
 
 ```yaml
 llm-pi-ai:
@@ -93,7 +97,7 @@ llm-pi-ai:
           input: [text]
 ```
 
-除模型自身的列表外,每个列表都至少要写一项模态;模型自身的空列表与省略它同义。未知模态在任何位置写入都会被拒绝。
+在 pi-ai 配置中,除模型自身的 `input` 外,每个列表都至少要写一项模态;模型自身的空列表与省略它同义。未知模态在任何位置写入都会被拒绝。
 
 这两个字段都是对你端点的断言,而不是对它的检查。声明了端点并不提供的图片能力的模型不会在这里被拦下,改由提供方拒绝该请求。
 

+ 2 - 3
packages/boot/app-boot/src/index.ts

@@ -17,7 +17,7 @@ import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '
 import Group from '@deepseek-ai/cordis-plugin-group'
 import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
-export { readProfilePatches, resolveTelemetryPatch, type ProfileContext } from './profile-context.ts'
+export { readProfilePatches, resolveTelemetryPatch, type ProfileContext, type ProfilePnpmInvocation } from './profile-context.ts'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 
 export {
@@ -673,8 +673,7 @@ export function installFailLoud(
 
 /**
  * Value mirrors used because Cordis's const enum has no runtime object to import.
- * Keep aligned with `packages/extensions/tool-cordis/src/fiber-state.ts` and
- * `packages/client/web/src/loader-status.ts`.
+ * Keep aligned with `packages/client/web/src/loader-status.ts`.
  */
 const FIBER_PENDING = 0 as FiberState.PENDING
 const FIBER_ACTIVE = 2 as FiberState.ACTIVE

+ 9 - 0
packages/boot/app-boot/src/profile-context.ts

@@ -4,9 +4,18 @@ import { composeEntries, loadProfileDirectory, PROFILE_PATCH_FILENAME, type Prof
 import { loadOptionalPatches } from './index.ts'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 
+/** Application-owned package manager executable; environment applies only to package operations. */
+export interface ProfilePnpmInvocation {
+  readonly command: string
+  readonly args: readonly string[]
+  readonly env: Readonly<Record<string, string>>
+}
+
 /** Current profile facts; scheduling and mutation belong to their callers. */
 export interface ProfileContext {
   readonly name: string
+  /** Packaged applications supply their bundled runtime instead of a PATH executable. */
+  readonly packageManager?: ProfilePnpmInvocation
   readonly dir: string
   readonly patchPath: string
   readonly installAnchor: string

+ 2 - 2
packages/boot/plugin-manager/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/boot/plugin-manager/README.md
-README.md: 66408c85273e57d78d94c98a3e8cdcefab1286fb
-README.zh.md: 2b10860867b9ff992a96222b24432ebb99025015
+README.md: 46b5447a8b3548b548bf844e1d753296861b17da
+README.zh.md: d4c285b72f8d31357a12f222d741323e9b4658e6

+ 4 - 2
packages/boot/plugin-manager/README.md

@@ -7,6 +7,8 @@ kind: "package-reference"
 
 English | [中文](README.zh.md)
 
+Application-owned profiles supply their bundled package-manager invocation through launcher facts. It takes precedence over `pnpmCommand` for package operations and registry inspection; its environment applies only to those subprocesses.
+
 ## Summary
 
 Manage the current profile's plugins without editing configuration by hand. Enable or disable individual plugin entries, select installed bundles, and install or remove external bundles. With HMR enabled in YAML, configuration changes apply immediately; without HMR, the running composition remains until restart. Changes affect every session using the profile.
@@ -26,9 +28,9 @@ Manage the current profile's plugins without editing configuration by hand. Enab
 <a id="use-this-package"></a>
 ## Use this package
 
-Base-backed profiles provide the manager. In Web, the sidebar's **Plugins** page ([ui-plugin-manager](../../client/ui-plugin-manager/README.md)) manages the profile's bundles and their uniquely addressable rows; the Settings Plugin list stays read-only. Agent-preset rows remain read-only. The `plugin_manager` tool exposes the same operations and is disabled by default.
+Base-backed profiles provide the manager. In Web, the sidebar's **Plugins** page ([ui-plugin-manager](../../client/ui-plugin-manager/README.md)) manages the profile's bundles and their uniquely addressable rows; the Settings Plugin list stays read-only. Agent-preset rows remain read-only. The `plugin_manager` tool exposes the same operations and is enabled in Creator mode. Other presets keep it disabled by default. Every tool action requires `danger-full-access` or approval for that call. Under lower sandbox modes, `ask` requests approval; `never`, rejection, cancellation, or an unavailable approval channel prevents execution. An approval leaves the session permission mode unchanged. Profile changes persist across sessions, and installed Host code executes in-process outside the workspace sandbox. Dependency build-script approval remains separate.
 
-Enable the tool explicitly in the profile patch; agents using a preset also need its `tool-plugin-manager` entry enabled.
+For a deployment without agent presets, enable the tool in the profile patch. Preset-backed sessions use their preset’s `tool-plugin-manager` entry.
 
 ```yaml
 - id: tool-plugin-manager

+ 4 - 2
packages/boot/plugin-manager/README.zh.md

@@ -7,6 +7,8 @@ kind: "package-reference"
 
 [English](README.md) | 中文
 
+应用拥有的 profile 通过启动器信息提供内置包管理器调用方式。它在包操作和 registry 检查中优先于 `pnpmCommand`;其环境仅应用于这些子进程。
+
 ## 概述
 
 管理当前 profile 的插件,无需手动编辑配置。启停单个插件条目、选择已安装的组合包,以及安装或删除外部组合包。在 YAML 中启用 HMR 时,配置变化立即生效;未启用 HMR 时,运行中的组合保留到重启。改动影响使用该 profile 的全部会话。
@@ -26,9 +28,9 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-基于 base 的 profile 提供管理服务。在 Web 中,侧边栏的**插件**页([ui-plugin-manager](../../client/ui-plugin-manager/README.zh.md))管理 profile 的组合包及其能唯一定位的行;设置页的插件列表保持只读。Agent 预设条目保持只读。`plugin_manager` 工具提供相同操作,默认禁用。
+基于 base 的 profile 提供管理服务。在 Web 中,侧边栏的**插件**页([ui-plugin-manager](../../client/ui-plugin-manager/README.zh.md))管理 profile 的组合包及其能唯一定位的行;设置页的插件列表保持只读。Agent 预设条目保持只读。`plugin_manager` 工具提供相同操作,在 Creator 模式中启用。其他预设仍默认禁用。 每个工具操作都要求 `danger-full-access` 或本次调用的批准。在较低沙箱模式下,`ask` 会请求审批;`never`、拒绝、取消或审批渠道不可用时均不执行。批准不改变会话的权限模式。profile 变更跨会话持久化,已安装的 Host 代码在宿主进程内运行,不受工作区沙箱限制。依赖构建脚本仍需单独批准。
 
-在 profile patch 中显式启用工具;使用预设的 Agent 还需要启用该预设中的 `tool-plugin-manager` 条目
+未使用 Agent 预设的部署在 profile patch 中启用工具;使用预设的会话由其预设中的 `tool-plugin-manager` 条目控制
 
 ```yaml
 - id: tool-plugin-manager

+ 14 - 2
packages/boot/plugin-manager/package.json

@@ -68,11 +68,17 @@
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-hmr": "workspace:^",
-    "@deepseek-ai/dsh-brand": "workspace:^"
+    "@deepseek-ai/dsh-brand": "workspace:^",
+    "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
+    "@deepseek-ai/dsh-sandbox": "workspace:^",
+    "@deepseek-ai/dsh-user-approval": "workspace:^"
   },
   "peerDependenciesMeta": {
     "@deepseek-ai/dsh-hmr": {
       "optional": true
+    },
+    "@deepseek-ai/dsh-user-approval": {
+      "optional": true
     }
   },
   "devDependencies": {
@@ -85,6 +91,12 @@
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-hmr": "workspace:^",
     "@deepseek-ai/cordis-plugin-timer": "workspace:^",
-    "@deepseek-ai/dsh-brand": "workspace:^"
+    "@deepseek-ai/dsh-brand": "workspace:^",
+    "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-projection": "workspace:^",
+    "@deepseek-ai/dsh-sandbox": "workspace:^",
+    "@deepseek-ai/dsh-user-approval": "workspace:^"
   }
 }

+ 3 - 2
packages/boot/plugin-manager/src/index.ts

@@ -266,7 +266,8 @@ export class PluginManager extends TypertRemoteService {
       case 'registry': {
         if (known.has(parsed.name)) return refused('already-installed', `${parsed.name} is already installed`)
         const view = await viewProfilePackage(this.profile.dir, spec.trim(), {
-          command: this.pnpmCommand, timeoutMs: this.inspectTimeoutMs, ...signal === undefined ? {} : { signal },
+          ...this.profile.packageManager ?? { command: this.pnpmCommand },
+          timeoutMs: this.inspectTimeoutMs, ...signal === undefined ? {} : { signal },
         })
         const log = `${view.stderr}${view.cause === undefined ? '' : `${messageOf(view.cause)}\n`}`.trim()
         if (view.exitCode !== 0 || view.cause !== undefined || view.timedOut) {
@@ -476,7 +477,7 @@ export class PluginManager extends TypertRemoteService {
     const cwd = this.profile.dir
     const identity = requestId === undefined ? {} : { requestId }
     const task = runProfilePnpm({ ...this.profile, profile: this.profile.name }, args, {
-      execution: 'service', command: this.pnpmCommand,
+      execution: 'service', ...this.profile.packageManager ?? { command: this.pnpmCommand },
       signal: signal === undefined ? this.abort.signal : AbortSignal.any([this.abort.signal, signal]),
       outputBytes: this.outputBytes, activateNewBundles: false,
       onOutput: (text, stream) => {

+ 12 - 4
packages/boot/plugin-manager/src/operations.ts

@@ -25,6 +25,10 @@ export interface PackageOperationContext {
 export interface PackageOperationOptions {
   /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. Defaults to `pnpm`. */
   command?: string
+  /** Prefix arguments for an application-owned executable. */
+  args?: readonly string[]
+  /** Application runtime environment, applied only to this package operation. */
+  env?: Readonly<Record<string, string>>
   /** CLI inherits authentication and terminal descriptors; service scrubs secrets and captures output. */
   execution: 'cli' | 'service'
   signal?: AbortSignal
@@ -111,8 +115,8 @@ export async function runProfilePnpm(
   let output = Buffer.alloc(0)
   let truncated = false
   const cancellation = new AbortController()
-  const child = execa(options.command ?? 'pnpm', args.map(arg => anchorPathSpec(arg, context.cwd)), {
-    cwd: dir, env: options.execution === 'cli' ? process.env : scrubbedParentEnv(), extendEnv: false, reject: false,
+  const child = execa(options.command ?? 'pnpm', [...options.args ?? [], ...args.map(arg => anchorPathSpec(arg, context.cwd))], {
+    cwd: dir, env: { ...(options.execution === 'cli' ? process.env : scrubbedParentEnv()), ...options.env }, extendEnv: false, reject: false,
     stdout: options.execution === 'cli' ? 'inherit' : 'pipe',
     stderr: options.execution === 'cli' ? 'inherit' : 'pipe',
     buffer: false, stdin: options.execution === 'cli' ? 'inherit' : 'ignore', cancelSignal: options.signal === undefined
@@ -197,6 +201,10 @@ export interface PackageViewResult {
 export interface PackageViewOptions {
   /** The pnpm executable name or path. Defaults to `pnpm`. */
   command?: string
+  /** Prefix arguments for an application-owned executable. */
+  args?: readonly string[]
+  /** Application runtime environment, applied only to this package operation. */
+  env?: Readonly<Record<string, string>>
   /** Ends the lookup early; the caller's signal, when it has one. */
   signal?: AbortSignal
   /** Bound on the lookup, in milliseconds. */
@@ -212,8 +220,8 @@ export interface PackageViewOptions {
  * @returns pnpm's exit, output, and how the lookup ended.
  */
 export async function viewProfilePackage(dir: string, spec: string, options: PackageViewOptions): Promise<PackageViewResult> {
-  const result = await execa(options.command ?? 'pnpm', ['view', spec, 'name', 'version', 'description', 'dsh', '--json'], {
-    cwd: dir, env: scrubbedParentEnv(), extendEnv: false, reject: false, stdin: 'ignore',
+  const result = await execa(options.command ?? 'pnpm', [...options.args ?? [], 'view', spec, 'name', 'version', 'description', 'dsh', '--json'], {
+    cwd: dir, env: { ...scrubbedParentEnv(), ...options.env }, extendEnv: false, reject: false, stdin: 'ignore',
     timeout: options.timeoutMs, ...options.signal === undefined ? {} : { cancelSignal: options.signal },
   })
   const cause = result.exitCode === undefined && !result.timedOut && !result.isCanceled

+ 13 - 3
packages/boot/plugin-manager/src/tools.ts

@@ -2,11 +2,14 @@
 import { assertNever } from '@deepseek-ai/dsh-util-values'
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from './index.ts'
+import type {} from '@deepseek-ai/dsh-sandbox-policy'
+import type {} from '@deepseek-ai/dsh-user-approval'
+import { approveEscalation } from '@deepseek-ai/dsh-sandbox'
 import type { PluginEntryId } from './types.ts'
 import { defineTool } from '@deepseek-ai/dsh-tools'
 
 /** Required services for the management tool. */
-export const inject = ['tools', 'pluginManager']
+export const inject = ['tools', 'pluginManager', 'sandboxPolicy']
 
 /** Register one management tool for discovery and the four persistent actions.
  * @param ctx Agent-scoped tool registration context.
@@ -14,7 +17,7 @@ export const inject = ['tools', 'pluginManager']
 export function apply(ctx: Context): void {
   ctx.tools.register(defineTool({
     name: 'plugin_manager',
-    description: 'List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart.',
+    description: 'List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart.',
     parameters: {
       action: { type: 'string', required: true, enum: ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle'], description: 'Management operation.' },
       target: { type: 'string', description: 'Plugin entry id, bundle package name, or installation spec, according to action.' },
@@ -27,7 +30,14 @@ export function apply(ctx: Context): void {
       schema: { type: 'string' },
       render: (_args, value) => [{ type: 'text', text: value }],
     },
-    async execute(args) {
+    async execute(args, exec) {
+      const policy = ctx.sandboxPolicy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
+      await approveEscalation({
+        requestedMode: 'danger-full-access', effectiveMode: policy.mode, subject: 'plugin management operation',
+        justification: `plugin_manager ${JSON.stringify(args)}. Profile changes persist across sessions; installed Host code runs outside the workspace sandbox.`,
+      }, { approver: ctx.get('approval'), agent: exec.agent, callId: exec.callId,
+        toolName: 'plugin_manager', signal: exec.signal })
+      exec.signal.throwIfAborted()
       const manager = ctx.pluginManager
       switch (args.action) {
         case 'list_plugins':

+ 27 - 2
packages/boot/plugin-manager/tests/manager.spec.ts

@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
 import { realpath } from 'node:fs/promises'
 import { join } from 'node:path'
 import { tmpdir } from 'node:os'
-import { pathToFileURL } from 'node:url'
+import { fileURLToPath, pathToFileURL } from 'node:url'
 import type { Context } from '@deepseek-ai/cordis'
 import { expect, it, onTestFinished, vi } from 'vitest'
 import {
@@ -18,7 +18,7 @@ import { Group } from '@deepseek-ai/cordis-plugin-loader'
 import * as operations from '../src/operations.ts'
 import { parse, parseDocument } from 'yaml'
 
-async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}) {
+async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}, packageManager?: ProfileContext['packageManager']) {
   // pnpm resolves workspace roots through native realpath, including Windows 8.3 aliases.
   const home = await realpath(mkdtempSync(join(tmpdir(), 'plugin-manager-')))
   const dir = join(home, 'profiles', 'test')
@@ -41,6 +41,7 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre
   const overlays: PatchOptions[] = overlay ? [{ id: 'managed', disabled: true }] : []
   const profile: ProfileContext = {
     name: 'test',
+    ...(packageManager === undefined ? {} : { packageManager }),
     startedBundles: ['core', 'extra'],
     dir, patchPath: join(dir, 'cordis.patch.yml'), installAnchor: anchor, cwd: home, home,
     overlays, telemetryDisabledEnv: undefined,
@@ -742,3 +743,27 @@ it('applies watched configuration while pnpm installation is still running', asy
   expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra', 'new-bundle'])
   expect(ctx.get('managedProbe')).toBeUndefined()
 })
+
+it('installs and removes with the bundled pnpm when PATH contains no pnpm', async () => {
+  const pnpm = fileURLToPath(new URL('../../../../apps/desktop/node_modules/pnpm/bin/pnpm.mjs', import.meta.url))
+  const { manager, dir } = await fixture('startup', false, undefined, { pnpmCommand: 'must-not-be-used' }, {
+    command: process.execPath, args: ['--expose-internals', pnpm], env: { PATH: '', ELECTRON_RUN_AS_NODE: '1' },
+  })
+  const target = join(dir, 'local-bundle')
+  mkdirSync(target)
+  writeFileSync(join(target, 'package.json'), JSON.stringify({ name: '@test/desktop-manager', version: '1.0.0',
+    dsh: { bundle: { patch: './cordis.patch.yml' } } }))
+  writeFileSync(join(target, 'cordis.patch.yml'), '[]\n')
+  // Fixture-only packages need no registry resolution during this local install.
+  const manifest = readProfileManifest('test', dir)
+  delete manifest.dependencies
+  writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
+  const installed = await manager.installBundle(target)
+  expect(installed.error).toBeUndefined()
+  expect(installed.packageResult?.exitCode).toBe(0)
+  expect(readProfileManifest('test', dir).dependencies).toHaveProperty('@test/desktop-manager')
+  const removed = await manager.removeBundle('@test/desktop-manager')
+  expect(removed.error).toBeUndefined()
+  expect(removed.packageResult?.exitCode).toBe(0)
+  expect(readProfileManifest('test', dir).dependencies ?? {}).not.toHaveProperty('@test/desktop-manager')
+})

+ 14 - 0
packages/boot/plugin-manager/tests/operations.spec.ts

@@ -233,3 +233,17 @@ it('asks the registry through pnpm view in the profile directory and reports how
   expect(missing).toMatchObject({ exitCode: null, timedOut: false })
   expect(missing.cause).toMatchObject({ message: 'spawn pnpm ENOENT', code: 'ENOENT' })
 })
+
+it('uses application-owned executable arguments and environment for package operations and inspection', async () => {
+  const { dir, context } = fixture()
+  const runtime = { command: '/app/electron', args: ['--expose-internals', '/app/pnpm.mjs'], env: { ELECTRON_RUN_AS_NODE: '1', PATH: '/app/bin' } }
+  command.run.mockImplementationOnce(() => result(0, ''))
+  await runProfilePnpm(context, ['add', './extra'], { ...runtime, execution: 'service', outputBytes: 100, activateNewBundles: false })
+  expect(command.run).toHaveBeenLastCalledWith(runtime.command, [...runtime.args, 'add', resolve(context.cwd, 'extra')],
+    expect.objectContaining({ env: expect.objectContaining(runtime.env) as unknown }))
+  command.run.mockResolvedValueOnce(Object.assign({ exitCode: 0, failed: false }, { stdout: '{}', stderr: '', timedOut: false }))
+  await viewProfilePackage(dir, 'example', { ...runtime, timeoutMs: 1000 })
+  expect(command.run).toHaveBeenLastCalledWith(runtime.command,
+    [...runtime.args, 'view', 'example', 'name', 'version', 'description', 'dsh', '--json'],
+    expect.objectContaining({ env: expect.objectContaining(runtime.env) as unknown }))
+})

+ 118 - 3
packages/boot/plugin-manager/tests/tools.spec.ts

@@ -3,6 +3,11 @@ import { Context } from '@deepseek-ai/cordis'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRuntime from '@deepseek-ai/dsh-tools'
 import { ToolCallId } from '@deepseek-ai/dsh-llm'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import SandboxPolicy, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
+import { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
+import SessionProjections from '@deepseek-ai/dsh-session-projection'
+import ApprovalService, { type ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
 import { expect, it, onTestFinished, vi } from 'vitest'
 import type PluginManager from '../src/index.ts'
 import * as tool from '../src/tools.ts'
@@ -12,7 +17,7 @@ function resultText(result: Awaited<ReturnType<ToolRuntime['execute']>>): string
   return result.value
 }
 
-async function fixture() {
+async function fixture(mode: 'read-only' | 'workspace-write' | 'danger-full-access' = 'danger-full-access', approval?: 'ask' | 'never') {
   const ctx = new Context()
   onTestFinished(() => ctx.fiber.dispose())
   const manager = {
@@ -26,12 +31,122 @@ async function fixture() {
   ctx.provide('pluginManager', manager as unknown as PluginManager)
   await ctx.plugin(SystemPrompt)
   await ctx.plugin(ToolRuntime)
+  await ctx.plugin(SessionProjections)
+  await ctx.plugin(SandboxPolicy, { mode })
+  if (approval !== undefined) await ctx.plugin(ApprovalService, { policy: approval })
   const fiber = await ctx.plugin(tool)
-  const call = (args: unknown) => ctx.tools.execute({ name: 'plugin_manager', arguments: args,
-    callId: ToolCallId('manager-call'), signal: new AbortController().signal })
+  const call = (args: unknown, agent?: Agent, signal = new AbortController().signal) => ctx.tools.execute({ name: 'plugin_manager', arguments: args,
+    ...agent === undefined ? {} : { agent },
+    callId: ToolCallId('manager-call'), signal })
   return { ctx, manager, call, fiber }
 }
 
+it.each(['read-only', 'workspace-write'] as const)('denies every management action in %s before accessing the manager', async (mode) => {
+  const { call, manager } = await fixture(mode)
+  for (const action of ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle']) {
+    const result = await call({ action, target: 'bundle', enabled: true })
+    expect(result.isError).toBe(true)
+    expect(JSON.stringify(result.content)).toContain('requires approval, but no approval service is composed')
+  }
+  for (const method of Object.values(manager)) expect(method).not.toHaveBeenCalled()
+})
+
+it('checks the calling session on each execution, including after permission is revoked', async () => {
+  const { call, manager } = await fixture()
+  const id = SessionId('manager-permissions')
+  const session = Session.create(id, undefined, { version: SESSION_FORMAT_VERSION, id, createdAt: 0, isSeeded: false })
+  const agent = { session } as unknown as Agent
+  setSandboxMode(session, 'workspace-write')
+  expect((await call({ action: 'list_plugins' }, agent)).isError).toBe(true)
+  expect(manager.listPlugins).not.toHaveBeenCalled()
+  setSandboxMode(session, 'danger-full-access')
+  expect((await call({ action: 'list_plugins' }, agent)).isError).toBe(false)
+  setSandboxMode(session, 'read-only')
+  expect((await call({ action: 'list_plugins' }, agent)).isError).toBe(true)
+  expect(manager.listPlugins).toHaveBeenCalledTimes(1)
+})
+
+function activeAgent(): Agent {
+  const id = SessionId('manager-approval')
+  const session = Session.create(id, undefined, { version: SESSION_FORMAT_VERSION, id, createdAt: 0, isSeeded: false })
+  session.append('turn/start', { turn: 1 })
+  return { session } as unknown as Agent
+}
+
+it.each(['read-only', 'workspace-write'] as const)('approves each action once in %s without changing session permissions', async (mode) => {
+  const { ctx, call, manager } = await fixture(mode, 'ask')
+  const agent = activeAgent()
+  const prompted = vi.fn(async () => 'allowed-once' as const)
+  const dispose = ctx.on('approval/request', prompted)
+  for (const action of ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle']) {
+    expect((await call({ action, target: 'bundle', enabled: true }, agent)).isError).toBe(false)
+  }
+  expect(prompted).toHaveBeenCalledTimes(6)
+  for (const method of Object.values(manager)) expect(method).toHaveBeenCalledTimes(1)
+  expect(ctx.sandboxPolicy.resolve({ session: agent.session }).mode).toBe(mode)
+  const audit = agent.session.snapshotEvents().filter(event => event.type.startsWith('approval/'))
+  expect(audit).toHaveLength(12)
+  expect(audit[0]).toMatchObject({ type: 'approval/asked', data: {
+    toolName: 'plugin_manager', callId: 'manager-call',
+  } })
+  const request = audit[0]
+  if (request?.type !== 'approval/asked') throw new Error('Expected an approval request')
+  expect(request.data.reason).toContain('"action":"list_plugins"')
+  expect(audit[1]).toMatchObject({ type: 'approval/decided', data: { outcome: 'allowed-once' } })
+  dispose()
+  expect((await call({ action: 'list_plugins' }, agent)).isError).toBe(true)
+  expect(manager.listPlugins).toHaveBeenCalledTimes(1)
+})
+
+it.each(['rejected', 'cancelled', 'unavailable'] as const)('does not mutate the profile when approval is %s', async (outcome) => {
+  const { ctx, call, manager } = await fixture('workspace-write', 'ask')
+  ctx.on('approval/request', async () => outcome)
+  const agent = activeAgent()
+  expect((await call({ action: 'install_bundle', target: 'bundle' }, agent)).isError).toBe(true)
+  expect(manager.installBundle).not.toHaveBeenCalled()
+  expect(agent.session.snapshotEvents().filter(event => event.type === 'approval/decided')
+    .map(event => event.data.outcome)).toEqual([outcome])
+})
+
+it('rejects never policy without prompting and keeps full-access calls prompt-free', async () => {
+  const { ctx, call, manager } = await fixture('workspace-write', 'never')
+  const prompted = vi.fn(async () => 'allowed-once' as const)
+  ctx.on('approval/request', prompted, { prepend: true })
+  const agent = activeAgent()
+  expect((await call({ action: 'set_plugin', target: 'include:demo', enabled: true }, agent)).isError).toBe(true)
+  expect(manager.setPluginEnabled).not.toHaveBeenCalled()
+  setSandboxMode(agent.session, 'danger-full-access')
+  expect((await call({ action: 'set_plugin', target: 'include:demo', enabled: true }, agent)).isError).toBe(false)
+  expect(manager.setPluginEnabled).toHaveBeenCalledTimes(1)
+  expect(prompted).not.toHaveBeenCalled()
+})
+
+it('cancels an approval wait before any manager operation', async () => {
+  const { ctx, call, manager } = await fixture('workspace-write', 'ask')
+  const asked = Promise.withResolvers<undefined>()
+  const answer = Promise.withResolvers<ApprovalOutcome>()
+  ctx.on('approval/request', () => { asked.resolve(undefined); return answer.promise })
+  const controller = new AbortController()
+  const result = call({ action: 'install_bundle', target: 'bundle' }, activeAgent(), controller.signal)
+  await asked.promise
+  expect(manager.installBundle).not.toHaveBeenCalled()
+  controller.abort()
+  answer.resolve('allowed-once')
+  expect((await result).isError).toBe(true)
+  expect(manager.installBundle).not.toHaveBeenCalled()
+})
+
+it('does not apply a grant when the call was cancelled before dispatch', async () => {
+  const { ctx, call, manager } = await fixture('workspace-write', 'ask')
+  const controller = new AbortController()
+  vi.spyOn(ctx.approval, 'request').mockImplementation(async () => {
+    controller.abort()
+    return 'allowed-once'
+  })
+  expect((await call({ action: 'install_bundle', target: 'bundle' }, activeAgent(), controller.signal)).isError).toBe(true)
+  expect(manager.installBundle).not.toHaveBeenCalled()
+})
+
 it('paginates inventories with an explicit continuation and total', async () => {
   const { call } = await fixture()
   const first = await call({ action: 'list_plugins' })

+ 9 - 0
packages/boot/plugin-manager/tsconfig.json

@@ -52,6 +52,15 @@
     },
     {
       "path": "../../util/brand"
+    },
+    {
+      "path": "../../sandbox/sandbox-policy"
+    },
+    {
+      "path": "../../sandbox/sandbox"
+    },
+    {
+      "path": "../../interaction/user-approval"
     }
   ]
 }

+ 2 - 2
packages/client/ui-agent-preset/src/client/locales.ts

@@ -44,7 +44,7 @@ export const en: Record<AgentPresetSettingsKey, string> = {
     'Single-tool coding agent with a persistent shell.',
   presetCordisName: 'Creator mode',
   presetCordisDescription:
-    'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance.',
+    'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, persistent plugin management, and preset-authoring guidance.',
   duplicate: 'Duplicate',
   duplicateUnavailable: 'This deployment has no writable preset directory',
   delete: 'Delete',
@@ -109,7 +109,7 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
   presetMinimalName: '极简模式',
   presetMinimalDescription: '仅提供持久 shell 的单工具编码 Agent。',
   presetCordisName: '创造模式',
-  presetCordisDescription: '用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。',
+  presetCordisDescription: '用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。',
   duplicate: '复制',
   duplicateUnavailable: '此部署未配置可写的预设目录',
   delete: '删除',

Некоторые файлы не были показаны из-за большого количества измененных файлов