Procházet zdrojové kódy

Merge remote-tracking branch 'origin/master' into worktree/code-diff-card

creatixchu před 4 týdny
rodič
revize
9a74f173d3
100 změnil soubory, kde provedl 1280 přidání a 309 odebrání
  1. 6 0
      .agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.i18n.yaml
  2. 29 0
      .agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.md
  3. 29 0
      .agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.zh.md
  4. 6 0
      .agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml
  5. 29 0
      .agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md
  6. 29 0
      .agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml
  8. 1 1
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md
  9. 1 1
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml
  11. 2 2
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
  12. 2 2
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md
  13. 0 27
      .agents/notes/implemented/feature/2026-09-14-model-image-input-settings.md
  14. 0 27
      .agents/notes/implemented/feature/2026-09-14-model-image-input-settings.zh.md
  15. 3 3
      .agents/notes/implemented/feature/2026-09-15-bundled-office-skills.i18n.yaml
  16. 31 0
      .agents/notes/implemented/feature/2026-09-15-bundled-office-skills.md
  17. 31 0
      .agents/notes/implemented/feature/2026-09-15-bundled-office-skills.zh.md
  18. 6 0
      .agents/notes/implemented/feature/2026-09-15-sidebar-workspace-hierarchy.i18n.yaml
  19. 31 0
      .agents/notes/implemented/feature/2026-09-15-sidebar-workspace-hierarchy.md
  20. 31 0
      .agents/notes/implemented/feature/2026-09-15-sidebar-workspace-hierarchy.zh.md
  21. 6 0
      .agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.i18n.yaml
  22. 29 0
      .agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.md
  23. 29 0
      .agents/notes/implemented/feature/2026-09-16-unified-model-input-controls.zh.md
  24. 6 0
      .agents/notes/implemented/process/2026-09-16-author-approval-credit.i18n.yaml
  25. 25 0
      .agents/notes/implemented/process/2026-09-16-author-approval-credit.md
  26. 25 0
      .agents/notes/implemented/process/2026-09-16-author-approval-credit.zh.md
  27. 1 1
      .github/review-ownership/README.md
  28. 4 4
      .github/review-ownership/author-weight.mjs
  29. 4 4
      .github/review-ownership/author-weight.test.mjs
  30. 27 12
      .github/review-ownership/check-approval.test.mjs
  31. 12 4
      apps/cli/tests/desktop-host.e2e.ts
  32. 2 1
      apps/desktop-host/package.json
  33. 2 2
      apps/desktop-host/src/index.ts
  34. 26 0
      apps/desktop-host/src/office.ts
  35. 58 0
      apps/desktop-host/tests/office.spec.ts
  36. 1 0
      apps/desktop-host/tsconfig.json
  37. 2 2
      apps/desktop/README.i18n.yaml
  38. 2 0
      apps/desktop/README.md
  39. 2 0
      apps/desktop/README.zh.md
  40. 17 2
      apps/desktop/scripts/prepare-primary-runtime.ts
  41. 9 0
      apps/desktop/scripts/smoke-primary-runtime.py
  42. 2 1
      apps/desktop/src/host-process.ts
  43. 24 2
      apps/desktop/tests/primary-runtime-preparation.spec.ts
  44. 84 0
      apps/web/tests/expected/models-settings/catalog-inputs.expected.md
  45. 15 10
      apps/web/tests/expected/models-settings/declared-edit.expected.md
  46. 6 5
      apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md
  47. 6 5
      apps/web/tests/expected/onboarding-deepseek-config/models.expected.md
  48. 13 0
      apps/web/tests/expected/workspace-management/grouping-options.expected.md
  49. 11 0
      apps/web/tests/expected/workspace-management/parent-folders.expected.md
  50. 31 0
      apps/web/tests/model-input-layout.ts
  51. 89 11
      apps/web/tests/models-settings.e2e.ts
  52. 7 5
      apps/web/tests/onboarding-deepseek-config.e2e.ts
  53. 55 5
      apps/web/tests/permission-policy-context.e2e.ts
  54. 1 1
      apps/web/tests/schedule-after.e2e.ts
  55. 93 3
      apps/web/tests/workspace-management.e2e.ts
  56. 2 2
      docs/capability-seams.i18n.yaml
  57. 3 1
      docs/capability-seams.md
  58. 3 1
      docs/capability-seams.zh.md
  59. 2 2
      docs/config-catalog.i18n.yaml
  60. 18 2
      docs/config-catalog.md
  61. 18 2
      docs/config-catalog.zh.md
  62. 2 2
      docs/event-producer-consumer.i18n.yaml
  63. 1 1
      docs/event-producer-consumer.md
  64. 1 1
      docs/event-producer-consumer.zh.md
  65. 2 2
      docs/module-graph.i18n.yaml
  66. 3 0
      docs/module-graph.md
  67. 3 0
      docs/module-graph.zh.md
  68. 2 2
      docs/persistence-catalog.i18n.yaml
  69. 14 14
      docs/persistence-catalog.md
  70. 14 14
      docs/persistence-catalog.zh.md
  71. 3 3
      docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.schema.json
  72. 12 12
      docs/persistence-schema.json
  73. 2 2
      docs/subsystems/llm-streaming.i18n.yaml
  74. 2 0
      docs/subsystems/llm-streaming.md
  75. 2 0
      docs/subsystems/llm-streaming.zh.md
  76. 2 2
      docs/subsystems/skills.i18n.yaml
  77. 2 2
      docs/subsystems/skills.md
  78. 2 2
      docs/subsystems/skills.zh.md
  79. 2 2
      docs/subsystems/workspace.i18n.yaml
  80. 1 1
      docs/subsystems/workspace.md
  81. 1 1
      docs/subsystems/workspace.zh.md
  82. 2 2
      docs/user/guide/providers.i18n.yaml
  83. 11 7
      docs/user/guide/providers.md
  84. 11 7
      docs/user/guide/providers.zh.md
  85. 2 1
      package.json
  86. 2 2
      packages/api/terminal-controller/README.i18n.yaml
  87. 4 3
      packages/api/terminal-controller/README.md
  88. 4 3
      packages/api/terminal-controller/README.zh.md
  89. 7 25
      packages/api/terminal-controller/src/index.ts
  90. 24 29
      packages/api/terminal-controller/tests/controller.spec.ts
  91. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  92. 1 0
      packages/client/ui-primitives/README.md
  93. 1 0
      packages/client/ui-primitives/README.zh.md
  94. 28 0
      packages/client/ui-primitives/src/Checkbox.module.css
  95. 30 0
      packages/client/ui-primitives/src/Checkbox.tsx
  96. 1 0
      packages/client/ui-primitives/src/index.ts
  97. 26 0
      packages/client/ui-primitives/tests/checkbox.client.spec.tsx
  98. 2 2
      packages/client/ui-settings-models/README.i18n.yaml
  99. 3 3
      packages/client/ui-settings-models/README.md
  100. 3 3
      packages/client/ui-settings-models/README.zh.md

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.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-user-terminal-permissions.md
+2026-09-16-user-terminal-permissions.md: f45b96419c34f8ebddc30431b42c9859c46de6b9
+2026-09-16-user-terminal-permissions.zh.md: 62bb152a1b899d026c8d7bddb350317ba12f14cf

+ 29 - 0
.agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.md

@@ -0,0 +1,29 @@
+# Agent Note: User-terminal permissions
+
+Status: implemented
+
+English | [中文](2026-09-16-user-terminal-permissions.zh.md)
+
+## Problem
+
+Users need to run commands themselves while keeping the Agent restricted. Sharing the Agent's sandbox mode forces a user to widen Agent access for manual work, and retaining an interactive shell prevents later mode changes because its process confinement cannot follow a new Session setting.
+
+## Decision
+
+The Web sidebar terminal runs directly through the Session's subprocess provider with the execution environment's system-user permissions. It neither confines the shell through the Agent sandbox nor requests Agent approval. Operating-system permissions, container isolation and the provider's credential-environment scrubbing continue to apply. Session identity owns access, process cleanup and the initial directory; sandbox policy supplies only the configured directory fallback when the Session has no cwd.
+
+Agent permission changes leave user terminals running. Agent-owned shell and terminal tools retain their own sandbox enforcement. User terminal input and output create no model input or Session events.
+
+This decision supersedes only the shared sandbox policy and mode-switch restriction in the [Web sidebar terminal decision](../feature/2026-09-09-web-sidebar-terminal.md). That note remains active for process ownership, transport, screen recovery and shell selection. OpenCode's `packages/core/src/pty.ts` and `packages/core/src/pty/pty.node.ts` provide adjacent evidence: its interactive terminal creates a PTY directly with the selected shell and working directory.
+
+## Alternatives considered
+
+**Inherit Agent permissions.** One Session mode describes both processes, but users must also grant the Agent access needed only for manual commands. Persistent user shells then obstruct changes to Agent permissions.
+
+**Add a separate terminal permission selector.** The product treats this terminal as a user-operated system shell. Another selector adds policy state and process-restart semantics without a current requirement; deployment and operating-system controls already determine the execution environment.
+
+## Consequences
+
+Access to the Web terminal grants command execution as the subprocess provider's system user, including writes outside the Session workspace where that user has permission. It does not grant root or escape a container. Session ownership remains useful for grouping and cleanup without implying Agent authority over user actions.
+
+Controller tests pin direct shell launch across all Agent sandbox modes and continued ownership during mode changes. The recorded Web permission-policy scenario keeps one real user PTY open across read-only, full-access and workspace-write transitions, verifies writes inside and outside the workspace, and retains the Agent's read-only denial and approval assertions. The Bash browser assertions run on macOS and Linux; Windows retains the portable controller checks and Agent-policy replay.

+ 29 - 0
.agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: User-terminal permissions
+
+Status: implemented
+
+[English](2026-09-16-user-terminal-permissions.md) | 中文
+
+## 问题
+
+用户需要在限制 Agent(智能体)权限的同时亲自运行命令。共享 Agent 的沙箱模式会迫使用户为了手动操作而扩大 Agent 权限;保留交互式 shell 又会阻止后续模式切换,因为已有进程的沙箱限制无法跟随新的 Session 设置改变。
+
+## 决策
+
+Web 侧栏终端直接通过 Session 的 subprocess provider 运行,使用执行环境中系统用户的权限。它不通过 Agent 沙箱限制 shell,也不请求 Agent 审批。操作系统权限、容器隔离和 provider 对环境凭据的清除仍然生效。Session 标识负责访问范围、进程清理和初始目录;sandbox policy 仅在 Session 没有 cwd 时提供配置的默认目录。
+
+改变 Agent 权限时,用户终端继续运行。Agent 使用的 shell 和 terminal 工具保留各自的沙箱限制。用户终端的输入和输出不产生模型输入或 Session 事件。
+
+本决策仅取代 [Web 侧栏终端决策](../feature/2026-09-09-web-sidebar-terminal.zh.md)中的共享沙箱策略和模式切换限制。原记录继续负责进程所有权、传输、屏幕恢复和 shell 选择。OpenCode 的 `packages/core/src/pty.ts` 和 `packages/core/src/pty/pty.node.ts` 提供相邻实现依据:其交互式终端使用选定的 shell 和工作目录直接创建 PTY。
+
+## 考虑过的替代方案
+
+**继承 Agent 权限。** 一个 Session 模式可以描述两类进程,但用户必须同时授予 Agent 仅用于手动命令的权限。持久用户 shell 随之阻碍 Agent 权限切换。
+
+**增加独立的终端权限选择器。** 产品将此终端视为用户操作的系统 shell。另一个选择器会增加策略状态和进程重启语义,目前没有对应需求;部署和操作系统控制已经确定执行环境。
+
+## 影响
+
+访问 Web 终端即可作为 subprocess provider 的系统用户执行命令,包括在该用户有权限时写入 Session 工作区之外的路径。它不会授予 root 权限或逃逸容器。Session 所有权继续用于分组和清理,不意味着 Agent 决定用户操作的权限。
+
+Controller 测试覆盖全部 Agent 沙箱模式下的直接 shell 启动,以及模式改变后的持续所有权。录制的 Web 权限策略场景在只读、完全访问和工作区写入之间切换时保持同一个真实用户 PTY,验证工作区内外的写入,并保留 Agent 的只读拒绝和审批断言。Bash 浏览器断言在 macOS 和 Linux 运行;Windows 保留可移植的 controller 检查和 Agent 策略回放。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.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/bug-fix/2026-09-16-messages-historical-tool-input.md
+2026-09-16-messages-historical-tool-input.md: 201b0f21ad041395a4c4101d6919b2878e22c9ab
+2026-09-16-messages-historical-tool-input.zh.md: bcc637982420124b401ef65c668b068394b6d915

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md

@@ -0,0 +1,29 @@
+# Agent Note: Replay malformed historical tool input through Messages
+
+Status: implemented
+
+English | [中文](2026-09-16-messages-historical-tool-input.zh.md)
+
+## Problem
+
+Chat Completions retains tool arguments as strings, including malformed JSON from failed calls. Switching that history to Messages requires an object for each `tool_use.input`. Rejecting one historical argument blocks every later request containing it, even after a successful tool retry; a summarization request containing the same call also fails.
+
+## Decision
+
+The [Messages serializer](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) follows the [pi-ai history conversion](../../../../packages/llm/llm-pi-ai/src/replay.ts): malformed JSON and non-object values become `{}` only in the outgoing historical tool input. Call ids, names, results, and original Session records remain intact. This applies with valid, absent, or unusable native replay metadata and does not execute the historical call again.
+
+This supersedes the historical argument rejection in the [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md). New Messages responses still require valid object arguments before successful completion; output-limit truncation retains its existing pruning behavior. No Session event, persistence type, or protocol configuration changes.
+
+## Alternatives considered
+
+**Reject malformed history.** A failed call can remain relevant evidence without preventing all subsequent model requests.
+
+**Repair or overwrite stored arguments.** Guessing missing quotes or retaining a parsed prefix can change the requested operation. Request-only empty input preserves the original evidence and requires no migration.
+
+**Drop the call.** Its result still cites the call id; keeping both preserves the tool exchange without inventing arguments.
+
+## Consequences
+
+Messages continuation can omit unusable historical parameters without losing the call identity or result. The model sees `{}` rather than the original malformed text, and the fallback is silent, matching pi-ai. Original arguments remain available in the Session log; this does not claim lossless provider input or repair invalid newly generated calls.
+
+Verification covers object-only conversion, failed results followed by user input, JSON round trips, both valid and degraded replay metadata, a [recorded Session](../../../../snapshots/session/deepseek-messages-invalid-tool-history/snapshot.yml) through the shipped headless profile and real Messages serializer, and a credential-gated live Messages continuation.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 通过 Messages 回放非法历史工具输入
+
+Status: implemented
+
+[English](2026-09-16-messages-historical-tool-input.md) | 中文
+
+## 问题
+
+Chat Completions 将工具参数保留为字符串,其中可能包含失败调用产生的非法 JSON。将这段历史切换到 Messages 时,每个 `tool_use.input` 都必须是对象。拒绝一条历史参数就会阻断包含它的所有后续请求,即使工具重试已经成功;包含同一调用的摘要请求也会失败。
+
+## 决策
+
+[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) 遵循 [pi-ai 历史转换](../../../../packages/llm/llm-pi-ai/src/replay.ts)的做法:只在发出的历史工具输入中,将非法 JSON 和非对象值替换为 `{}`。调用 ID、名称、结果和原始 Session 记录保持不变。原生回放元数据有效、缺失或不可用时均采用此规则,也不会重新执行历史调用。
+
+这取代了 [Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)中的历史参数拒绝规则。新生成的 Messages 响应在成功完成前仍要求工具参数是有效对象;达到输出上限时仍按现有规则裁剪。不改变 Session 事件、持久化类型或协议配置。
+
+## 考虑过的替代方案
+
+**拒绝非法历史。** 失败调用可以继续作为相关证据保留,而不必阻断所有后续模型请求。
+
+**修复或覆盖已存参数。** 猜测缺失的引号或保留部分解析结果可能改变请求的操作。只在请求中使用空输入可以保留原始证据,也不需要迁移。
+
+**删除调用。** 对应结果仍引用调用 ID;同时保留调用和结果,可以保留工具交互而不编造参数。
+
+## 后果
+
+Messages 可以在省略不可用历史参数的同时继续会话,并保留调用身份和结果。模型看到的是 `{}`,而不是原始非法文本;该兜底与 pi-ai 一样不产生诊断。原始参数仍可在 Session 日志中查阅;这不保证提供方输入无损,也不修复新生成的非法调用。
+
+验证覆盖仅接受对象的转换、失败结果后的用户输入、JSON 往返、有效与降级的回放元数据、通过已发布 headless profile 和真实 Messages 序列化器运行的[录制 Session](../../../../snapshots/session/deepseek-messages-invalid-tool-history/snapshot.yml),以及需要凭据的真实 Messages 续接。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.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-09-07-deepseek-messages-adapter.md
-2026-09-07-deepseek-messages-adapter.md: 6b7aff250aacd4bb7d0af3b4e68ee5b2002c13e3
-2026-09-07-deepseek-messages-adapter.zh.md: 829e2189cb48a2acaa1ec27f7ccade0f163b58ad
+2026-09-07-deepseek-messages-adapter.md: c667ec3afdd9956282a532e748ad2e2488d26f5b
+2026-09-07-deepseek-messages-adapter.zh.md: 0e3425e41d8a9585a1638eedeefcf6ead76a5a78

+ 1 - 1
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md

@@ -14,7 +14,7 @@ The [DeepSeek adapter](../../../../packages/llm/llm-deepseek/README.md) serves m
 
 The adapter follows the [DeepSeek compatibility documentation](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) and [Anthropic streaming protocol](https://platform.claude.com/docs/en/build-with-claude/streaming). The pi-ai Anthropic implementation informed the handling of adjacent user messages, cumulative usage, fragmented tool arguments, and optional thinking signatures. DeepSeek effort uses `output_config.effort`; an Anthropic thinking token budget does not control DeepSeek effort. Both protocols forward explicit `temperature` values; DeepSeek accepts that parameter with thinking enabled and ignores its value, so callers retain their existing thinking configuration.
 
-Assistant blocks remain the durable model-visible content. A versioned `ReplayEnvelope` stores only the protocol format, model identity, aligned block kinds, and signatures absent from those blocks. Same-model Messages continuation restores signatures verbatim, including empty signatures; foreign history carries no invented signature. Unusable metadata follows the existing [replay degradation rule](../architecture/2026-07-14-provider-routed-llm-adapters.md): the request omits signatures with a warning while preserving durable content; content validation such as tool argument parsing still fails explicitly. This keeps provider replay data opaque to the loop while preserving it through Session persistence and block pruning.
+Assistant blocks remain the durable model-visible content. A versioned `ReplayEnvelope` stores only the protocol format, model identity, aligned block kinds, and signatures absent from those blocks. Same-model Messages continuation restores signatures verbatim, including empty signatures; foreign history carries no invented signature. Unusable metadata follows the existing [replay degradation rule](../architecture/2026-07-14-provider-routed-llm-adapters.md): the request omits signatures with a warning while preserving durable content; historical tool arguments use the [empty-input fallback](../bug-fix/2026-09-16-messages-historical-tool-input.md) when Messages cannot represent them. This keeps provider replay data opaque to the loop while preserving it through Session persistence and block pruning.
 
 Both protocols prefer Files references for deterministic request images and share upload caching, refresh, quota recovery, and attachment offload. The Files client retains the selected protocol and configured endpoint: Messages follows the [exact `/v1` root rule](../bug-fix/2026-09-15-messages-v1-base-url.md), while Chat Completions appends `/files`. Messages Files requests carry the required beta header. Cached ids remain scoped by the resolved Files root and credential, so equivalent `/v1` and unversioned Messages roots share uploads. Messages metadata omits expiry, so local reuse is bounded from the original upload time without asserting remote deletion. A Files-resolution failure rebuilds the complete request under the independent inline-image budget; caller cancellation stops it. The shared image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md

@@ -14,7 +14,7 @@ Status: implemented
 
 适配器遵循 [DeepSeek 兼容文档](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) 和 [Anthropic 流协议](https://platform.claude.com/docs/en/build-with-claude/streaming)。pi-ai 的 Anthropic 实现为相邻用户消息、累计用量、工具参数分片和可选思考签名的处理提供参考。DeepSeek 通过 `output_config.effort` 设置思考强度;Anthropic 思考 token 预算不控制 DeepSeek 思考强度。两种协议都转发显式 `temperature` 值;DeepSeek 在启用思考时接受该参数但忽略其值,因此调用方可以保留已有思考配置。
 
-助手内容块保留持久化的模型可见内容。带版本的 `ReplayEnvelope` 仅保存协议格式、模型标识、对齐的块类型以及内容块未包含的签名。同模型续接原样恢复签名,包括空签名;外部历史不生成虚构签名。不可用的元数据遵循现有[回放降级规则](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):请求省略签名并记录警告,保留持久化内容;工具参数等内容校验仍会正常报错。提供者回放数据对循环保持不透明,同时能够随 Session 持久化和内容块裁剪保留。
+助手内容块保留持久化的模型可见内容。带版本的 `ReplayEnvelope` 仅保存协议格式、模型标识、对齐的块类型以及内容块未包含的签名。同模型续接原样恢复签名,包括空签名;外部历史不生成虚构签名。不可用的元数据遵循现有[回放降级规则](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):请求省略签名并记录警告,保留持久化内容;Messages 无法表示历史工具参数时使用[空输入兜底](../bug-fix/2026-09-16-messages-historical-tool-input.zh.md)。提供者回放数据对循环保持不透明,同时能够随 Session 持久化和内容块裁剪保留。
 
 两种协议均优先为确定性请求图片使用 Files 引用,并共享上传缓存、刷新、配额恢复和附件卸载。Files 客户端保留所选协议与已配置端点:Messages 遵循[严格匹配 `/v1` 的根地址规则](../bug-fix/2026-09-15-messages-v1-base-url.zh.md),Chat Completions 则追加 `/files`。Messages Files 请求携带必需的 beta 标头。缓存 id 按解析后的 Files 根地址和凭据限定作用域,因此等价的 `/v1` 与无版本 Messages 根地址可以复用上传。Messages 元数据不含过期时间,因此本地复用从原始上传时间起受限,但不宣称远端文件已删除。Files 解析失败会按独立的内联图片预算重建完整请求;调用方取消则停止请求。共享图片策略在请求与 token 计量中保留 128 MiB 的保留图片预算、20 MiB 的内联 base64 预算,以及最旧前缀卸载。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.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-09-09-web-sidebar-terminal.md
-2026-09-09-web-sidebar-terminal.md: 2649610e776029b10b11fc4ea87d75a24a58d32b
-2026-09-09-web-sidebar-terminal.zh.md: 9a3fd828ff52142cb945ff14d84d13c9e8522ecf
+2026-09-09-web-sidebar-terminal.md: 5757aed3702dbcc7752e99912714bd1356a96206
+2026-09-09-web-sidebar-terminal.zh.md: 39870d1ba26cbc1d655b35a3d2dba61692d0fdb9

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md

@@ -14,7 +14,7 @@ Guide entries declare stable ids within their provider. A keyed `sidebar.right.t
 
 The application theme supplies terminal default colors. The body reads resolved CSS tokens, and updates xterm only when those colors change. Public OSC parser observers retain indexed and default-color overrides separately from the DSH defaults; resets remove the corresponding override before restoring the current theme. Observers delegate queries and color handling to xterm. xterm's minimum contrast adjustment improves text legibility without remapping ANSI backgrounds. The DOM cursor reads the rendered cell background after each render and uses a contrasting fill through scoped CSS variables, so cursor movement never resets the palette. Browser checks cover indexed, true-color and inverse cells, light/dark switching, OSC retention and reset, and blinking cursor styles.
 
-`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. The terminal guide card has a primary action for the remembered available shell and a separate installed-shell menu. Selecting a menu item records its path and opens a new terminal immediately; discovery alone allocates no process. Each tab owns its startup and close lifecycle. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last selected shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions.
+`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. The terminal guide card has a primary action for the remembered available shell and a separate installed-shell menu. Selecting a menu item records its path and opens a new terminal immediately; discovery alone allocates no process. Each tab owns its startup and close lifecycle. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last selected shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider; [user-terminal permissions](../architecture/2026-09-16-user-terminal-permissions.md) govern their execution permissions independently of the Agent. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions.
 
 Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup.
 
@@ -44,7 +44,7 @@ The latest attachment controls input and dimensions; other attachments remain re
 
 ## Consequences
 
-A kept-open terminal retains a process and bounded screen memory. Reload restores the sidebar layout and reconnects Host-retained terminals; Host restart does not restore processes. An exited shell remains visible without automatic respawn. Background cleanup may outlive its tab, and unavailable browser storage limits retry recovery to the current page. Native PTY support and descendant cleanup guarantees remain provider-specific. One writable attachment avoids competing resize and input streams, while explicit takeover permits recovery from another page. Changing sandbox mode requires closing retained terminals first.
+A kept-open terminal retains a process and bounded screen memory. Reload restores the sidebar layout and reconnects Host-retained terminals; Host restart does not restore processes. An exited shell remains visible without automatic respawn. Background cleanup may outlive its tab, and unavailable browser storage limits retry recovery to the current page. Native PTY support and descendant cleanup guarantees remain provider-specific. One writable attachment avoids competing resize and input streams, while explicit takeover permits recovery from another page. User terminals remain open when the Session sandbox mode changes.
 
 The implementation retains the Agent-terminal and portable-execution notes because their ownership and provider decisions remain independently useful; neither is superseded by browser terminals.
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md

@@ -14,7 +14,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命
 
 应用主题提供终端的默认颜色。终端正文读取解析后的 CSS 令牌,仅在颜色变化时更新 xterm。公开的 OSC 解析观察器将索引色和默认颜色覆盖与 DSH 默认值分开保存;重置命令先删除对应覆盖,再恢复当前主题。观察器将查询和颜色处理交给 xterm。xterm 的最小对比度调整改善文字可读性,同时不重新映射 ANSI 背景色。DOM 光标在每次渲染后读取单元格实际背景,通过局部 CSS 变量使用有足够对比度的填充色,因此光标移动不会重置调色板。浏览器检查覆盖索引色、真彩色、反色单元格、明暗主题切换、OSC 保留和重置,以及闪烁光标样式。
 
-`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。终端开始页卡片的主操作打开上次选择且仍可用的 shell,独立菜单提供已安装 shell。选择菜单项会记录路径并立即打开新终端;仅探测 shell 不分配进程。每个标签页拥有自己的启动和关闭生命周期。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次选择的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。
+`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。终端开始页卡片的主操作打开上次选择且仍可用的 shell,独立菜单提供已安装 shell。选择菜单项会记录路径并立即打开新终端;仅探测 shell 不分配进程。每个标签页拥有自己的启动和关闭生命周期。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次选择的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider;[用户终端权限](../architecture/2026-09-16-user-terminal-permissions.zh.md)规定其独立于 Agent 的执行权限。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。
 
 关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。
 
@@ -44,7 +44,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命
 
 ## 影响
 
-保留终端会保留进程和有界屏幕内存。刷新恢复侧栏布局并重连 Host 保留的终端;Host 重启不恢复进程。shell 退出后保持可见,不自动重启。后台清理可能比标签页存活更久,浏览器存储不可用时只能在当前页面保留重试能力。原生 PTY 支持和后代进程清理保证仍由 provider 决定。单一可写连接避免竞争的输入和尺寸流,显式接管允许从另一页面恢复操作。改变 sandbox mode 前需要关闭保留的终端。
+保留终端会保留进程和有界屏幕内存。刷新恢复侧栏布局并重连 Host 保留的终端;Host 重启不恢复进程。shell 退出后保持可见,不自动重启。后台清理可能比标签页存活更久,浏览器存储不可用时只能在当前页面保留重试能力。原生 PTY 支持和后代进程清理保证仍由 provider 决定。单一可写连接避免竞争的输入和尺寸流,显式接管允许从另一页面恢复操作。Session 的沙箱模式改变时,用户终端保持打开。
 
 Agent 终端和可移植执行环境两篇记录仍保留,其所有权与 provider 决策继续独立有效,不被浏览器终端取代。
 

+ 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)仍由现有决策负责。

+ 3 - 3
.agents/notes/implemented/feature/2026-09-14-model-image-input-settings.i18n.yaml → .agents/notes/implemented/feature/2026-09-15-bundled-office-skills.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/feature/2026-09-15-bundled-office-skills.md
+2026-09-15-bundled-office-skills.md: 089ca0326a7c53d4ee023608010efd49a8616db2
+2026-09-15-bundled-office-skills.zh.md: cd10aa863b1149594e9eb6b6a8e479511424f20d

+ 31 - 0
.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.md

@@ -0,0 +1,31 @@
+# Agent Note: Bundled Office skills with structural verification
+
+Status: implemented
+
+English | [中文](2026-09-15-bundled-office-skills.zh.md)
+
+## Problem
+
+Office tasks need format-specific editing guidance and dependable file checks. Requiring users to install interpreters, package managers, or rendering command-line tools interrupts ordinary document delivery. Structural heuristics can also reject valid merged tables, multi-section documents, or Chinese text without observing an actual layout defect.
+
+## Decision
+
+The [Office provider](../../../../packages/skill/skill-office/README.md) contributes three independently discoverable skills at the bundled rank. The default workflow uses `load_workspace_dependencies` and its Python executable; explicit user and AGENTS.md environment choices take precedence. A configurable absolute asset root lets Desktop expose Python-readable resources outside its application archive. Registration validates the required resources and YAML descriptions; loaded instructions exclude the metadata. Disposal removes every candidate.
+
+One standard-library checker recognizes Transitional and Strict OOXML namespaces, validates ZIP/XML integrity and internal relationships, reports format-specific structure, and checks only explicit text or count assertions. Corrupt or encrypted ZIP members produce the same JSON package-failure report as other invalid documents. DOCX table summaries count logical grid columns, including merged cells. Section geometry is reported rather than judged against the final section; font filenames do not establish glyph coverage. XLSX formula counts never imply recalculation. Text assertions follow section and note references and worksheet string indices, so retained headers, unused note definitions, comments, glossary entries, and unused strings cannot satisfy requested wording.
+
+Desktop mounts the skill provider and runtime query independently of document rendering. Word uses python-docx, PowerPoint creation and editing use python-pptx, and Excel uses openpyxl and pandas. The managed payload and the ordinary creation examples require neither a rendering engine nor a separate presentation authoring library.
+
+Office skills and the bundled-runtime query remain registered without a rendering service. Visual inspection depends on the active model accepting images and a rendering tool being available. Otherwise the skills complete structural and content checks and deliver with the unverified visual scope stated. `present` refers to the current workspace source file; it does not preserve a private copy.
+
+## Alternatives considered
+
+**Require a plan and a local renderer before every delivery.** Simple edits do not need a fixed planning artifact, and models without image input cannot judge rendered pages. Mandatory renderer installation would turn an optional quality signal into a dependency unrelated to many requests.
+
+**Judge layout using package structure and font-name guesses.** Merged cells, different section widths, font substitution, and application layout rules prevent those observations from establishing rendered correctness. The checker reports facts and leaves visual judgments to actual images.
+
+**Share one undifferentiated Office skill.** Format-specific discovery avoids loading spreadsheet formula guidance for a Word edit or presentation instructions for a cell update. The deterministic checker remains shared because all three formats use the same package relationship rules.
+
+## Consequences
+
+The provider supplies reusable instructions without selecting or installing a deployment runtime. The checker is portable wherever the supported Python standard library works, but it cannot establish Office rendering fidelity, advanced feature preservation, or formula results. Loader and disposal tests cover resource relocation, activation failures, and absent rendering services. A recorded Session pins the Office catalog and loaded instruction body. Checker tests cover structural failures and JSON diagnostics; native payload smoke executes the copied checker with the bundled interpreter.

+ 31 - 0
.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: Bundled Office skills with structural verification
+
+Status: implemented
+
+[English](2026-09-15-bundled-office-skills.md) | 中文
+
+## 问题
+
+Office 任务需要针对文件格式的编辑指引和可靠的文件检查。要求用户安装解释器、包管理器或渲染命令行工具,会中断普通文档交付。结构启发式规则也可能在未观察到实际版式缺陷时,误拒有效的合并表格、多分节文档或中文文本。
+
+## 决策
+
+[Office 提供方](../../../../packages/skill/skill-office/README.zh.md)以内置优先级提供三个可独立发现的 skill(技能)。默认工作流使用 `load_workspace_dependencies` 及其 Python 可执行文件;用户和 AGENTS.md 明确指定的环境优先。可配置的绝对资源根目录让 Desktop 在应用归档之外暴露 Python 可读取的资源。注册时验证必需资源与 YAML 描述;加载后的指令不包含元数据。卸载时移除全部候选项。
+
+一个仅依赖标准库的检查器识别 Transitional 与 Strict OOXML 命名空间,验证 ZIP/XML 完整性与包内引用,报告各格式的结构,并只检查明确指定的文本或数量断言。损坏或加密的 ZIP 成员与其他无效文档一样生成 JSON 包失败报告。DOCX 表格摘要计算逻辑网格列数,包含合并单元格。分节几何信息仅报告,不以最终分节判断全文;字体文件名不能证明字形覆盖范围。XLSX 公式数量不代表已执行重算。文本断言跟随分节与脚注/尾注引用及工作表字符串索引,因此保留的页眉、未使用的脚注/尾注定义、批注、词库条目和未使用的字符串不能满足措辞要求。
+
+Desktop 独立于文档渲染挂载技能提供者和运行时查询。Word 使用 python-docx,PowerPoint 创建和编辑使用 python-pptx,Excel 使用 openpyxl 和 pandas。受管理产物和普通创建示例都不要求渲染引擎或单独的演示文稿创作库。
+
+缺少渲染服务时,Office skills 与内置运行时查询仍保持注册。视觉检查取决于当前模型支持图片且有可用渲染工具。否则,skill 完成结构与内容检查,并在交付时说明未验证的视觉范围。`present` 引用工作区中的当前源文件,不保存私有副本。
+
+## 考虑过的替代方案
+
+**每次交付都强制要求计划与本地渲染器。** 简单编辑不需要固定计划文件,不支持图片的模型也无法判断渲染页面。强制安装渲染器会将可选质量信号变成与许多请求无关的依赖。
+
+**通过包结构与字体名猜测判断版式。** 合并单元格、不同分节宽度、字体替换和应用排版规则,使这些观察不足以证明渲染正确。检查器报告事实,将视觉判断留给实际图像。
+
+**共用一个不区分格式的 Office skill。** 按格式发现可避免在 Word 编辑时加载公式指引,或在单元格更新时加载演示文稿指令。确定性检查器仍然共享,因为三种格式采用相同的包引用规则。
+
+## 影响
+
+提供方提供可复用指令,不选择或安装部署运行时。检查器可在受支持的 Python 标准库可用之处运行,但不能证明 Office 渲染保真度、高级特性保留或公式结果。Loader 和卸载测试覆盖资源重定位、激活失败与渲染服务缺失。录制 Session 固定 Office 目录与加载后的指令正文。检查器测试覆盖结构错误与 JSON 诊断;原生产物冒烟检查通过内置解释器执行复制后的检查器。

+ 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)保留现有责任方。组件检查覆盖默认值、确切选择、元数据保留和禁用控件;浏览器场景覆盖保存后的适配器能力、重新打开后的选择,独占一行的输入类型,以及发现、采纳和重新打开过程中传递的内置视觉元数据。

+ 6 - 0
.agents/notes/implemented/process/2026-09-16-author-approval-credit.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/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 的审批阈值、仅靠作者积分不能通过、分页,以及显示值舍入为两分但实际低于阈值的情况。

+ 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')
 })
 

+ 12 - 4
apps/cli/tests/desktop-host.e2e.ts

@@ -1,9 +1,9 @@
 /** Built Desktop Host lifecycle with Electron disconnecting before profile startup settles. */
 
 import { fork } from 'node:child_process'
-import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
-import { join } from 'node:path'
+import { dirname, join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { finished } from 'node:stream/promises'
 import { expect, it, onTestFinished } from 'vitest'
@@ -11,7 +11,15 @@ import { expect, it, onTestFinished } from 'vitest'
 it.each([false, true])('settles startup after parent IPC disconnect (boot failure: %s)', async (fail) => {
   const root = mkdtempSync(join(tmpdir(), 'desktop-disconnect-'))
   const modules = join(root, 'node_modules', '@deepseek-ai')
-  for (const name of ['dsh-app-boot', 'dsh', 'dsh-home-paths', 'dsh-tools']) mkdirSync(join(modules, name), { recursive: true })
+  const hostDirectory = fileURLToPath(new URL('../../desktop-host/', import.meta.url))
+  const manifest = JSON.parse(readFileSync(join(hostDirectory, 'package.json'), 'utf8')) as { dependencies: Record<string, string> }
+  const stubbed = new Set(['@deepseek-ai/dsh-app-boot', '@deepseek-ai/dsh', '@deepseek-ai/dsh-home-paths', '@deepseek-ai/dsh-tools'])
+  for (const name of Object.keys(manifest.dependencies)) {
+    const destination = join(root, 'node_modules', name)
+    mkdirSync(dirname(destination), { recursive: true })
+    if (stubbed.has(name)) mkdirSync(destination)
+    else symlinkSync(realpathSync(join(hostDirectory, 'node_modules', name)), destination, 'junction')
+  }
   for (const [name, source] of [
     ['dsh-home-paths', `export const resolveDshHome = () => ${JSON.stringify(root)}`],
     ['dsh-tools', 'export const defineTool = value => value'],
@@ -35,7 +43,7 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
     }
   `)
   const entry = join(root, 'index.js')
-  copyFileSync(fileURLToPath(new URL('../../desktop-host/lib/index.js', import.meta.url)), entry)
+  copyFileSync(join(hostDirectory, 'lib', 'index.js'), entry)
   const child = fork(entry, [root, root], { execArgv: [], stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
   let stderr = ''
   child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk })

+ 2 - 1
apps/desktop-host/package.json

@@ -16,6 +16,7 @@
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-home-paths": "workspace:^",
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
-    "@deepseek-ai/dsh-tools": "workspace:^"
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-skill-office": "workspace:^"
   }
 }

+ 2 - 2
apps/desktop-host/src/index.ts

@@ -6,7 +6,7 @@ import { runProfile } from '@deepseek-ai/dsh/profile-boot'
 import type {} from '@deepseek-ai/dsh-client-connection'
 import type {} from '@deepseek-ai/dsh-host-webserver'
 import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
-import * as workspaceDependencies from './workspace-dependencies.ts'
+import * as desktopOffice from './office.ts'
 
 async function main(): Promise<void> {
   const runtimeDir = process.argv[2] as string
@@ -32,7 +32,7 @@ async function main(): Promise<void> {
   })
   process.once('disconnect', () => { void stop() })
   const { ctx } = await application
-  await ctx.plugin(workspaceDependencies, {
+  await ctx.plugin(desktopOffice, {
     source: process.argv[4] ?? join(runtimeDir, '..', 'runtime', 'primary-runtime'),
     root: join(resolveDshHome(), 'dsh-runtimes', 'dsh-primary-runtime'),
   })

+ 26 - 0
apps/desktop-host/src/office.ts

@@ -0,0 +1,26 @@
+/** Desktop Office skills and bundled authoring dependencies. */
+
+import { dirname, join } from 'node:path'
+import type { Context } from '@deepseek-ai/cordis'
+import * as officeSkills from '@deepseek-ai/dsh-skill-office'
+import * as workspaceDependencies from './workspace-dependencies.ts'
+
+/** Loader identity for the application-owned Office composition. */
+export const name = 'desktop-office'
+/** Application-selected bundled payload and installation directories. */
+export interface Config {
+  /** Bundled payload directory. Missing sibling `office-skills` resources fail Host startup. */
+  readonly source: string
+  /** Harness-home directory where workspace dependencies are installed. */
+  readonly root: string
+}
+
+/**
+ * Enable offline Office authoring and structural checks in the Desktop profile.
+ * @param ctx - Profile scope; child plugins declare their own service requirements.
+ * @param config - Bundled payload source and Harness-home installation root.
+ */
+export async function apply(ctx: Context, config: Config): Promise<void> {
+  await ctx.plugin(workspaceDependencies, config)
+  await ctx.plugin(officeSkills, { assetRoot: join(dirname(config.source), 'office-skills') })
+}

+ 58 - 0
apps/desktop-host/tests/office.spec.ts

@@ -0,0 +1,58 @@
+import { cp, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Include from '@deepseek-ai/cordis-plugin-include'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import SkillRegistry from '@deepseek-ai/dsh-skill'
+import ToolRuntime from '@deepseek-ai/dsh-tools'
+import { expect, it } from 'vitest'
+import * as desktopOffice from '../src/office.ts'
+
+it('loads Desktop Office skills without a document renderer and removes them on disposal', async () => {
+  const root = await mkdtemp(join(tmpdir(), 'desktop-office-'))
+  const ctx = new Context()
+  try {
+    const assets = join(root, 'runtime', 'office-skills')
+    await cp(new URL('../../../packages/skill/skill-office/assets/', import.meta.url), assets, { recursive: true })
+    ctx.baseUrl = pathToFileURL(root).href + '/'
+    await ctx.plugin(Loader)
+    ctx.loader.builtins.include = Include
+    expect('default' in desktopOffice).toBe(false)
+    expect(ctx.loader.unwrapExports(desktopOffice)).toBe(desktopOffice)
+    const modules = new Map<string, unknown>([
+      ['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime],
+      ['skills', SkillRegistry], ['office', desktopOffice],
+    ])
+    ctx.loader.internal = {
+      version: 'v2',
+      async import(specifier: string) {
+        if (!modules.has(specifier)) throw new Error(`unexpected plugin ${specifier}`)
+        return modules.get(specifier)
+      },
+    } as unknown as NonNullable<typeof ctx.loader.internal>
+    const config = join(root, 'cordis.yml')
+    await writeFile(config, [
+      '- name: agents', '- name: systemPrompt', '- name: tools', '- name: skills', '- name: office',
+      '  config:', `    source: ${JSON.stringify(join(root, 'runtime', 'primary-runtime'))}`,
+      `    root: ${JSON.stringify(join(root, 'installed'))}`, '',
+    ].join('\n'))
+    await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(config).href } })
+    await ctx.loader.await()
+    for (const entry of ctx.loader.entries()) await entry.fiber?.await()
+    expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['office-docx', 'office-pptx', 'office-xlsx'])
+    expect((await ctx.skills.get('office-pptx'))?.resourceBase).toEqual({ kind: 'directory', path: join(assets, 'office-pptx') })
+    expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['load_workspace_dependencies'])
+    const entry = [...ctx.loader.entries()].find(entry => entry.options.name === 'office')
+    expect(entry).toBeDefined()
+    await entry?.fiber?.dispose()
+    expect(await ctx.skills.list()).toEqual([])
+    expect(ctx.tools.schemas()).toEqual([])
+  } finally {
+    await ctx.fiber.dispose()
+    await rm(root, { recursive: true, force: true })
+  }
+})

+ 1 - 0
apps/desktop-host/tsconfig.json

@@ -8,6 +8,7 @@
     "src"
   ],
   "references": [
+    { "path": "../../packages/skill/skill-office" },
     { "path": "../../packages/util/home-paths" },
     { "path": "../../packages/core/tools" },
     {

+ 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: bf4f22be5aafa1323433d2eb396e9a5417dc3511
-README.zh.md: 058e6b223a4522c993243b463bc7cbd5796529aa
+README.md: d19304ae123aefe57af649c5d6a02c4db867314e
+README.zh.md: 3d5b84c23701c4915fb1983fd6363c1ce2dc3059

+ 2 - 0
apps/desktop/README.md

@@ -18,6 +18,8 @@ The current Windows Python payload contains unsigned native extensions. Smart Ap
 
 Desktop carries independent Python, Node.js and pnpm distributions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml and XlsxWriter with their complete dependencies. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths plus `pythonDistributions`, the bundled distribution names and versions. The version report excludes user-installed additions. Office tasks prefer these libraries unless user or workspace instructions select another environment. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory.
 
+Desktop registers `office-docx`, `office-pptx`, and `office-xlsx` by default. The skills use the bundled Python libraries for creation and focused edits, then reopen the files and run a shared structural checker before delivery. PowerPoint creation and editing use python-pptx. Skill resources are copied to `runtime/office-skills` outside ASAR so Python can read the checker. An available `render_document` tool can add visual inspection; its absence does not prevent authoring or delivery. See the [Office skill package](../../packages/skill/skill-office/README.md) for checks and limitations.
+
 The payload follows the Desktop release. `runtime.json` records the Desktop version, target, component and Python distribution versions, and a digest of the selected target’s locked payload inputs and assembly format. Distribution names use PEP 503 normalization; duplicate normalized names and conflicting numpy/pandas component and distribution versions reject the manifest. Matching installations are reused; a dependency or archive change replaces the directory after a complete staged copy even when the Desktop version stays unchanged. Older manifests without a digest are replaced on their next installation. User-added Python packages remain only while the payload identity matches. A failed directory replacement retains the previous installation; Windows may refuse replacement while an interpreter is still running.
 
 The private Desktop `runtime/bin` directory is added only to package-installation processes, not the Host PATH inherited by PTC and agent shells. This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices.

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

@@ -18,6 +18,8 @@ macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含
 
 Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml、XlsxWriter 及其完整依赖。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径,以及记录内置分发包名称与版本的 `pythonDistributions`。版本报告不包含用户自行安装的包。Office 任务默认使用这些库,用户或工作区指令指定其他环境时遵循其要求。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。
 
+Desktop 默认注册 `office-docx`、`office-pptx` 和 `office-xlsx`。这些技能使用内置 Python 库创建文件和进行定点编辑,随后重新打开文件,并在交付前运行共享结构检查器。PowerPoint 的创建和编辑使用 python-pptx。技能资源复制到 ASAR 外的 `runtime/office-skills`,让 Python 可以读取检查器。可用的 `render_document` 工具可以补充视觉检查;缺少该工具不妨碍创作或交付。检查范围与限制见 [Office 技能包](../../packages/skill/skill-office/README.zh.md)。
+
 该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台、组件与 Python 分发包版本,以及所选目标的锁定产物输入与组装格式的摘要。分发包名称按 PEP 503 归一化;名称归一化后重复,或 numpy/pandas 的组件版本与分发包版本冲突时,清单会被拒绝。匹配的安装会被复用;依赖或压缩包变化后,即使 Desktop 版本不变,也会在完整暂存副本完成后替换目录。不含摘要的旧清单会在下次安装时被替换。用户自行添加的 Python 包仅在产物身份一致时保留。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。
 
 Desktop 私有的 `runtime/bin` 目录仅添加到包安装进程,不进入 PTC 和 agent shell 从 Host 继承的 PATH。该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。

+ 17 - 2
apps/desktop/scripts/prepare-primary-runtime.ts

@@ -6,7 +6,7 @@ import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } f
 import { cp } from 'node:fs/promises'
 import { createRequire } from 'node:module'
 import { tmpdir } from 'node:os'
-import { dirname, join } from 'node:path'
+import { dirname, join, resolve } from 'node:path'
 import extractZip from 'extract-zip'
 import { x as extractTar } from 'tar'
 import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../desktop-host/src/primary-runtime.ts'
@@ -75,6 +75,17 @@ export async function unpackPrimaryRuntimeWheel(archive: string, destination: st
   })
 }
 
+/**
+ * Copy the skill package's complete asset tree to ordinary filesystem resources.
+ * @param source - The package's assets directory.
+ * @param destination - Desktop runtime resource directory outside ASAR.
+ * @returns Resolves after replacing the external assets with the complete package tree.
+ */
+export async function prepareOfficeSkillAssets(source: string, destination: string): Promise<void> {
+  rmSync(destination, { recursive: true, force: true })
+  await cp(source, destination, { recursive: true, dereference: true })
+}
+
 /**
  * Materialize the selected Desktop target's primary runtime in its build resources.
  * @returns Resolves after dependency installation and native-target execution checks.
@@ -131,6 +142,9 @@ export async function preparePrimaryRuntime(): Promise<void> {
   } finally {
     rmSync(staging, { recursive: true, force: true })
   }
+  const hostRequire = createRequire(resolve(import.meta.dirname, '..', '..', 'desktop-host', 'package.json'))
+  await prepareOfficeSkillAssets(join(dirname(hostRequire.resolve('@deepseek-ai/dsh-skill-office/package.json')), 'assets'),
+    join(paths.runtime, 'office-skills'))
   smokePrimaryRuntime(join(paths.runtime, 'primary-runtime'))
 }
 
@@ -144,7 +158,8 @@ export function smokePrimaryRuntime(root: string): void {
   if (manifest.pythonPackages === undefined) throw new Error('primary runtime: missing Python distribution versions; prepare the payload before running its smoke checks.')
   const entries = workspaceDependencyPaths(root, manifest)
   const options = { stdio: 'inherit', timeout: 120_000 } as const
-  execFileSync(entries.python, ['-I', '-B', join(import.meta.dirname, 'smoke-primary-runtime.py'), JSON.stringify(manifest.pythonPackages), manifest.components.python], options)
+  execFileSync(entries.python, ['-I', '-B', join(import.meta.dirname, 'smoke-primary-runtime.py'), JSON.stringify(manifest.pythonPackages),
+    manifest.components.python, join(dirname(root), 'office-skills', 'scripts', 'check_office.py')], options)
   execFileSync(entries.python, ['-I', '-B', '-m', 'pip', 'check'], options)
   execFileSync(entries.node, ['-e', `if (process.versions.node !== ${JSON.stringify(manifest.components.node)}) process.exit(1)`], options)
   execFileSync(entries.node, [entries.pnpm, '--version'], options)

+ 9 - 0
apps/desktop/scripts/smoke-primary-runtime.py

@@ -4,6 +4,7 @@ import importlib.metadata
 import json
 from pathlib import Path
 import re
+import subprocess
 import sys
 import tempfile
 
@@ -83,6 +84,14 @@ def main():
         finally:
             reopened_workbook.close()
         assert pandas.read_excel(root / "workbook.xlsx")["Value"].iloc[0] == 42
+        for file, arguments in [
+            ("document.docx", ["--contains", "Office 文档"]),
+            ("presentation.pptx", ["--contains", "Office 演示", "--count", "1"]),
+            ("workbook.xlsx", ["--contains", "Value", "--count", "1"]),
+        ]:
+            checked = subprocess.run([sys.executable, "-I", "-B", sys.argv[3], str(root / file), *arguments],
+                                     check=False, capture_output=True, text=True, timeout=30)
+            assert checked.returncode == 0 and json.loads(checked.stdout)["verdict"] == "pass", checked.stdout + checked.stderr
     print("Office runtime versions and document round trips passed.")
 
 

+ 2 - 1
apps/desktop/src/host-process.ts

@@ -72,7 +72,8 @@ export class DesktopHostProcess {
    * @param inspectPort - Optional loopback inspector port for workspace development.
    * @param environment - Environment inherited by the Host and its plugin subprocesses.
    * @param onFailure - Receives the first unexpected child failure, including after readiness.
-   * @param primaryRuntime - Optional payload location for bundled script dependencies.
+   * @param primaryRuntime - Optional bundled dependency payload; when supplied, missing sibling
+   *   `office-skills` resources fail Host startup.
    * @param profileResolution - Package resolution mode for the application-owned profile.
    */
   constructor(

+ 24 - 2
apps/desktop/tests/primary-runtime-preparation.spec.ts

@@ -1,10 +1,10 @@
 import { createHash } from 'node:crypto'
-import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { basename, join } from 'node:path'
 import { zipSync } from 'fflate'
 import { expect, it } from 'vitest'
-import { downloadPrimaryRuntimeAsset, primaryRuntimePayloadDigest, smokePrimaryRuntime, unpackPrimaryRuntimeWheel } from '../scripts/prepare-primary-runtime.ts'
+import { downloadPrimaryRuntimeAsset, prepareOfficeSkillAssets, primaryRuntimePayloadDigest, smokePrimaryRuntime, unpackPrimaryRuntimeWheel } from '../scripts/prepare-primary-runtime.ts'
 import lock from '../scripts/primary-runtime-lock.json' with { type: 'json' }
 
 const libraryWheel = Buffer.from('UEsDBAoAAAAAAASeLl0sYMPjDAAAAAwAAAAJAAAAc2FtcGxlLnB5c2FtcGxlID0gNDIKUEsBAh4DCgAAAAAABJ4uXSxgw+MMAAAADAAAAAkAAAAAAAAAAQAAAKSBAAAAAHNhbXBsZS5weVBLBQYAAAAAAQABADcAAAAzAAAAAAA=', 'base64')
@@ -107,3 +107,25 @@ it('rejects library files requiring an unsupported installation scheme', async (
     await rm(root, { recursive: true, force: true })
   }
 })
+
+it('copies complete Office resources outside the application archive and removes obsolete assets', async () => {
+  const root = await mkdtemp(join(tmpdir(), 'desktop-office-assets-'))
+  try {
+    const source = join(root, 'package', 'assets')
+    const destination = join(root, 'Contents', 'Resources', 'runtime', 'office-skills')
+    await mkdir(join(source, 'scripts'), { recursive: true })
+    await writeFile(join(source, 'scripts', 'check_office.py'), 'print("checker")\n')
+    for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) {
+      await mkdir(join(source, name))
+      await writeFile(join(source, name, 'SKILL.md'), `# ${name}\n`)
+    }
+    await prepareOfficeSkillAssets(source, destination)
+    await writeFile(join(destination, 'obsolete.py'), 'old helper')
+    await prepareOfficeSkillAssets(source, destination)
+    for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) {
+      expect(await readFile(join(destination, name, 'SKILL.md'), 'utf8')).toBe(`# ${name}\n`)
+    }
+    expect(await readFile(join(destination, 'scripts', 'check_office.py'), 'utf8')).toBe('print("checker")\n')
+    await expect(readFile(join(destination, 'obsolete.py'))).rejects.toMatchObject({ code: 'ENOENT' })
+  } finally { await rm(root, { recursive: true, force: true }) }
+})

+ 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')

+ 55 - 5
apps/web/tests/permission-policy-context.e2e.ts

@@ -3,7 +3,8 @@
 // the real provider, while replay keeps the same provider-authored behavior
 // keyless. Assertions read the exact durable header, runtime-context messages,
 // and tool calls, so assistant prose alone cannot satisfy the scenario.
-import { readFile } from 'node:fs/promises'
+import { mkdtemp, readFile, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import type { Browser, Page } from 'playwright'
@@ -11,6 +12,8 @@ import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { canonicalPath } from '@deepseek-ai/dsh-sandbox'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types'
+import type {} from '@deepseek-ai/dsh-api-terminal-controller'
 import {
   assertFinalWorkspaceSnapshot, assertFixtureInventory, fixtureUserPrompts, launchWebScaffold, recordFixture,
   watchConsole, webSnapshotMode, type WebScaffold,
@@ -65,10 +68,18 @@ describe('web e2e: current sandbox policy reaches the model before tools', () =>
   let tripwire: ReturnType<typeof watchConsole>
   let disposeApproval: (() => void) | undefined
   let sessionWorkspace: string | undefined
+  let outsideWorkspace: string | undefined
+  let terminalId: WebTerminalId | undefined
   const sessionEvents: SessionEvent[] = []
 
   beforeAll(async () => {
-    scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, compareReplaySession: true })
+    scaffold = await launchWebScaffold({
+      ...MODE === 'record' ? {} : { replayFixture: FIXTURE, compareReplaySession: true },
+      ...process.platform === 'win32' ? {} : {
+        extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)),
+      },
+    })
+    outsideWorkspace = await mkdtemp(join(tmpdir(), 'dsh-user-terminal-'))
     disposeApproval = scaffold.ctx.on('approval/request', () => Promise.resolve('allowed-once'), { prepend: true })
     scaffold.ctx.on('session/event', (session, event: SessionEvent) => {
       sessionWorkspace = session.header.cwd
@@ -83,11 +94,48 @@ describe('web e2e: current sandbox policy reaches the model before tools', () =>
   }, 120_000)
 
   afterAll(async () => {
-    await browser?.close()
-    disposeApproval?.()
-    await scaffold?.close()
+    try { await browser?.close() } finally {
+      disposeApproval?.()
+      try { await scaffold?.close() } finally {
+        if (outsideWorkspace !== undefined) await rm(outsideWorkspace, { recursive: true, force: true })
+      }
+    }
   })
 
+  async function verifyUserTerminal(preset: string): Promise<void> {
+    // The pinned interactive Bash profile is POSIX-only; Windows still replays every Agent policy assertion.
+    if (process.platform === 'win32') return
+    if (terminalId === undefined) {
+      const expand = page.locator('[data-sidebar-right-expand]')
+      if (await expand.isVisible()) await expand.click()
+      await page.locator('[data-sidebar-right-guide-entry="terminal"]').getByRole('button', { name: /^New terminal/u }).click()
+      await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('bash-')
+    }
+    const agent = scaffold.ctx.agents.list()[0]
+    if (agent === undefined || sessionWorkspace === undefined || outsideWorkspace === undefined) throw new Error('Terminal test has no Session workspace')
+    const terminals = scaffold.ctx.terminalController.list(agent.id)
+    expect(terminals).toHaveLength(1)
+    terminalId ??= terminals[0]!.id
+    expect(terminals[0]).toMatchObject({ id: terminalId, state: 'running', cwd: sessionWorkspace })
+    const outsideFile = join(outsideWorkspace, 'terminal-access.txt')
+    const quotedOutside = `'${outsideFile.replaceAll("'", "'\\''")}'`
+    const beforeInput = sessionEvents.length
+    await page.locator('.xterm-helper-textarea:visible').click()
+    await page.keyboard.insertText(`printf '%s' '${preset}' > terminal-access.txt; printf '%s' '${preset}' > ${quotedOutside}`)
+    await page.keyboard.press('Enter')
+    await expect.poll(() => readFile(join(sessionWorkspace!, 'terminal-access.txt'), 'utf8')).toBe(preset)
+    await expect.poll(() => readFile(outsideFile, 'utf8')).toBe(preset)
+    expect(sessionEvents).toHaveLength(beforeInput)
+    await page.keyboard.insertText('rm terminal-access.txt')
+    await page.keyboard.press('Enter')
+    await expect.poll(async () => {
+      try { await readFile(join(sessionWorkspace!, 'terminal-access.txt')); return false } catch (error) {
+        if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+        return true
+      }
+    }).toBe(true)
+  }
+
   it('switches read-only, danger-full-access, and workspace-write through the real GUI command path', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-permission-policy-context'))
     if (MODE !== 'record') {
@@ -107,11 +155,13 @@ describe('web e2e: current sandbox policy reaches the model before tools', () =>
       await input.press('Enter')
       sessionId = await settled
       await input.waitFor({ timeout: 10_000 })
+      await verifyUserTerminal(preset)
     }
 
     await writeComposerDraft(page, input, '/permission read-only')
     await input.press('Enter')
     await page.getByRole('button', { name: 'Access mode, current: Read Only' }).waitFor({ timeout: 10_000 })
+    await verifyUserTerminal('read-only')
     const settled = scaffold.whenTurnSettled()
     await writeComposerDraft(page, input, PROMPTS[3])
     await input.press('Enter')

+ 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/capability-seams.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/capability-seams.md
-capability-seams.md: 9dad96da1399441f96355a13d3848fcf01a5c177
-capability-seams.zh.md: 470694f746e500330ee3acf56ed1fa912c9c0088
+capability-seams.md: a6a90b1fd72278a3027385b3b4cd9a3c78cbb903
+capability-seams.zh.md: d906ac7d7a9617a4044f7b9960eca947cc1ce8a9

+ 3 - 1
docs/capability-seams.md

@@ -146,6 +146,7 @@ flowchart LR
   svc_skills["ctx.skills<br/>Skill provider registry"]
   pkg_skill_badge["skill-badge"]
   pkg_skill_filesystem["skill-filesystem"]
+  pkg_skill_office["skill-office"]
   svc_agents["ctx.agents<br/>Agent service"]
   pkg_acp["acp"]
   pkg_agent_default_model["agent-default-model"]
@@ -346,6 +347,7 @@ flowchart LR
   pkg_skill --> svc_skills
   pkg_skill_badge --> svc_skills
   pkg_skill_filesystem --> svc_skills
+  pkg_skill_office --> svc_skills
   pkg_spill --> svc_spillStore
   pkg_spill_local --> svc_spillStore
   pkg_ssh --> svc_ssh
@@ -574,7 +576,7 @@ flowchart LR
 | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. |
 | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and the Session controller serves baselines and pushes changed values. |
 | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. |
-| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
+| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
 | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
 | `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner. |
 | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |

+ 3 - 1
docs/capability-seams.zh.md

@@ -148,6 +148,7 @@ flowchart LR
   svc_skills["ctx.skills<br/>Skill provider registry"]
   pkg_skill_badge["skill-badge"]
   pkg_skill_filesystem["skill-filesystem"]
+  pkg_skill_office["skill-office"]
   svc_agents["ctx.agents<br/>Agent service"]
   pkg_acp["acp"]
   pkg_agent_default_model["agent-default-model"]
@@ -348,6 +349,7 @@ flowchart LR
   pkg_skill --> svc_skills
   pkg_skill_badge --> svc_skills
   pkg_skill_filesystem --> svc_skills
+  pkg_skill_office --> svc_skills
   pkg_spill --> svc_spillStore
   pkg_spill_local --> svc_spillStore
   pkg_ssh --> svc_ssh
@@ -576,7 +578,7 @@ flowchart LR
 | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | 插件注册直接面向人的命令,而不会把调用发送给模型。 |
 | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | 各领域注册由状态驱动的折叠单元;主动驱动过程维护每个会话的水位状态,Session controller 提供 baseline 并推送发生变化的值。 |
 | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 |
-| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 |
+| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 |
 | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 |
 | `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | 通过 settings 分层默认 `ModelSelection`,让直接入口与 Host 支撑的 Agent 入口共享同一个状态所有者。 |
 | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 |

+ 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: 709456fdd72dea966a3f50f279776eb04e9578af
-config-catalog.zh.md: e09f0ec0e7ea1efede878d02862f3eb90d48c358
+config-catalog.md: f89ea6b4e8bb262c678f532e57fa67bfae025f1c
+config-catalog.zh.md: 2aca9c78d7fb9cb9f9c23ed1b367342afbcad3e6

+ 18 - 2
docs/config-catalog.md

@@ -233,7 +233,7 @@ Source: [`packages/api/settings-controller/src/index.ts:36`](../packages/api/set
 
 ## `@deepseek-ai/dsh-api-terminal-controller`
 
-Requires: `subprocess` · `sandboxPolicy` · `sessionProjections` · `typert`
+Requires: `subprocess` · `sandboxPolicy` · `typert`
 
 ```ts config-catalog
 /** Deployment limits and an optional shell profile. */
@@ -272,7 +272,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/api/terminal-controller/src/index.ts:28`](../packages/api/terminal-controller/src/index.ts)
+Source: [`packages/api/terminal-controller/src/index.ts:26`](../packages/api/terminal-controller/src/index.ts)
 
 <a id="deepseek-aidsh-api-workspace-files"></a>
 
@@ -2402,6 +2402,22 @@ export interface Config {
 
 Source: [`packages/skill/skill-filesystem/src/index.ts:49`](../packages/skill/skill-filesystem/src/index.ts)
 
+<a id="deepseek-aidsh-skill-office"></a>
+
+## `@deepseek-ai/dsh-skill-office`
+
+Requires: `skills`
+
+```ts config-catalog
+/** Office skill resource location. */
+export interface Config {
+  /** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */
+  assetRoot?: string
+}
+```
+
+Source: [`packages/skill/skill-office/src/index.ts:15`](../packages/skill/skill-office/src/index.ts)
+
 <a id="deepseek-aidsh-spill-local"></a>
 
 ## `@deepseek-ai/dsh-spill-local`

+ 18 - 2
docs/config-catalog.zh.md

@@ -235,7 +235,7 @@ export interface Config {
 
 ## `@deepseek-ai/dsh-api-terminal-controller`
 
-Requires: `subprocess` · `sandboxPolicy` · `sessionProjections` · `typert`
+Requires: `subprocess` · `sandboxPolicy` · `typert`
 
 ```ts config-catalog
 /** Deployment limits and an optional shell profile. */
@@ -274,7 +274,7 @@ export interface Config {
 }
 ```
 
-来源: [`packages/api/terminal-controller/src/index.ts:28`](../packages/api/terminal-controller/src/index.ts)
+来源: [`packages/api/terminal-controller/src/index.ts:26`](../packages/api/terminal-controller/src/index.ts)
 
 <a id="deepseek-aidsh-api-workspace-files"></a>
 
@@ -2404,6 +2404,22 @@ export interface Config {
 
 来源:[`packages/skill/skill-filesystem/src/index.ts:49`](../packages/skill/skill-filesystem/src/index.ts)
 
+<a id="deepseek-aidsh-skill-office"></a>
+
+## `@deepseek-ai/dsh-skill-office`
+
+依赖:`skills`
+
+```ts config-catalog
+/** Office skill resource location. */
+export interface Config {
+  /** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */
+  assetRoot?: string
+}
+```
+
+来源:[`packages/skill/skill-office/src/index.ts:15`](../packages/skill/skill-office/src/index.ts)
+
 <a id="deepseek-aidsh-spill-local"></a>
 
 ## `@deepseek-ai/dsh-spill-local`

+ 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: 74cc0ca4bcb72e61a97db1ba1d0cf508da57bd11
-event-producer-consumer.zh.md: e14aa398ee0a7d8c18adad11a8af1d1fadcfcfac
+event-producer-consumer.md: 85b4fceb59447bf9ba27a3f627f50a123e2b4e02
+event-producer-consumer.zh.md: 1605f32ae101b9ff8c536d39a5c8c43a8a18cb7c

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

@@ -86,7 +86,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 
 | Event string | Dispatchers | Listeners |
 | --- | --- | --- |
-| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), `terminal-controller`, [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), `ui-renderer`, [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
+| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), `ui-renderer`, [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
 | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
 | `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`web`](../packages/web/web) |

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

@@ -88,7 +88,7 @@
 
 | Event string | Dispatchers | Listeners |
 | --- | --- | --- |
-| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), `terminal-controller`, [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), `ui-renderer`, [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
+| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), `ui-renderer`, [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
 | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
 | `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`web`](../packages/web/web) |

+ 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: 51993f85ff0aeb893c1a8c83fa737e2fa0301c4c
-module-graph.zh.md: 6347832cf2383b782db2bf86efb7596aa219a995
+module-graph.md: b9269b0966999fe28b9a61087480516983653989
+module-graph.zh.md: 84254d4025fad25db8bf4b45b11ab443d68d4ce5

+ 3 - 0
docs/module-graph.md

@@ -63,6 +63,7 @@ flowchart TD
     pkg_skill["skill"]
     pkg_skill_badge["skill-badge"]
     pkg_skill_filesystem["skill-filesystem"]
+    pkg_skill_office["skill-office"]
     pkg_tool_skill["tool-skill"]
   end
   subgraph group_subagent["packages/subagent"]
@@ -459,6 +460,7 @@ flowchart TD
   pkg_subprocess_local --> pkg_subprocess
   pkg_subprocess_local --> pkg_timeout
   pkg_skill_badge --> pkg_skill
+  pkg_skill_office --> pkg_skill
   pkg_spill --> pkg_brand
   pkg_spill --> pkg_llm
   pkg_spill --> pkg_session
@@ -1425,6 +1427,7 @@ flowchart TD
 | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`subprocess`](../packages/subprocess/subprocess) |
 | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) |
+| [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) |
 | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) |
 | [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) |

+ 3 - 0
docs/module-graph.zh.md

@@ -65,6 +65,7 @@ flowchart TD
     pkg_skill["skill"]
     pkg_skill_badge["skill-badge"]
     pkg_skill_filesystem["skill-filesystem"]
+    pkg_skill_office["skill-office"]
     pkg_tool_skill["tool-skill"]
   end
   subgraph group_subagent["packages/subagent"]
@@ -461,6 +462,7 @@ flowchart TD
   pkg_subprocess_local --> pkg_subprocess
   pkg_subprocess_local --> pkg_timeout
   pkg_skill_badge --> pkg_skill
+  pkg_skill_office --> pkg_skill
   pkg_spill --> pkg_brand
   pkg_spill --> pkg_llm
   pkg_spill --> pkg_session
@@ -1427,6 +1429,7 @@ flowchart TD
 | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`subprocess`](../packages/subprocess/subprocess) |
 | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) |
+| [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) |
 | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) |
 | [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) |

+ 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: be8b3753c04b264c1eef8640594577c9edf6db88
-persistence-catalog.zh.md: 07b8cea48edb7aa9a2f6c7e5186b2bd374607537
+persistence-catalog.md: 637dcfcf48d2699b0072d087c7a765fe900a7684
+persistence-catalog.zh.md: e8af53ae630ac7b061e9d158eef8f26d2a5fdde4

+ 14 - 14
docs/persistence-catalog.md

@@ -1802,9 +1802,9 @@ SHA-256: `0ddab4577db37a3e0d9e3a91761735c8ef6f5629d526ab8ecaeeea19c097c8f8`
 | `kind` | required | `"plugin"` |
 | `plugin` | required | `string` |
 
-<a id="persistence-type-eventagentinboxspliceddatainserted0source10"></a>
+<a id="persistence-type-eventagentinboxspliceddatainserted0source11"></a>
 
-### `event:agent/inbox/spliced.data.inserted[0].source[10]`
+### `event:agent/inbox/spliced.data.inserted[0].source[11]`
 
 SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
 
@@ -2184,7 +2184,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 |
 |---|---|---|
@@ -2198,7 +2198,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 |
 |---|---|---|
@@ -2212,7 +2212,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 |
 |---|---|---|
@@ -2226,7 +2226,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 |
 |---|---|---|
@@ -2242,7 +2242,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 |
 |---|---|---|
@@ -2256,7 +2256,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 |
 |---|---|---|
@@ -2269,7 +2269,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 |
 |---|---|---|
@@ -4237,7 +4237,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"`
 
@@ -5172,7 +5172,7 @@ One of:
 
 - [`packages/context/agent-instructions/src/state.ts#AgentInstructionSource`](#persistence-type-packagescontextagent-instructionssrcstatetsagentinstructionsource)
 - [`packages/llm/llm/src/message.ts#ToolMessageSource`](#persistence-type-packagesllmllmsrcmessagetstoolmessagesource)
-- [`event:agent/inbox/spliced.data.inserted[0].source[10]`](#persistence-type-eventagentinboxspliceddatainserted0source10)
+- [`event:agent/inbox/spliced.data.inserted[0].source[11]`](#persistence-type-eventagentinboxspliceddatainserted0source11)
 - [`event:agent/inbox/spliced.data.inserted[0].source[19]`](#persistence-type-eventagentinboxspliceddatainserted0source19)
 - [`packages/skill/tool-skill/src/index.ts#SkillCatalogSource`](#persistence-type-packagesskilltool-skillsrcindextsskillcatalogsource)
 - [`packages/skill/skill/src/index.ts#SkillInvocationSource`](#persistence-type-packagesskillskillsrcindextsskillinvocationsource)
@@ -5377,7 +5377,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 |
 |---|---|---|
@@ -5390,7 +5390,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:
 
@@ -5455,7 +5455,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 |
 |---|---|---|

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

@@ -1804,9 +1804,9 @@ SHA-256: `0ddab4577db37a3e0d9e3a91761735c8ef6f5629d526ab8ecaeeea19c097c8f8`
 | `kind` | 必需 | `"plugin"` |
 | `plugin` | 必需 | `string` |
 
-<a id="persistence-type-eventagentinboxspliceddatainserted0source10"></a>
+<a id="persistence-type-eventagentinboxspliceddatainserted0source11"></a>
 
-### `event:agent/inbox/spliced.data.inserted[0].source[10]`
+### `event:agent/inbox/spliced.data.inserted[0].source[11]`
 
 SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
 
@@ -2186,7 +2186,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)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -2200,7 +2200,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)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -2214,7 +2214,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)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -2228,7 +2228,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)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -2244,7 +2244,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)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -2258,7 +2258,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)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -2271,7 +2271,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)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -4239,7 +4239,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"`
 
@@ -5174,7 +5174,7 @@ SHA-256: `4c965666d5b42116945bc356087cd5e1fd549e6dece560785ed60511059a8283`
 
 - [`packages/context/agent-instructions/src/state.ts#AgentInstructionSource`](#persistence-type-packagescontextagent-instructionssrcstatetsagentinstructionsource)
 - [`packages/llm/llm/src/message.ts#ToolMessageSource`](#persistence-type-packagesllmllmsrcmessagetstoolmessagesource)
-- [`event:agent/inbox/spliced.data.inserted[0].source[10]`](#persistence-type-eventagentinboxspliceddatainserted0source10)
+- [`event:agent/inbox/spliced.data.inserted[0].source[11]`](#persistence-type-eventagentinboxspliceddatainserted0source11)
 - [`event:agent/inbox/spliced.data.inserted[0].source[19]`](#persistence-type-eventagentinboxspliceddatainserted0source19)
 - [`packages/skill/tool-skill/src/index.ts#SkillCatalogSource`](#persistence-type-packagesskilltool-skillsrcindextsskillcatalogsource)
 - [`packages/skill/skill/src/index.ts#SkillInvocationSource`](#persistence-type-packagesskillskillsrcindextsskillinvocationsource)
@@ -5379,7 +5379,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)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|
@@ -5392,7 +5392,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)
 
 以下类型之一:
 
@@ -5457,7 +5457,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)
 
 | 属性 | 存在性 | 类型 |
 |---|---|---|

+ 3 - 3
docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.schema.json

@@ -794,7 +794,7 @@
         "event:deliverables/presented.data"
       ],
       "sources": [
-        "packages/fs/tool-present/src/types.ts"
+        "packages/deliverables/tool-present/src/types.ts"
       ]
     },
     {
@@ -2024,10 +2024,10 @@
         ]
       },
       "names": [
-        "packages/fs/tool-present/src/types.ts#PresentedFile"
+        "packages/deliverables/tool-present/src/types.ts#PresentedFile"
       ],
       "sources": [
-        "packages/fs/tool-present/src/types.ts"
+        "packages/deliverables/tool-present/src/types.ts"
       ]
     },
     {

+ 12 - 12
docs/persistence-schema.json

@@ -14599,7 +14599,7 @@
         "packages/llm/llm/src/types.ts#StreamChunk"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:424"
+        "packages/llm/llm/src/types.ts:426"
       ]
     },
     {
@@ -14859,7 +14859,7 @@
         "packages/llm/llm/src/types.ts#ReplayEnvelope"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:402"
+        "packages/llm/llm/src/types.ts:404"
       ]
     },
     {
@@ -23601,7 +23601,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"
       ]
     },
     {
@@ -36826,7 +36826,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"
       ]
     },
     {
@@ -38044,7 +38044,7 @@
         ]
       },
       "names": [
-        "event:agent/inbox/spliced.data.inserted[0].source[10]"
+        "event:agent/inbox/spliced.data.inserted[0].source[11]"
       ],
       "sources": [
         "packages/api/session-controller/src/types.ts:380"
@@ -40722,7 +40722,7 @@
         "packages/llm/llm/src/types.ts#ToolSchema"
       ],
       "sources": [
-        "packages/llm/llm/src/types.ts:445"
+        "packages/llm/llm/src/types.ts:447"
       ]
     },
     {
@@ -42299,7 +42299,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"
       ]
     },
     {
@@ -43988,7 +43988,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"
       ]
     },
     {
@@ -44363,7 +44363,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"
       ]
     },
     {
@@ -46741,7 +46741,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"
       ]
     },
     {
@@ -59874,7 +59874,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"
       ]
     },
     {
@@ -59955,7 +59955,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/subsystems/skills.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/skills.md
-skills.md: 84165578d37c0d947f20435d9605bb685a5b673b
-skills.zh.md: 02fd41d5bc3a93ea4aa6de2a9996bc91bd648859
+skills.md: 7e45418b8b63955619b1d964e0cfc40ee7264632
+skills.zh.md: ead07c09fe7174d960a36173d842c652fa544c07

+ 2 - 2
docs/subsystems/skills.md

@@ -2,9 +2,9 @@
 
 English | [中文](skills.zh.md)
 
-The [skill capability family](../../packages/skill) includes the Service Definition ([dsh-skill](../../packages/skill/skill), `ctx.skills`), the local Service Provider ([dsh-skill-filesystem](../../packages/skill/skill-filesystem)), the optional packaged badge provider ([dsh-skill-badge](../../packages/skill/skill-badge)), and the Consumer ([dsh-tool-skill](../../packages/skill/tool-skill)). The registry merges provider catalogs across its host and per-scope layers; providers contribute local or packaged skills; the Consumer owns the initial and replacement catalogs plus the model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md).
+The [skill capability family](../../packages/skill) includes the Service Definition ([dsh-skill](../../packages/skill/skill), `ctx.skills`), the local Service Provider ([dsh-skill-filesystem](../../packages/skill/skill-filesystem)), optional packaged providers ([dsh-skill-badge](../../packages/skill/skill-badge) and [dsh-skill-office](../../packages/skill/skill-office)), and the Consumer ([dsh-tool-skill](../../packages/skill/tool-skill)). The registry merges provider catalogs across its host and per-scope layers; providers contribute local or packaged skills; the Consumer owns the initial and replacement catalogs plus the model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md).
 
-Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts), [`packages/skill/skill-filesystem/src/index.ts`](../../packages/skill/skill-filesystem/src/index.ts), [`packages/skill/skill-badge/src/index.ts`](../../packages/skill/skill-badge/src/index.ts), and [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts).
+Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts), [`packages/skill/skill-filesystem/src/index.ts`](../../packages/skill/skill-filesystem/src/index.ts), [`packages/skill/skill-badge/src/index.ts`](../../packages/skill/skill-badge/src/index.ts), [`packages/skill/skill-office/src/index.ts`](../../packages/skill/skill-office/src/index.ts), and [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts).
 
 ## Provider registry
 

+ 2 - 2
docs/subsystems/skills.zh.md

@@ -2,9 +2,9 @@
 
 [English](skills.md) | 中文
 
-[skill(技能)能力族](../../packages/skill) 包含 Service Definition([dsh-skill](../../packages/skill/skill),`ctx.skills`)、本地 Service Provider([dsh-skill-filesystem](../../packages/skill/skill-filesystem))、可选的随包徽章提供方([dsh-skill-badge](../../packages/skill/skill-badge))和 Consumer([dsh-tool-skill](../../packages/skill/tool-skill))。注册表在其宿主层与各 scope 层之间合并各提供方的目录;提供方贡献本地或随包 skill;Consumer 拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.zh.md)。
+[skill(技能)能力族](../../packages/skill) 包含 Service Definition([dsh-skill](../../packages/skill/skill),`ctx.skills`)、本地 Service Provider([dsh-skill-filesystem](../../packages/skill/skill-filesystem))、可选的随包提供方([dsh-skill-badge](../../packages/skill/skill-badge) 与 [dsh-skill-office](../../packages/skill/skill-office))和 Consumer([dsh-tool-skill](../../packages/skill/tool-skill))。注册表在其宿主层与各 scope 层之间合并各提供方的目录;提供方贡献本地或随包 skill;Consumer 拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.zh.md)。
 
-源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-filesystem/src/index.ts`](../../packages/skill/skill-filesystem/src/index.ts)、[`packages/skill/skill-badge/src/index.ts`](../../packages/skill/skill-badge/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。
+源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-filesystem/src/index.ts`](../../packages/skill/skill-filesystem/src/index.ts)、[`packages/skill/skill-badge/src/index.ts`](../../packages/skill/skill-badge/src/index.ts)、[`packages/skill/skill-office/src/index.ts`](../../packages/skill/skill-office/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。
 
 ## 提供方注册表
 

+ 2 - 2
docs/subsystems/workspace.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/workspace.md
-workspace.md: 93cce66801d123edd3a4b27e79f025c6935121a2
-workspace.zh.md: 9727c2efa1f0d1abc840f5ce9a377f57af01985a
+workspace.md: 80ea3157dbd904e168e2809ce8aa0b8641c1b2d9
+workspace.zh.md: 727c612e58cdbe54b0ba0e6292803d4543e5a32e

+ 1 - 1
docs/subsystems/workspace.md

@@ -214,7 +214,7 @@ Typed Remote control of transient Session-owned terminal processes.
 @Remote list(sessionId: SessionId): WebTerminalInfo[]
 
 /**
- * Allocate an interactive shell once for a caller-generated identity.
+ * Allocate a user shell once for a caller-generated identity, without Agent sandbox or approval restrictions.
  * @param agent - Session owner supplied by the Gateway.
  * @param request - initial dimensions and idempotency identity.
  * @param signal - allocation cancellation; committed terminals survive disconnection.

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

@@ -214,7 +214,7 @@ Typed Remote control of transient Session-owned terminal processes.
 @Remote list(sessionId: SessionId): WebTerminalInfo[]
 
 /**
- * Allocate an interactive shell once for a caller-generated identity.
+ * Allocate a user shell once for a caller-generated identity, without Agent sandbox or approval restrictions.
  * @param agent - Session owner supplied by the Gateway.
  * @param request - initial dimensions and idempotency identity.
  * @param signal - allocation cancellation; committed terminals survive disconnection.

+ 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 - 1
package.json

@@ -239,6 +239,7 @@
     "tsx": "^4.22.4",
     "typescript": "^6.0.3",
     "vite-tsconfig-paths": "^6.1.1",
-    "vitest": "^4.1.8"
+    "vitest": "^4.1.8",
+    "@deepseek-ai/dsh-skill-office": "workspace:^"
   }
 }

+ 2 - 2
packages/api/terminal-controller/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/api/terminal-controller/README.md
-README.md: bc73007faa1b846e327742d2bfd5b44dcd23737d
-README.zh.md: 4dfd2181c518234df9517ae7afbeb57e4438b75c
+README.md: dd93237a94f0cc9bdbd6e6d51f9a2762898399a7
+README.zh.md: b3bda5f4040ab6f9bb9cd74a8fd32f48dbdb1362

+ 4 - 3
packages/api/terminal-controller/README.md

@@ -25,9 +25,9 @@ Open the execution environment's default shell in a Session workspace from the W
 <a id="use-this-package"></a>
 ## Use this package
 
-The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `shells`, `list`, `create`, `retain`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error.
+The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. Sandbox policy supplies only the fallback working directory for Sessions without a cwd. `remote.terminal` exposes `environment`, `shells`, `list`, `create`, `retain`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error.
 
-Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits only confirmed lookup misses. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory; its sandbox policy also applies to the terminal.
+Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits only confirmed lookup misses. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory. User terminals run with the execution environment’s system-user permissions, independently of the Agent’s sandbox mode and approval policy. Operating-system and container restrictions still apply; DSH does not elevate the user. The subprocess provider retains its credential-environment scrubbing.
 
 | Configuration | Default | Meaning |
 |---|---|---|
@@ -53,7 +53,7 @@ An open tab in any connected window retains its terminal, including hidden tabs
 
 The Host uses `ctx.subprocess.spawnTerminal` with `TERM=xterm-256color`; it never launches a desktop terminal application. Streaming UTF-8 decoding preserves split characters and leading BOMs, and replaces incomplete trailing bytes at EOF. Unary control uses the Gateway, and `follow` uses its multiplexed Remote stream transport. Headless xterm and its serializer produce each opening screen after all preceding output writes, then monotone output sequences identify subsequent frames. Slow followers fail explicitly; a new attachment restores the current screen.
 
-The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. An open or pending terminal prevents changing that Session's sandbox mode. Input or resize refused after control transfer or process exit leaves the output attachment intact and disables input; rejected input is not replayed.
+The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. Changing the Session’s sandbox mode leaves user terminals running with the same permissions. Input or resize refused after control transfer or process exit leaves the output attachment intact and disables input; rejected input is not replayed.
 
 The Client saves each Session/content-to-terminal association before allocation under its own `dsh.terminal.binding.v1.*` localStorage key. The content identity is globally unique; layout-local tab ids only identify live view occurrences. Independent record writes and deletes preserve other windows' bindings. Restored views reuse that identity; the sidebar terminal provider restores its views before querying unrepresented Host terminals. A new view may create a process, while a recovered view reports a missing target without creating a replacement. Explicit close removes the association after saving its cleanup request. The Host supplies current process metadata and screen contents; neither is saved in the browser. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses. Client-owned errors carry locale keys. Plugin disposal awaits active and previously detached output streams without closing Host processes.
 
@@ -70,6 +70,7 @@ Closing saves an unfinished cleanup request before releasing the tab, then await
 
 - [Subprocess](../../subprocess/subprocess/README.md)
 - [Right Sidebar](../../client/ui-sidebar-right/README.md)
+- [User-terminal permissions](../../../.agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.md)
 - [Web terminal decision](../../../.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md)
 
 <a id="model-experience"></a>

+ 4 - 3
packages/api/terminal-controller/README.zh.md

@@ -25,9 +25,9 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用此包
 
-Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`shells`、`list`、`create`、`retain`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。
+Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。Sandbox policy 仅为没有 cwd 的 Session 提供默认工作目录。`remote.terminal` 提供 `environment`、`shells`、`list`、`create`、`retain`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。
 
-Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,仅省略确定未找到的候选。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区,终端遵循同一 sandbox policy。
+Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,仅省略确定未找到的候选。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区。用户终端使用执行环境中系统用户的权限,独立于 Agent 的沙箱模式和审批策略。操作系统和容器的限制仍然生效;DSH 不提升用户权限。Subprocess provider 继续清除环境中的凭据变量。
 
 | 配置 | 默认值 | 含义 |
 |---|---|---|
@@ -53,7 +53,7 @@ Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provide
 
 Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终端,不启动桌面终端应用。流式 UTF-8 解码保留跨块字符和开头的 BOM,并在 EOF 将不完整的尾部字节替换为替代字符。控制请求走 Gateway,`follow` 使用其复用的 Remote stream。Headless xterm 和序列化 addon 在此前输出写入后生成初始屏幕,后续增量携带单调序号。过慢的订阅者明确失败;重新连接恢复当前屏幕。
 
-最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。存在终端或创建请求时不能改变该 Session 的 sandbox mode。 控制权转移或进程退出后被拒绝的输入和尺寸请求保留输出连接并禁用输入,不重发被拒绝的输入。
+最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。改变 Session 的沙箱模式时,用户终端继续以原有权限运行。控制权转移或进程退出后被拒绝的输入和尺寸请求保留输出连接并禁用输入,不重发被拒绝的输入。
 
 Client 在分配前将每条 Session/内容与终端身份的关联保存到独立的 localStorage key `dsh.terminal.binding.v1.*`。内容身份全局唯一;布局内的 tab id 只标识活动视图 occurrence。逐条记录的写入和删除会保留其他窗口的关联。恢复视图复用该身份;侧栏 terminal provider 先恢复自己的视图,再查询尚无视图的 Host 终端。新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。显式关闭先保存清理请求,再删除关联。当前进程元数据和屏幕内容由 Host 提供,不保存在浏览器中。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。Client 自产错误携带本地化键。插件卸载等待活跃及先前断开的输出流结束,不关闭 Host 进程。
 
@@ -70,6 +70,7 @@ Client 在分配前将每条 Session/内容与终端身份的关联保存到独
 
 - [Subprocess](../../subprocess/subprocess/README.zh.md)
 - [Right Sidebar](../../client/ui-sidebar-right/README.zh.md)
+- [用户终端权限](../../../.agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.zh.md)
 - [Web terminal decision](../../../.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md)
 
 <a id="model-experience"></a>

+ 7 - 25
packages/api/terminal-controller/src/index.ts

@@ -1,11 +1,9 @@
-/** Session-scoped browser terminals over the composed subprocess and sandbox providers. */
+/** Session-owned user terminals with the execution environment's system-user permissions. */
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import type { Agent } from '@deepseek-ai/dsh-agent'
-import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionId } from '@deepseek-ai/dsh-session'
 import type {} from '@deepseek-ai/dsh-sandbox-policy'
-import type {} from '@deepseek-ai/dsh-sandbox'
-import type {} from '@deepseek-ai/dsh-session-projection'
 import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
 import { discoverShells, resolveShell } from './shells.ts'
 import { BrowserTerminal } from './terminal.ts'
@@ -75,7 +73,7 @@ interface OwnedSession {
 
 /** Typed Remote control of transient Session-owned terminal processes. */
 export class TerminalController extends TypertRemoteService {
-  static inject = ['subprocess', 'sandboxPolicy', 'sessionProjections', 'typert']
+  static inject = ['subprocess', 'sandboxPolicy', 'typert']
   static Config: z<Config> = z.object({
     shell: z.union([z.object({
       path: z.string().required(), name: z.string().required(), args: z.array(z.string()).default([]),
@@ -102,15 +100,6 @@ export class TerminalController extends TypertRemoteService {
    */
   constructor(ctx: Context, private readonly config: Config) {
     super(ctx, 'terminalController', { namespace: 'terminal' })
-    ctx.on('internal/dispatch', (_mode, eventName, args) => {
-      if (eventName !== 'session/event') return
-      const [session, event] = args as [Session, SessionEvent]
-      if (event.type !== 'sandbox/mode') return
-      const owner = this.owners.get(session.id)
-      if (owner === undefined || owner.terminals.size + owner.pending.size + owner.allocations.size === 0) return
-      const current = ctx.sessionProjections.stateOf(session, 'sandboxMode') ?? ctx.sandboxPolicy.defaultMode
-      if (event.data.mode !== current) throw new Error('Close browser terminals before changing the Session sandbox mode')
-    }, { global: true })
     ctx.effect(() => async () => {
       this.lifetime.abort(new Error('Terminal controller disposed'))
       const results = await Promise.allSettled([...this.owners].map(([id, owner]) => this.disposeOwner(id, owner)))
@@ -129,7 +118,7 @@ export class TerminalController extends TypertRemoteService {
   environment(agent: Agent, signal: AbortSignal): TerminalEnvironment {
     signal.throwIfAborted()
     const { sandboxPolicy } = this.execution(agent)
-    return { cwd: sandboxPolicy.resolve({ session: agent.session }).workspaceRoot,
+    return { cwd: agent.session.header.cwd ?? sandboxPolicy.workspaceRoot,
       maxInputBytes: this.config.maxInputBytes, maxCols: this.config.maxCols,
       maxRows: this.config.maxRows, scrollback: this.config.scrollback }
   }
@@ -159,7 +148,7 @@ export class TerminalController extends TypertRemoteService {
   }
 
   /**
-   * Allocate an interactive shell once for a caller-generated identity.
+   * Allocate a user shell once for a caller-generated identity, without Agent sandbox or approval restrictions.
    * @param agent - Session owner supplied by the Gateway.
    * @param request - initial dimensions and idempotency identity.
    * @param signal - allocation cancellation; committed terminals survive disconnection.
@@ -349,20 +338,13 @@ export class TerminalController extends TypertRemoteService {
 
   private async spawn(agent: Agent, owner: OwnedSession, request: TerminalCreateRequest, signal: AbortSignal): Promise<BrowserTerminal> {
     const environment = this.environment(agent, signal)
-    const { subprocess, sandboxPolicy } = this.execution(agent)
+    const { subprocess } = this.execution(agent)
     const shell = request.shellPath === undefined
       ? await resolveShell(subprocess, this.config.shell, signal)
       : (await this.shells(agent, signal)).find(candidate => candidate.path === request.shellPath)
     if (shell === undefined) throw new Error('Selected shell is not available in this execution environment')
-    const policy = sandboxPolicy.resolve({ session: agent.session })
-    let argv = [shell.path, ...shell.args]
-    if (policy.mode !== 'danger-full-access') {
-      const sandbox = agent.ctx.get('sandbox')
-      if (sandbox === undefined) throw new Error('The Session sandbox mode requires an execution sandbox provider')
-      argv = (await sandbox.confine(argv, { ...policy, mode: policy.mode }, signal)).argv
-    }
     const handle = await subprocess.spawnTerminal({
-      argv, cwd: environment.cwd, cols: request.cols, rows: request.rows,
+      argv: [shell.path, ...shell.args], cwd: environment.cwd, cols: request.cols, rows: request.rows,
       terminalType: 'xterm-256color', env: { DSH_SESSION_ID: agent.id },
       shellActivity: true,
       graceMs: this.config.disposeGraceMs, signal,

+ 24 - 29
packages/api/terminal-controller/tests/controller.spec.ts

@@ -1,4 +1,4 @@
-/** Session identity, allocation races, confinement and real PTY behavior. */
+/** Session identity, allocation races, human execution permissions and real PTY behavior. */
 import { mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
@@ -21,18 +21,16 @@ const id = 'test-terminal' as WebTerminalId
 const request = { id, cols: 80, rows: 24 }
 const signal = (): AbortSignal => new AbortController().signal
 
-function owner(ctx: Context, id = 'session'): Agent {
-  return { id: id as SessionId, ctx, session: { id: id as SessionId } } as unknown as Agent
+function owner(ctx: Context, id = 'session', cwd?: string): Agent {
+  return { id: id as SessionId, ctx, session: { id: id as SessionId, header: { cwd } } } as unknown as Agent
 }
 
 function fixture(overrides: Partial<Config> = {}) {
   const ctx = new Context()
   roots.push(ctx)
   const effects = vi.spyOn(ctx.fiber, 'effect')
-  const sandboxPolicy = { defaultMode: 'danger-full-access', resolve: vi.fn((): SandboxExecutionPolicy => ({ mode: 'danger-full-access', workspaceRoot: '/workspace' })) }
-  const projections = { stateOf: vi.fn((): SandboxMode | null => null) }
+  const sandboxPolicy = { defaultMode: 'danger-full-access', workspaceRoot: '/workspace', resolve: vi.fn((): SandboxExecutionPolicy => ({ mode: 'danger-full-access', workspaceRoot: '/workspace' })) }
   ctx.provide('sandboxPolicy', sandboxPolicy as never)
-  ctx.provide('sessionProjections', projections as never)
   const output = new PassThrough()
   const done = Promise.withResolvers<{ exitCode: number; signal: null }>()
   const handle = {
@@ -50,7 +48,7 @@ function fixture(overrides: Partial<Config> = {}) {
     if (result?.type !== 'return' || typeof result.value !== 'function') throw new Error(`Missing effect: ${label}`)
     return result.value()
   }
-  return { ctx, agent: owner(ctx), controller, subprocess, handle, sandboxPolicy, projections, disposeEffect }
+  return { ctx, agent: owner(ctx), controller, subprocess, handle, sandboxPolicy, disposeEffect }
 }
 
 describe('TerminalController', () => {
@@ -282,22 +280,23 @@ describe('TerminalController', () => {
     expect(controller.list(agent.id)).toEqual([])
   })
 
-  it('uses the Session sandbox policy to confine its selected shell', async () => {
+  it.each(['read-only', 'workspace-write', 'danger-full-access'] as const)('starts a user shell without confinement under %s Agent permissions', async (mode) => {
     const { controller, agent, ctx, sandboxPolicy, subprocess } = fixture()
-    const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: '/workspace', sessionId: agent.id }
-    sandboxPolicy.resolve.mockReturnValue(policy)
+    sandboxPolicy.resolve.mockReturnValue({ mode, workspaceRoot: '/workspace', sessionId: agent.id })
     const confine = vi.fn((argv: readonly string[]) => ({ argv: ['sandbox-runner', ...argv] }))
     ctx.provide('sandbox', { confine } as never)
     await controller.create(agent, request, signal())
-    expect(confine).toHaveBeenCalledWith(['/bin/bash', '--noprofile', '--norc', '-i'], policy, expect.any(AbortSignal))
-    expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['sandbox-runner', '/bin/bash', '--noprofile', '--norc', '-i'], env: { DSH_SESSION_ID: agent.id }, graceMs: 100 }))
+    expect(confine).not.toHaveBeenCalled()
+    expect(sandboxPolicy.resolve).not.toHaveBeenCalled()
+    expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['/bin/bash', '--noprofile', '--norc', '-i'], env: { DSH_SESSION_ID: agent.id }, graceMs: 100 }))
   })
 
-  it('rejects a confined Session without a sandbox provider before spawning', async () => {
-    const { controller, agent, sandboxPolicy, subprocess } = fixture()
-    sandboxPolicy.resolve.mockReturnValue({ mode: 'read-only', workspaceRoot: '/workspace' })
-    await expect(controller.create(agent, request, signal())).rejects.toThrow('requires an execution sandbox provider')
-    expect(subprocess.spawnTerminal).not.toHaveBeenCalled()
+  it('uses the Session working directory without requiring a sandbox provider', async () => {
+    const { controller, ctx, subprocess } = fixture()
+    const agent = owner(ctx, 'workspace-session', '/another-workspace')
+    expect(controller.environment(agent, signal())).toMatchObject({ cwd: '/another-workspace' })
+    await controller.create(agent, request, signal())
+    expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/another-workspace' }))
   })
 
   it.each(['subprocess', 'sandboxPolicy'] as const)('fails clearly when the Session lacks %s', (missing) => {
@@ -309,20 +308,16 @@ describe('TerminalController', () => {
     expect(() => controller.environment(owner(isolated), signal())).toThrow('requires subprocess and sandbox policy providers')
   })
 
-  it('blocks sandbox-mode changes only while that Session retains a terminal', async () => {
-    const { controller, agent, ctx, projections } = fixture()
+  it('allows Agent sandbox-mode changes while retaining the same user terminal', async () => {
+    const { controller, agent, ctx, subprocess, handle } = fixture()
     const mode = (mode: SandboxMode): void => { ctx.emit('session/event', agent.session, { type: 'sandbox/mode', data: { mode } } as SessionEvent) }
-    ctx.emit('session/disposed', agent.session)
-    ctx.emit('session/event', agent.session, { type: 'turn/start', data: { turn: 1 } } as SessionEvent)
-    expect(() => { mode('workspace-write') }).not.toThrow()
     await controller.create(agent, request, signal())
-    expect(() => { mode('danger-full-access') }).not.toThrow()
-    expect(() => { mode('workspace-write') }).toThrow('Close browser terminals')
-    projections.stateOf.mockReturnValue('read-only')
-    expect(() => { mode('read-only') }).not.toThrow()
-    expect(() => { mode('danger-full-access') }).toThrow('Close browser terminals')
-    await controller.close(agent, id)
-    expect(() => { mode('workspace-write') }).not.toThrow()
+    for (const value of ['read-only', 'workspace-write', 'danger-full-access'] as const) {
+      expect(() => { mode(value) }).not.toThrow()
+      expect(controller.list(agent.id)).toMatchObject([{ id, state: 'running' }])
+    }
+    expect(subprocess.spawnTerminal).toHaveBeenCalledOnce()
+    expect(handle.terminate).not.toHaveBeenCalled()
   })
 
   it('terminates committed processes when the Session effect ends', async () => {

+ 2 - 2
packages/client/ui-primitives/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/client/ui-primitives/README.md
-README.md: 5e8b41a3119610ed7ca40f9ca974cc56cfc50cd7
-README.zh.md: 06b56d6dcd629aa1faef36950e6c884a1d863fee
+README.md: 9290831ca4f33899a88e61e8fdd8f9951de905fa
+README.zh.md: b865c49c86df5f2269cea90be9d93bfa3abdff51

+ 1 - 0
packages/client/ui-primitives/README.md

@@ -38,6 +38,7 @@ Check this table before writing a control in a feature package. A plugin cannot
 |---|---|
 | `Button` | Clickable action; `variant` selects `primary`, `ghost`, `outline`, or `toolbar`. |
 | `Switch` | Two-state toggle, 36×20. `label` is required, so the control cannot ship unnamed. |
+| `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. |
 | `Input` | Single-line text entry for search boxes and inline forms. |
 | `Menu` | Dropdown of items, separators, and group labels, with nested submenus. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. |
 | `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. |

+ 1 - 0
packages/client/ui-primitives/README.zh.md

@@ -38,6 +38,7 @@ kind: "package-library"
 |---|---|
 | `Button` | 可点击操作;`variant` 选择 `primary`、`ghost`、`outline` 或 `toolbar`。 |
 | `Switch` | 36×20 的双态开关。`label` 必填,控件不可能在没有名称的情况下发布。 |
+| `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 |
 | `Input` | 单行文本输入,用于搜索框与行内表单。 |
 | `Menu` | 由条目、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 |
 | `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 |

+ 28 - 0
packages/client/ui-primitives/src/Checkbox.module.css

@@ -0,0 +1,28 @@
+.checkbox {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 14px;
+  line-height: 20px;
+  color: var(--dsw-alias-label-primary);
+  cursor: pointer;
+}
+
+.checkbox input {
+  flex: 0 0 auto;
+  width: 16px;
+  height: 16px;
+  margin: 0;
+  accent-color: var(--dsw-alias-brand-primary);
+  cursor: inherit;
+}
+
+.checkbox input:focus-visible {
+  outline: 2px solid var(--dsw-alias-brand-primary);
+  outline-offset: 2px;
+}
+
+.checkbox:has(input:disabled) {
+  cursor: default;
+  opacity: 0.5;
+}

+ 30 - 0
packages/client/ui-primitives/src/Checkbox.tsx

@@ -0,0 +1,30 @@
+/** Controlled native checkbox with a caller-owned visible and accessible label. */
+
+import clsx from 'clsx'
+import css from './Checkbox.module.css'
+
+/**
+ * Render a labeled checkbox with native keyboard and form semantics.
+ * @param props.checked - current checked state.
+ * @param props.onChange - receives the requested checked state.
+ * @param props.label - localized visible and accessible label.
+ * @param props.disabled - whether the control refuses changes.
+ * @param props.title - optional localized hover text.
+ * @param props.className - extra class for the label's placement.
+ * @returns the label containing its checkbox.
+ */
+export function Checkbox({ checked, onChange, label, disabled = false, title, className }: {
+  checked: boolean
+  onChange: (next: boolean) => void
+  label: string
+  disabled?: boolean
+  title?: string | undefined
+  className?: string | undefined
+}) {
+  return (
+    <label className={clsx(css.checkbox, className)} title={title}>
+      <input type="checkbox" checked={checked} disabled={disabled} onChange={(event) => { onChange(event.target.checked) }} />
+      <span>{label}</span>
+    </label>
+  )
+}

+ 1 - 0
packages/client/ui-primitives/src/index.ts

@@ -12,6 +12,7 @@ export { Pill } from './Pill.tsx'
 export { Tag } from './Tag.tsx'
 export type { TagTone } from './Tag.tsx'
 export { Switch } from './Switch.tsx'
+export { Checkbox } from './Checkbox.tsx'
 export { Input } from './Input.tsx'
 export { Menu } from './Menu.tsx'
 export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx'

+ 26 - 0
packages/client/ui-primitives/tests/checkbox.client.spec.tsx

@@ -0,0 +1,26 @@
+// @vitest-environment jsdom
+/** The shared checkbox keeps native label activation and controlled state. */
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+import { Checkbox } from '../src/Checkbox.tsx'
+
+afterEach(cleanup)
+
+it('requests a checked state through its visible label and follows the owner', () => {
+  const onChange = vi.fn()
+  const { rerender } = render(<Checkbox label="Images" checked={false} onChange={onChange} />)
+  fireEvent.click(screen.getByText('Images'))
+  expect(onChange).toHaveBeenLastCalledWith(true)
+  expect(screen.getByRole<HTMLInputElement>('checkbox', { name: 'Images' }).checked).toBe(false)
+  rerender(<Checkbox label="Images" checked onChange={onChange} />)
+  fireEvent.click(screen.getByRole('checkbox'))
+  expect(onChange).toHaveBeenLastCalledWith(false)
+})
+
+it('refuses label activation while disabled', () => {
+  const onChange = vi.fn()
+  render(<Checkbox label="Images" checked disabled onChange={onChange} />)
+  fireEvent.click(screen.getByText('Images'))
+  expect(onChange).not.toHaveBeenCalled()
+  expect(screen.getByRole<HTMLInputElement>('checkbox').checked).toBe(true)
+})

+ 2 - 2
packages/client/ui-settings-models/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/client/ui-settings-models/README.md
-README.md: a26cd22f8785f8f2c948ac74560ff85d80097577
-README.zh.md: 82b9e031b477aa83c59acdfbade38f8282adefbd
+README.md: 6feb95d987f11bcf81b9bfa2c67068567244dcec
+README.zh.md: 9316d698bdf11f630f5815b9fffd0bbdc4546031

+ 3 - 3
packages/client/ui-settings-models/README.md

@@ -35,15 +35,15 @@ The primary field on an editor card is a single **API key** input — the page n
 
 ### Editing a provider
 
-The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `settings.yaml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, and image input; unrelated model fields survive edits.
+The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `settings.yaml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, and input types; unrelated model fields survive edits.
 
 The DeepSeek card edits the shared `llm-deepseek` endpoint, credentials, and model catalog without a protocol selector. When Cordis YAML selects Messages, the public endpoint placeholder is `https://api.deepseek.com/anthropic`. Saving the card preserves protocol configuration.
 
-Expand **Customized settings → Model options → Image input** to declare whether the model supports images. **Supported** writes text and image input; **Not supported** writes text only. DeepSeek's **Default (text only)** removes `inputModalities`; an absent declaration means text only. Pi-ai's **Use default** removes `input` and inherits the installed model catalog or provider default. Selecting **Not supported** or **Default (text only)** for DeepSeek also removes `imagePixelBudget` and `imageMaxBytes`, which its adapter rejects without image input. Declare support only for models that can actually process images.
+Expand **Customized settings → Model options** to edit each model. Both provider families share the same row layout, labels, and icons: context window and max output tokens occupy two columns, and **Input types** occupies a separate row with **Text** and **Image** checkboxes. A row without an input declaration displays the installed model’s input types, then the provider default, then Text. Known pi-ai providers load their installed catalog without endpoint I/O; opening a row does not write an override. Explicit input selections take precedence, including text-only overrides of vision models. Checkbox edits save the selected types, with at least one type required. DeepSeek writes `inputModalities`; pi-ai writes `input`. Unchecking Image for DeepSeek also removes `imagePixelBudget` and `imageMaxBytes`, because the adapter rejects those limits without image input. Clearing the input field in `settings.yaml` restores adapter inheritance; **Restore default models** resets the entire catalog override. Declare only input types the upstream model can actually process.
 
 ### Adding and deleting providers
 
-The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each selected candidate copies its id, display name, context window, and output-token cap into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
+The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
 
 ### First-run dialogs
 

+ 3 - 3
packages/client/ui-settings-models/README.zh.md

@@ -35,15 +35,15 @@ kind: "package-reference"
 
 ### 编辑提供方
 
-收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `settings.yaml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens` 和图片输入;无关的模型字段在编辑后仍会保留。
+收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `settings.yaml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens` 和输入类型;无关的模型字段在编辑后仍会保留。
 
 `llm-deepseek` 的 DeepSeek 卡片编辑共用的端点、凭据和模型目录,不提供协议选择器。Cordis YAML 选择 Messages 时,官方端点占位符为 `https://api.deepseek.com/anthropic`;保存卡片不会改写协议配置。
 
-展开**自定义设置 → 模型选项 → 图片输入**,声明模型是否支持图片。选择**支持**会写入文本和图片输入;选择**不支持**会写入仅文本。DeepSeek 的**默认(仅文本)**会移除 `inputModalities`;缺省的声明表示仅文本。Pi-ai 的**使用默认值**会移除 `input`,继承已安装模型目录或提供方的默认值。DeepSeek 选择**不支持**或**默认(仅文本)**时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。仅为实际能够处理图片的模型声明支持。
+展开**自定义设置 → 模型选项**编辑模型。两个提供方家族共用相同的模型行布局、标签和图标:上下文窗口与最大输出 token 数分为两列,**输入类型**独占下一行,提供**文本**和**图片**复选框。未声明输入类型的模型行优先显示已安装模型的输入类型,其次是提供方默认值,最后回退为文本。已知 pi-ai 提供方会加载已安装目录,不向端点发送请求;打开模型行不会写入覆盖值。显式输入选择具有优先权,包括为视觉模型设置的仅文本覆盖。修改复选框会保存所选类型,且至少保留一种。DeepSeek 写入 `inputModalities`,pi-ai 写入 `input`。DeepSeek 取消勾选图片时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。在 `settings.yaml` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。
 
 ### 新增与删除提供方
 
-「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供方错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供方公布相应信息时,把 id、显示名、上下文窗口与最大输出 token 数复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。
+「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供方错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供方公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。
 
 ### 首次运行弹窗
 

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů