Pārlūkot izejas kodu

Merge master with released Office skills into shared conversion

yudshj 1 nedēļu atpakaļ
vecāks
revīzija
14f375168d
100 mainītis faili ar 1071 papildinājumiem un 224 dzēšanām
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml
  2. 4 0
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
  3. 4 0
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml
  8. 2 0
      .agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md
  9. 2 0
      .agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md
  10. 6 0
      .agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.i18n.yaml
  11. 29 0
      .agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.md
  12. 29 0
      .agents/notes/implemented/architecture/2026-09-16-user-terminal-permissions.zh.md
  13. 6 0
      .agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml
  14. 29 0
      .agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md
  15. 29 0
      .agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md
  16. 2 2
      .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml
  17. 3 3
      .agents/notes/implemented/feature/2026-07-06-sandbox.md
  18. 3 3
      .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md
  19. 2 2
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml
  20. 1 1
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md
  21. 1 1
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md
  22. 2 2
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml
  23. 2 2
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
  24. 2 2
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md
  25. 6 0
      .agents/notes/implemented/feature/2026-09-15-bundled-office-skills.i18n.yaml
  26. 31 0
      .agents/notes/implemented/feature/2026-09-15-bundled-office-skills.md
  27. 31 0
      .agents/notes/implemented/feature/2026-09-15-bundled-office-skills.zh.md
  28. 6 0
      .agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.i18n.yaml
  29. 23 0
      .agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.md
  30. 23 0
      .agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.zh.md
  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. 5 1
      apps/desktop/README.md
  39. 5 1
      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. 30 0
      apps/desktop/src/directory-picker.ts
  43. 2 1
      apps/desktop/src/host-process.ts
  44. 19 0
      apps/desktop/src/ipc.ts
  45. 5 13
      apps/desktop/src/main.ts
  46. 2 3
      apps/desktop/src/node-environment.ts
  47. 6 3
      apps/desktop/src/preload-app.ts
  48. 82 0
      apps/desktop/tests/directory-picker.spec.ts
  49. 23 2
      apps/desktop/tests/main-startup.spec.ts
  50. 19 0
      apps/desktop/tests/node-environment.spec.ts
  51. 17 1
      apps/desktop/tests/preload-app.spec.ts
  52. 24 2
      apps/desktop/tests/primary-runtime-preparation.spec.ts
  53. 50 0
      apps/desktop/tests/ptc-runtime.spec.ts
  54. 9 9
      apps/web/tests/expected/plugin-manager/live-enabled.expected.md
  55. 9 9
      apps/web/tests/expected/plugin-manager/manager.expected.md
  56. 55 5
      apps/web/tests/permission-policy-context.e2e.ts
  57. 38 0
      apps/web/tests/plugin-manager.e2e.ts
  58. 2 2
      docs/capability-seams.i18n.yaml
  59. 3 1
      docs/capability-seams.md
  60. 3 1
      docs/capability-seams.zh.md
  61. 2 2
      docs/config-catalog.i18n.yaml
  62. 18 2
      docs/config-catalog.md
  63. 18 2
      docs/config-catalog.zh.md
  64. 2 2
      docs/event-producer-consumer.i18n.yaml
  65. 1 1
      docs/event-producer-consumer.md
  66. 1 1
      docs/event-producer-consumer.zh.md
  67. 2 2
      docs/module-graph.i18n.yaml
  68. 3 0
      docs/module-graph.md
  69. 3 0
      docs/module-graph.zh.md
  70. 2 2
      docs/persistence-catalog.i18n.yaml
  71. 3 3
      docs/persistence-catalog.md
  72. 3 3
      docs/persistence-catalog.zh.md
  73. 1 1
      docs/persistence-schema.json
  74. 2 2
      docs/subsystems/skills.i18n.yaml
  75. 2 2
      docs/subsystems/skills.md
  76. 2 2
      docs/subsystems/skills.zh.md
  77. 2 2
      docs/subsystems/workspace.i18n.yaml
  78. 1 1
      docs/subsystems/workspace.md
  79. 1 1
      docs/subsystems/workspace.zh.md
  80. 2 1
      package.json
  81. 2 2
      packages/api/terminal-controller/README.i18n.yaml
  82. 4 3
      packages/api/terminal-controller/README.md
  83. 4 3
      packages/api/terminal-controller/README.zh.md
  84. 7 25
      packages/api/terminal-controller/src/index.ts
  85. 24 29
      packages/api/terminal-controller/tests/controller.spec.ts
  86. 2 2
      packages/client/README.i18n.yaml
  87. 1 1
      packages/client/README.md
  88. 1 1
      packages/client/README.zh.md
  89. 2 2
      packages/client/ui-directory-picker-native/README.i18n.yaml
  90. 8 5
      packages/client/ui-directory-picker-native/README.md
  91. 7 4
      packages/client/ui-directory-picker-native/README.zh.md
  92. 2 1
      packages/client/ui-directory-picker-native/package.json
  93. 4 4
      packages/client/ui-directory-picker-native/src/client/flow.ts
  94. 6 10
      packages/client/ui-directory-picker-native/src/client/index.ts
  95. 58 1
      packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx
  96. 33 0
      packages/client/ui-directory-picker-native/tests/desktop-picker.client.spec.tsx
  97. 2 2
      packages/client/ui-plugin-manager/README.i18n.yaml
  98. 2 0
      packages/client/ui-plugin-manager/README.md
  99. 2 0
      packages/client/ui-plugin-manager/README.zh.md
  100. 6 6
      packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
-2026-09-10-desktop-web-wrapper.md: 13b2a36643198f649d6c4eb56df3e01aa98b50ac
-2026-09-10-desktop-web-wrapper.zh.md: de1f5e671c88ea03d6bd36e88692c81ff6f58404
+2026-09-10-desktop-web-wrapper.md: 445c574f86d6e465763603e7d447d92ffefb4f99
+2026-09-10-desktop-web-wrapper.zh.md: a41ce3c2f18237ddf580777e88fe05507998982f

+ 4 - 0
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md

@@ -26,8 +26,12 @@ Shared `initProfile` creates missing profile files and preserves existing conten
 
 This partially supersedes the private composition and portless transport in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md). That design avoided listening ports and used framed byte pipes to avoid Base64 expansion and cross-version V8 serialization. Shared HTTP gives up the portless guarantee and assigns serving and authentication to the existing Web implementation. Release identity, signing, process ownership, and native shell features remain active decisions.
 
+Native directory selection in the local application uses a narrow preload IPC call to Electron’s window-owned dialog. Main admits only the current application window’s main frame at `dsh-app://app`; shell, remote, and child frames cannot request it. Concurrent requests share the pending dialog and destroyed windows discard selections. Web backend selection and Host browse are shared.
+
 ## Alternatives considered
 
+**Use the Host OS chooser in Electron.** The Host’s macOS AppleScript dialog has no Electron parent window and cannot reliably follow application focus. Electron owns the local dialog while Web keeps its Host chooser; cancellation and errors do not launch a second chooser.
+
 **Maintain a second backend composition and carrier.** This permits a portless application, but every Web route, reload behavior, authentication change, and stream capability needs a Desktop implementation or explicit omission. Reintroduction requires a desktop product requirement that cannot use the Web implementation and justifies that continuing cost.
 
 **Merge CLI and Desktop plugin installations.** Shared boot code does not require shared executable dependencies. Separate installations allow independently qualified releases and plugin versions while their existing data owners govern shared sessions and settings.

+ 4 - 0
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md

@@ -26,8 +26,12 @@ App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析
 
 本记录部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的私有组合与无端口传输。该设计避免监听端口,并使用分帧字节管道避免 Base64 膨胀与跨版本 V8 序列化。共享 HTTP 放弃无端口保证,将服务与认证交给已有 Web 实现。发布身份、签名、进程归属及原生壳功能仍是有效决策。
 
+本地应用的原生目录选择通过窄 preload IPC 调用 Electron 的窗口所属对话框。Main 仅接受当前应用窗口中位于 `dsh-app://app` 的主框架请求;shell、远程页面和子框架均不能调用。并发请求共用待完成的对话框,窗口销毁后丢弃选择结果。Web 后端选择和 Host 浏览由共享实现负责。
+
 ## Alternatives considered
 
+**在 Electron 中使用 Host 操作系统选择器。** Host 的 macOS AppleScript 对话框没有 Electron 父窗口,无法可靠跟随应用焦点。Electron 负责本地对话框,Web 保留 Host 选择器;取消和错误不会启动第二个选择器。
+
 **维护第二套后端组合与传输。** 这允许应用不监听端口,但每项 Web 路由、重载行为、认证变化和流式能力都需要 Desktop 实现或明确省略。只有无法使用 Web 实现、且足以承担持续维护成本的桌面产品需求,才支持重新引入这种方案。
 
 **合并 CLI 与 Desktop 插件安装。** 共享启动代码不要求共享可执行依赖。独立安装允许分别验收发布与插件版本,共享会话和设置则仍由已有数据归属方负责。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.md
-2026-09-11-desktop-electron-node-runtime.md: 6743bbfcc9acffb04d28ef9f1540d516b10dd2fc
-2026-09-11-desktop-electron-node-runtime.zh.md: 73622e66183eb7ed94f654e861cb061e02be7a3d
+2026-09-11-desktop-electron-node-runtime.md: 71516bb930f8eee22c1c6a988b6dab596a53a215
+2026-09-11-desktop-electron-node-runtime.zh.md: 162908392b98ee6a666ca414b602207ad8344937

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.md

@@ -18,7 +18,7 @@ This supersedes the separate-Node choice in the [packaging decision](2026-08-25-
 
 Host and pnpm launches pass `--expose-internals`: the bundled Cordis loader uses Node's internal ESM loader, while its native builtin accessor cannot locate the required symbol in Electron 44. The explicit flag makes that loader available without modifying Cordis. The RunAsNode fuse remains enabled.
 
-Package-script environments prepend a small `node` shell launcher that forwards arguments to the current Electron executable. This supports shell lifecycle scripts without a system Node installation. On Windows it is `node.cmd`, not a replacement `node.exe`; third-party code that directly spawns the literal `node` executable without a shell must use `process.execPath` or provide its own runtime. Child processes inherit RunAsNode; worker threads inherit the Host's arguments. Desktop does not emulate upstream OpenSSL behavior or rebuild arbitrary third-party native addons automatically.
+The Host inherits the caller PATH without Desktop’s private `bin` directory, so PTC and agent shells cannot resolve internal launchers through that directory. `DSH_DESKTOP_NODE_EXECUTABLE` is injected only for package installation. Package-script environments prepend a small `node` shell launcher that forwards arguments to the current Electron executable. This supports shell lifecycle scripts without a system Node installation. On Windows it is `node.cmd`, not a replacement `node.exe`; third-party code that directly spawns the literal `node` executable without a shell must use `process.execPath` or provide its own runtime. Child processes inherit RunAsNode; worker threads inherit the Host's arguments. Desktop does not emulate upstream OpenSSL behavior or rebuild arbitrary third-party native addons automatically.
 
 Electron's Node patches and native ABI are release compatibility obligations. The packaged native smoke exercises pnpm shell scripts without system Node on PATH, terminal output through the Windows shell, Koffi, Sharp, and HTML conversion. The Host smoke loads an external plugin sharing Cordis and serves its route through the real Web application. Platform signing and installed-application qualification remain required; Windows results do not establish macOS compatibility. Windows token signing runs serially and retains the first failure, preventing queued tasks from repeating a rejected PIN.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.zh.md

@@ -18,7 +18,7 @@ Desktop 通过自己的 Electron 可执行文件运行共享 Web Host 和内置
 
 Host 和 pnpm 启动时传入 `--expose-internals`:内置 Cordis 加载器使用 Node 内部 ESM 加载器,而其原生 builtin 访问器无法在 Electron 44 中找到所需符号。显式参数使加载器可用,无需修改 Cordis。RunAsNode fuse 保持启用。
 
-包脚本环境在 PATH 前添加一个小型 `node` shell 启动器,把参数转发给当前 Electron 可执行文件。这支持没有系统 Node 的 shell 生命周期脚本。Windows 上它是 `node.cmd`,并非替代的 `node.exe`;第三方代码若绕过 shell 直接启动名为 `node` 的可执行文件,必须使用 `process.execPath` 或提供自己的运行时。子进程继承 RunAsNode;worker 线程继承 Host 参数。Desktop 不模拟上游 OpenSSL 行为,也不自动重编译任意第三方原生扩展。
+Host 继承调用者的 PATH,不加入 Desktop 私有的 `bin` 目录,因此 PTC 和 agent shell 不会通过该目录解析内部启动器。`DSH_DESKTOP_NODE_EXECUTABLE` 仅为包安装注入。包脚本环境在 PATH 前添加一个小型 `node` shell 启动器,把参数转发给当前 Electron 可执行文件。这支持没有系统 Node 的 shell 生命周期脚本。Windows 上它是 `node.cmd`,并非替代的 `node.exe`;第三方代码若绕过 shell 直接启动名为 `node` 的可执行文件,必须使用 `process.execPath` 或提供自己的运行时。子进程继承 RunAsNode;worker 线程继承 Host 参数。Desktop 不模拟上游 OpenSSL 行为,也不自动重编译任意第三方原生扩展。
 
 Electron 的 Node 补丁和原生 ABI 属于发布兼容性责任。打包原生 smoke 在 PATH 不含系统 Node 的情况下验证 pnpm shell 脚本,并验证 Windows shell 终端输出、Koffi、Sharp 和 HTML 转换。Host smoke 加载共享 Cordis 的外部插件,通过真实 Web 应用提供其路由。各平台仍需完成签名和已安装应用验收;Windows 结果不能证明 macOS 兼容性。Windows Token 签名串行执行并保留首次失败,阻止排队任务重复提交被拒绝的 PIN。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md
-2026-09-11-sandboxed-node-ptc-runtime.md: 242b3cfedb49b7ab60c47c6ee03005ddb821bb33
-2026-09-11-sandboxed-node-ptc-runtime.zh.md: d1ab47550e49129ee3e1fefbdfa54cda397374a3
+2026-09-11-sandboxed-node-ptc-runtime.md: f851775460c5bd01760d31552bde8c691807ec06
+2026-09-11-sandboxed-node-ptc-runtime.zh.md: 94f3a37eb702a97161c1d08cec531fcffb3460f2

+ 2 - 0
.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md

@@ -14,6 +14,8 @@ The [PTC foundation](../feature/2026-06-15-ptc.md) remains responsible for regis
 
 `dsh-ptc-runtime-node` runs each program in one fresh Node process. The host resolves execution choices, confines the launch through the same `ctx.sandbox` provider as Bash, and gives process lifetime to `ctx.subprocess`. The child evaluates erasable TypeScript with direct Node APIs, an empty model environment and host-provided asynchronous bindings. No worker or persistent kernel remains inside this provider.
 
+The host preserves `ELECTRON_RUN_AS_NODE` for child startup; the bootstrap removes it from the native environment before evaluation, and model-visible `process.env` stays empty. Nested Electron launches require their own explicit Node-mode selection. Desktop uses Electron as its Node executable; removing this selector launches Electron's application path instead of the PTC bootstrap. Sandbox permission changes cannot repair that launch mismatch. The macOS Desktop regression uses real Electron to verify binding writes, direct workspace writes, and rejection of writes outside the workspace under restricted policy. It requires an installed Electron binary; ordinary runtime tests cover environment filtering without that dependency.
+
 ### Resolved inputs and policy
 
 `PtcRuntime.resolve(request)` validates supported options and supplies a complete `PtcRunSpec`; `run(spec)` does not introduce defaults. PTC passes the calling Session's cwd and resolved standing policy. Direct runtime callers receive deployment defaults through the same resolver. The filesystem and subprocess providers share one execution world, and bootstrap paths cross through the filesystem's explicit host-file mapping or a configured preinstalled bootstrap.

+ 2 - 0
.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md

@@ -14,6 +14,8 @@ Node worker 隔离 JavaScript 状态,但不应用调用 Session 的 OS 沙箱
 
 `dsh-ptc-runtime-node` 在一个全新 Node 进程中运行每个程序。Host 解析执行选择,通过与 Bash 相同的 `ctx.sandbox` 提供方约束启动,并将进程生命周期交给 `ctx.subprocess`。子进程以直接 Node API、空模型环境和 Host 提供的异步绑定求值可擦除 TypeScript。本提供方不保留 worker 或持久内核。
 
+Host 在子进程启动时保留 `ELECTRON_RUN_AS_NODE`;bootstrap 在求值前将其从原生环境中删除,模型可见的 `process.env` 仍为空。嵌套启动 Electron 需要自行显式选择 Node 模式。桌面端使用 Electron 作为 Node 可执行文件;删除此选择变量会启动 Electron 应用路径,而不是 PTC bootstrap。更改沙箱权限无法修复这一启动模式不匹配。macOS 桌面端回归测试使用真实 Electron 验证绑定写入、直接工作区写入以及受限策略对工作区外写入的拒绝。该测试需要已安装的 Electron 二进制文件;普通运行时测试无需此依赖即可覆盖环境过滤。
+
 ### 已解析输入与策略
 
 `PtcRuntime.resolve(request)` 验证支持的选项并补全 `PtcRunSpec`;`run(spec)` 不引入默认值。PTC 传入调用 Session 的 cwd 与已解析常设策略。直接运行时调用方通过同一解析器取得部署默认值。文件系统与子进程提供方共享一个执行世界,bootstrap 路径通过文件系统的显式宿主文件映射或配置的预安装 bootstrap 传递。

+ 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-07-06-sandbox.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md
-2026-07-06-sandbox.md: 9f7139c8088df35ac87ddd66120dd8d6dc8e6e35
-2026-07-06-sandbox.zh.md: b8d8b1feb0db91431d0e71bea42299eafbc6856f
+2026-07-06-sandbox.md: 45a1c9c028f127c67e56f4436088852f59e10140
+2026-07-06-sandbox.zh.md: 6ad51a039583949849c853fab2f7c2a72d6d9db7

+ 3 - 3
.agents/notes/implemented/feature/2026-07-06-sandbox.md

@@ -86,7 +86,7 @@ The model sees the current effective file policy in the owner-derived `sandbox:p
 
 `ctx.sandboxPolicy.resolve()` stamps the complete execution policy — explicit escalation mode > session override > configured default, with `SessionHeader.cwd` > configured fallback root — before the executor runs. `SandboxBashExecutor.resolve()` retains that policy on the spec, or supplies the deployment fallback for a direct agentless caller, so `run()`/`start()` never read mutable session state. Per-process wrap facts are keyed by the returned `ShellProcess`; `onProcessDone()` receives spawn failure out of band from stderr classification and stamps that handle before `done` resolves, so overlapping processes retain their own modes and runner dialects.
 
-When a confining executor is mounted, `bash` advertises paired `sandbox_permissions` and `justification` fields. The schema exposes the full closed escalation vocabulary because effective mode is per-session; execution rejects any target that is not strictly wider than that call's effective mode. Approval resolves before execution. `allowed-once` stamps the granted mode onto only that request, while `rejected`, `cancelled`, `unavailable`, a missing approval service, or a missing agent all fail closed with distinct results. No grant is persisted.
+When a confining executor is mounted, `bash` advertises paired `sandbox_permissions` and `justification` fields. The schema exposes the full closed escalation vocabulary because effective mode is per-session; execution accepts a repeated effective mode without approval and rejects narrower or unsupported targets ([same-mode requests](2026-09-16-sandbox-same-mode.md)). Approval resolves before execution. `allowed-once` stamps the granted mode onto only that request, while `rejected`, `cancelled`, `unavailable`, a missing approval service, or a missing agent all fail closed with distinct results. No grant is persisted.
 
 Escalation is a same-turn retry of the denied command with the narrowest sufficient `sandbox_permissions` and a `justification`; the approval prompt is the consent step. It must be grounded in an actual denial, except when the session already observed the same denied access, and a disabled or rejected approval ends that command. The retry, approval decision, and result use existing tool and approval events. `dsh-tool-bash` owns the ask because the executor Service Definition has neither the agent nor call id required for user interaction.
 
@@ -164,7 +164,7 @@ Each phase gets its full design when picked up, validated against the code at th
 What shipped pins — the tiers in Testing hold each:
 
 - A denied command retried with `sandbox_permissions` + `justification` prompts the user through the composed answerer chain; a grant runs THAT call under the wider mode (result facts say so) while every other call keeps its own effective mode; every non-grant outcome produces its distinct error text and executes nothing.
-- The escalation fields exist exactly when the mounted executor confines; a request that is not strictly wider than the call's effective mode fails closed with its own text and prompts no one; a deployment with no ApprovalService fails escalating calls closed and leaves plain calls untouched.
+- The escalation fields exist exactly when the mounted executor confines; a repeated effective mode succeeds without approval; narrower or unsupported targets fail closed without prompting; a deployment with no ApprovalService fails escalating calls closed and leaves plain calls untouched.
 - One sourced policy-context message states the complete current sandbox and approval policies atomically; the whole exchange — context messages, headers, knob events, approval notices, approvals, and results — reconstructs from the session log alone, with no policy bookkeeping events beyond the two knob events.
 - One preset selection records only changed knob values, while a no-op selection records nothing; the next pre-step upserts both current values atomically, and a committed sandbox switch is honored by the next call's stamp.
 - A resumed session's overrides enter its first new policy-context message with no catch-up state; a composition default changed while the process was down likewise appears in that message.
@@ -182,7 +182,7 @@ Costs and accepted limits:
 - **Runner attribution uses an in-band protocol.** Exit status plus stderr cannot cryptographically identify the writer, so a confined child can mimic a fatal runner line and status to cause an availability/diagnostic false attribution. The conjunction and exact notice exclusion reduce accidental matches; this is not a sandbox bypass because the child is already confined.
 - **The launcher is a workspace dependency in source and an npm dependency after publication.** The main repository tests reviewed C source, native CI builds, and byte-pinned local tarballs together before publishing the same package family; the real-kernel e2e legs vouch for behavior through those installed bytes.
 - **The model may over-ask.** Escalating without denial grounding, or picking `danger-full-access` where `workspace-write` suffices: the description steers and the enum forces the ladder, but the human prompt is the actual gate; the `approval/asked` reasons make over-asking auditable, and a `prepend` policy answerer can auto-reject patterns a deployment never wants.
-- **The advertised target set is static while the effective mode is per-session** (schemas are registry-global) — a session already at the widest mode is still offered the fields. Harmless by construction: the strict-wider check at execution, not the enum, is the safety boundary — a non-widening request fails with its own text and never prompts anyone.
+- **The advertised target set is static while the effective mode is per-session** (schemas are registry-global) — a session already at the widest mode is still offered the fields. Harmless by construction: the strict-wider check at execution, not the enum, is the safety boundary — a same-mode request needs no approval, and narrower or unsupported targets fail without prompting.
 - **A granted escalation is not a working sandbox.** An unavailable backend still fails closed even for a granted escalation to a confining mode — at `confine()` when the platform has no chain or every probe fails, through the spawn channel when the selected executable cannot start, or through a structured rule when a started runner refuses — while a granted `danger-full-access` run never touches the provider at all: there the grant, not the probe, is the authority.
 - **Runtime-context history is append-only.** A policy switch appends a complete superseding snapshot after retained history, preserving the stable system-and-conversation prefix; unchanged state adds no message.
 - **Older policy snapshots remain in history.** Each full snapshot explicitly supersedes earlier runtime-context snapshots, so replay and compaction need only retain the latest materialized message.

+ 3 - 3
.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md

@@ -86,7 +86,7 @@ Landlock launcher 源码和包家族位于 `native/system`,与 harness 消费
 
 `ctx.sandboxPolicy.resolve()` 在执行器运行前盖章完整执行策略——显式升级模式 > 会话覆盖 > 配置默认值,且 `SessionHeader.cwd` > 配置的后备根目录。`SandboxBashExecutor.resolve()` 在 spec 上保留该策略,或为直接的无 agent 调用方提供部署后备值,使 `run()`/`start()` 永不读取可变会话状态。每进程包装事实以返回的 `ShellProcess` 为键;`onProcessDone()` 会通过 stderr 分类之外的通道接收 spawn 失败,并在 `done` 结算前给该句柄盖章,因此重叠进程各自保留自己的模式和 runner 方言。
 
-当约束执行器被挂载时,`bash` 公布配对的 `sandbox_permissions` 和 `justification` 字段。schema 暴露完整的封闭升级词汇,因为有效模式是按会话的;执行拒绝任何不严格宽于该调用有效模式的目标。批准在执行之前解析。`allowed-once` 仅将授权模式盖章到该请求上,而 `rejected`、`cancelled`、`unavailable`、缺失的 approval 服务或缺失的 agent 都以各自不同的结果文本失败关闭。授权不持久化。
+当约束执行器被挂载时,`bash` 公布配对的 `sandbox_permissions` 和 `justification` 字段。schema 暴露完整的封闭升级词汇,因为有效模式是按会话的;执行允许重复有效模式且无需审批,拒绝更窄或不支持的目标([同模式请求](2026-09-16-sandbox-same-mode.zh.md))。批准在执行之前解析。`allowed-once` 仅将授权模式盖章到该请求上,而 `rejected`、`cancelled`、`unavailable`、缺失的 approval 服务或缺失的 agent 都以各自不同的结果文本失败关闭。授权不持久化。
 
 升级是对被拒绝命令的同轮次重试,使用最窄的足够 `sandbox_permissions` 和一个 `justification`;批准提示词是同意步骤。它必须基于实际的拒绝,除非会话已观察到相同的被拒绝访问;禁用或被拒绝的批准终结该命令。重试、批准决策和结果使用既有的工具和批准事件。`dsh-tool-bash` 拥有请求动作,因为执行器 Service Definition 既没有 agent 也没有用户交互所需的 call id。
 
@@ -164,7 +164,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边
 已交付并固定的内容——测试中的各层级分别保障:
 
 - 被拒绝的命令以 `sandbox_permissions` + `justification` 重试时,通过组合的应答器链提示用户;授权使该次调用在更宽模式下运行(结果事实如此报告),而其他所有调用保持各自的有效模式;每种非授权结果产生各自不同的错误文本且不执行任何内容。
-- 升级字段恰好在已挂载的执行器约束时存在;不严格宽于调用有效模式的请求以自身文本失败关闭且不提示任何人;没有 ApprovalService 的部署对升级调用失败关闭,对普通调用不影响。
+- 升级字段恰好在已挂载的执行器约束时存在;重复有效模式无需审批即可成功;更窄或不支持的目标不提示任何人并失败关闭;没有 ApprovalService 的部署对升级调用失败关闭,对普通调用不影响。
 - 一条带来源的策略上下文消息会以原子方式声明完整的当前沙箱策略与批准策略;整个交互——上下文消息、header、旋钮事件、批准通知、批准与结果——仅从会话日志即可重建,除两个旋钮事件外没有策略簿记事件。
 - 一次 preset 选择只记录发生变化的旋钮值,而无操作的选择不记录任何内容;下一次 pre-step 会原子 upsert 两个当前值,已提交的沙箱切换由下一次调用的盖章兑现。
 - 恢复会话的覆盖项会进入其首条新策略上下文消息,无需追赶状态;进程停止期间变更的组合默认值也会出现在该消息中。
@@ -182,7 +182,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边
 - **Runner 归因使用带内协议。** 退出状态与 stderr 无法以密码学方式识别写入者,因此受限子进程可以模仿 runner 的致命诊断行和状态,造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配;这不是沙箱绕过,因为子进程已经受到限制。
 - **launcher 在源码中是 workspace 依赖,发布后是 NPM 依赖。** 主仓库会在发布同一个包家族之前,一起测试经审查的 C 源码、原生 CI 构建和字节固定的本地 tarball;真实内核 e2e 测试环节会验证这些安装字节的实际行为。
 - **模型可能过度请求。** 在没有拒绝依据的情况下升级,或在 `workspace-write` 足够时选择 `danger-full-access`:描述引导且枚举强制阶梯,但人的提示词是实际门控;`approval/asked` 原因使过度请求可审计,且 `prepend` 策略应答器可以自动拒绝部署永远不想要的模式。
-- **公布的目标集是静态的,而有效模式是按会话的**(schema 是注册表全局的)——已处于最宽模式的会话仍被提供这些字段。构造上无害:执行时的严格放宽检查(而非枚举)是安全边界——非放宽请求以自身文本失败且不提示任何人
+- **公布的目标集是静态的,而有效模式是按会话的**(schema 是注册表全局的)——已处于最宽模式的会话仍被提供这些字段。构造上无害:执行时的严格放宽检查(而非枚举)是安全边界——同模式请求无需审批,更窄或不支持的目标不提示任何人并失败
 - **授权的升级不等于可工作的沙箱。** 不可用的后端即使对授权升级到约束模式也仍然失败关闭——平台没有链或所有探测失败时在 `confine()` 阶段失败,所选可执行文件无法启动时通过 spawn 通道失败,已启动的 runner 拒绝时则通过结构化规则失败——而授权的 `danger-full-access` 运行根本不触及提供方:此时授权(而非探测)是权威。
 - **运行时上下文历史仅追加。** 策略切换会在保留的历史之后追加一份用于取代先前快照的完整快照,从而保留稳定的系统与对话前缀;状态不变时不添加消息。
 - **旧策略快照仍保留在历史中。** 每份完整快照都会明确取代更早的运行时上下文快照,因此回放与压缩(compaction)只需保留最新具体化的消息。

+ 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 决策继续独立有效,不被浏览器终端取代。
 

+ 6 - 0
.agents/notes/implemented/feature/2026-09-15-bundled-office-skills.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-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-16-sandbox-same-mode.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-sandbox-same-mode.md
+2026-09-16-sandbox-same-mode.md: f1b7c3ae05103363f47f28fab1ae534a8a8b04c2
+2026-09-16-sandbox-same-mode.zh.md: 0bb04415230ec7a0f0f3fdd3c7f00bd7b76d9d9b

+ 23 - 0
.agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.md

@@ -0,0 +1,23 @@
+# Agent Note: Repeated sandbox modes need no approval
+
+Status: implemented
+
+English | [中文](2026-09-16-sandbox-same-mode.zh.md)
+
+## Problem
+
+Models can repeat `sandbox_permissions: danger-full-access` while that mode is already effective. Rejecting the call prevents authorized work without preventing any permission increase.
+
+## Decision
+
+`approveEscalation` returns the effective mode immediately when the requested mode matches it. Argument pairing remains mandatory at the tool. Wider modes still require approval; narrower and unsupported targets still fail. This partially supersedes the non-widening rejection in the [sandbox decision](2026-07-06-sandbox.md); its confinement and per-call approval decisions remain active.
+
+## Alternatives considered
+
+**Reject every non-widening request.** This makes a redundant argument fatal even though it asks for no additional permission.
+
+**Ask for approval again.** Existing permission is sufficient, so another prompt adds no authorization.
+
+## Consequences
+
+Bash and filesystem tools accept repeated effective modes without an approval service or agent. Shared unit tests cover both advertised targets, both tool consumers execute the repeated mode, and the `fs-same-mode` ACP snapshot verifies an unrestricted write with no approval events and checks the resulting file.

+ 23 - 0
.agents/notes/implemented/feature/2026-09-16-sandbox-same-mode.zh.md

@@ -0,0 +1,23 @@
+# Agent Note: 重复沙箱模式无需审批
+
+Status: implemented
+
+[English](2026-09-16-sandbox-same-mode.md) | 中文
+
+## 问题
+
+模型可能在 `danger-full-access` 已生效时重复传入 `sandbox_permissions: danger-full-access`。拒绝该调用会阻止已获授权的工作,却没有阻止任何权限增加。
+
+## 决策
+
+当请求模式与生效模式相同时,`approveEscalation` 立即返回生效模式。工具仍要求参数成对出现。更宽模式仍需审批;更窄和不支持的目标仍然失败。这部分取代了[沙箱决策](2026-07-06-sandbox.zh.md)中的非放宽请求拒绝规则;其约束与逐调用审批决策仍然有效。
+
+## 考虑过的替代方案
+
+**拒绝所有非放宽请求。** 即使没有请求额外权限,冗余参数也会导致调用失败。
+
+**再次请求审批。** 现有权限已经足够,再次提示不会增加授权。
+
+## 影响
+
+Bash 和文件系统工具无需审批服务或 agent 即可接受重复生效模式。共享单元测试覆盖两个已公布目标,两个工具消费方均执行重复模式,`fs-same-mode` ACP 快照验证没有审批事件的无限制写入并检查生成文件。

+ 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: df2e01dd70b07cfe9c8982874fb337ec976f7b9f
-README.zh.md: 470d21abd4f70b718fac1fd305b38a5448598c54
+README.md: a76e1d0d44f7ef935d9514f5bfd4b271cda6e074
+README.zh.md: 8911afee6a5f1ec9df8c0dabf93aa81f21bc1571

+ 5 - 1
apps/desktop/README.md

@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
 
 The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it.
 
+Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog.
+
 ## Key technical decisions
 
 The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation.
@@ -16,9 +18,11 @@ 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.
 
-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.
+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.
 
 Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](scripts/primary-runtime-lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; pnpm follows the Desktop build dependency lock. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets/<target>/runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit.
 

+ 5 - 1
apps/desktop/README.zh.md

@@ -4,6 +4,8 @@
 
 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。
 
+Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。
+
 ## 关键技术决策
 
 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。
@@ -16,9 +18,11 @@ 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 可能拒绝替换。
 
-该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。
+Desktop 私有的 `runtime/bin` 目录仅添加到包安装进程,不进入 PTC 和 agent shell 从 Host 继承的 PATH。该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。
 
 Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](scripts/primary-runtime-lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;pnpm 使用 Desktop 构建依赖锁。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets/<target>/runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。
 

+ 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.")
 
 

+ 30 - 0
apps/desktop/src/directory-picker.ts

@@ -0,0 +1,30 @@
+/** Window-owned workspace directory dialogs for the local Desktop renderer. */
+
+import { dialog, ipcMain, type BrowserWindow } from 'electron'
+import { DESKTOP_IPC, assertDesktopSender } from './ipc.ts'
+
+/**
+ * Install the application-lifetime directory picker IPC handler.
+ * @param getWindow - Current local application window; shell pages and subframes cannot open dialogs.
+ */
+export function installDesktopDirectoryPicker(getWindow: () => BrowserWindow | undefined): void {
+  const pending = new WeakMap<BrowserWindow, Promise<string | null>>()
+  ipcMain.handle(DESKTOP_IPC.directoryPick, async (event) => {
+    const window = getWindow()
+    if (window === undefined || window.isDestroyed() || event.sender !== window.webContents
+      || event.senderFrame !== window.webContents.mainFrame) {
+      throw new Error('dsh desktop: rejected directory picker from an unowned renderer')
+    }
+    assertDesktopSender(event, ['app'])
+    const existing = pending.get(window)
+    if (existing !== undefined) return existing
+    if (window.isMinimized()) window.restore()
+    window.show()
+    window.focus()
+    const result = dialog.showOpenDialog(window, { properties: ['openDirectory', 'createDirectory'] }).then(
+      ({ canceled, filePaths }) => window.isDestroyed() || canceled ? null : filePaths[0] ?? null,
+    ).finally(() => { pending.delete(window) })
+    pending.set(window, result)
+    return result
+  })
+}

+ 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(

+ 19 - 0
apps/desktop/src/ipc.ts

@@ -1,5 +1,6 @@
 /** Typed preload operations exposed only by the Electron shell. */
 
+import type { IpcMainInvokeEvent } from 'electron'
 import type { DesktopPluginRecord } from './project-manager.ts'
 import type { DesktopLocale } from './locale.ts'
 
@@ -8,6 +9,7 @@ export const DESKTOP_IPC = {
   localeGet: 'dsh-desktop:locale-get',
   boot: 'dsh-desktop:boot',
   bootFailed: 'dsh-desktop:boot-failed',
+  directoryPick: 'dsh-desktop:directory-pick',
   pluginsList: 'dsh-desktop:plugins-list',
   pluginsAdd: 'dsh-desktop:plugins-add',
   pluginsRemove: 'dsh-desktop:plugins-remove',
@@ -43,3 +45,20 @@ export interface DshDesktopApi {
     subscribe(listener: (state: DesktopUpdateState) => void): () => void
   }
 }
+
+/** Scheme of Desktop-owned application and shell documents. */
+export const SCHEME = 'dsh-app'
+
+/**
+ * Reject IPC outside the allowed Desktop document origins.
+ * @param event - IPC caller whose frame URL supplies the origin.
+ * @param hostnames - Desktop document hosts allowed for this operation.
+ */
+export function assertDesktopSender(event: IpcMainInvokeEvent, hostnames: readonly string[]): void {
+  const senderFrame = event.senderFrame
+  if (senderFrame === null) throw new Error('dsh desktop: rejected IPC without a sender frame')
+  const url = new URL(senderFrame.url)
+  if (url.protocol !== `${SCHEME}:` || !hostnames.includes(url.hostname)) {
+    throw new Error('dsh desktop: rejected IPC from an unowned renderer')
+  }
+}

+ 5 - 13
apps/desktop/src/main.ts

@@ -19,16 +19,15 @@ import {
 import { resolveDesktopPaths } from './paths.ts'
 import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts'
 import { DesktopHostProcess } from './host-process.ts'
-import { desktopNodeEnvironment } from './node-environment.ts'
+import { installDesktopDirectoryPicker } from './directory-picker.ts'
 import { DesktopBackendController } from './backend-controller.ts'
-import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts'
+import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
 import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
 import { claimDesktopSingleInstance } from './single-instance.ts'
 import { DesktopUpdateCoordinator } from './update-coordinator.ts'
 import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts'
 import { DesktopFatalRecovery } from './fatal-recovery.ts'
 
-const SCHEME = 'dsh-app'
 let focusPrimaryWindow = (): void => {}
 let stopForRecovery = async (): Promise<void> => {}
 let shuttingDown = false
@@ -159,15 +158,6 @@ function createWindow(preload: string, show = false): BrowserWindow {
   return window
 }
 
-function assertDesktopSender(event: IpcMainInvokeEvent, hostnames: readonly string[]): void {
-  const senderFrame = event.senderFrame
-  if (senderFrame === null) throw new Error('dsh desktop: rejected IPC without a sender frame')
-  const url = new URL(senderFrame.url)
-  if (url.protocol !== `${SCHEME}:` || !hostnames.includes(url.hostname)) {
-    throw new Error('dsh desktop: rejected IPC from an unowned renderer')
-  }
-}
-
 async function serveShellAsset(request: Request): Promise<Response> {
   if (request.method !== 'GET' && request.method !== 'HEAD') return new Response(null, { status: 405 })
   const root = resolve(app.getAppPath(), 'renderer')
@@ -226,7 +216,7 @@ async function main(): Promise<void> {
   const backend = new DesktopBackendController((onFailure) => {
     const hostInspectPort = developmentHostInspectPort(development)
     const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
-      hostInspectPort, desktopNodeEnvironment(resources.node, resources.nodeBin, process.env), onFailure,
+      hostInspectPort, process.env, onFailure,
       development ? join(app.getAppPath(), '.desktop-build', 'targets', `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`, 'runtime', 'primary-runtime')
         : join(process.resourcesPath, 'runtime', 'primary-runtime'),
       development ? 'link' : 'runtime')
@@ -297,6 +287,8 @@ async function main(): Promise<void> {
     return Promise.resolve(new Response(null, { status: 404 }))
   })
 
+  installDesktopDirectoryPicker(() => mainWindow)
+
   ipcMain.handle(DESKTOP_IPC.boot, async (event) => {
     assertDesktopSender(event, ['app'])
     await startup

+ 2 - 3
apps/desktop/src/node-environment.ts

@@ -1,4 +1,4 @@
-/** Electron's Node mode inherited by the Host, pnpm, and their subprocesses. */
+/** Electron Node-mode startup, with private shell launchers scoped to package installation. */
 
 import { delimiter } from 'node:path'
 
@@ -13,7 +13,6 @@ export function desktopNodeEnvironment(executable: string, bin: string | undefin
   return {
     ...environment,
     ELECTRON_RUN_AS_NODE: '1',
-    DSH_DESKTOP_NODE_EXECUTABLE: executable,
-    ...(bin === undefined ? {} : { PATH: `${bin}${delimiter}${environment.PATH ?? ''}` }),
+    ...(bin === undefined ? {} : { DSH_DESKTOP_NODE_EXECUTABLE: executable, PATH: `${bin}${delimiter}${environment.PATH ?? ''}` }),
   }
 }

+ 6 - 3
apps/desktop/src/preload-app.ts

@@ -1,11 +1,14 @@
-/** Context-isolated application boot bridge and desktop carrier marker. */
+/** Context-isolated application boot, local directory picker, and desktop carrier marker. */
 
 import { contextBridge, ipcRenderer } from 'electron'
-import { DESKTOP_IPC } from './ipc.ts'
+import { DESKTOP_IPC, SCHEME } from './ipc.ts'
 import { markDocumentPlatform } from './preload-platform.ts'
 import { syncNativeTheme } from './preload-theme.ts'
 
-if (location.protocol === 'dsh-app:' && location.hostname === 'app') {
+if (location.protocol === `${SCHEME}:` && location.hostname === 'app') {
+  contextBridge.exposeInMainWorld('__DSH_DIRECTORY_PICKER__', {
+    pick: () => ipcRenderer.invoke(DESKTOP_IPC.directoryPick) as Promise<string | null>,
+  })
   contextBridge.exposeInMainWorld('dshDesktopBoot', {
     ready: () => ipcRenderer.invoke(DESKTOP_IPC.boot) as Promise<unknown>,
     failed: (message: string) => ipcRenderer.invoke(DESKTOP_IPC.bootFailed, message) as Promise<void>,

+ 82 - 0
apps/desktop/tests/directory-picker.spec.ts

@@ -0,0 +1,82 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { BrowserWindow, IpcMainInvokeEvent, OpenDialogReturnValue } from 'electron'
+import { DESKTOP_IPC } from '../src/ipc.ts'
+
+const electron = vi.hoisted(() => ({
+  handle: vi.fn<(channel: string, handler: (event: IpcMainInvokeEvent) => Promise<string | null>) => void>(),
+  showOpenDialog: vi.fn<(window: BrowserWindow, options: unknown) => Promise<OpenDialogReturnValue>>(),
+}))
+vi.mock('electron', () => ({ ipcMain: { handle: electron.handle }, dialog: electron }))
+const { installDesktopDirectoryPicker } = await import('../src/directory-picker.ts')
+
+beforeEach(() => { vi.resetAllMocks() })
+
+function fixture() {
+  const frame = { url: 'dsh-app://app/' }
+  const window = {
+    webContents: { mainFrame: frame },
+    isDestroyed: vi.fn(() => false),
+    isMinimized: vi.fn(() => false),
+    restore: vi.fn(),
+    show: vi.fn(),
+    focus: vi.fn(),
+  }
+  let current: BrowserWindow | undefined = window as unknown as BrowserWindow
+  installDesktopDirectoryPicker(() => current)
+  expect(electron.handle.mock.calls[0]?.[0]).toBe(DESKTOP_IPC.directoryPick)
+  const handler = electron.handle.mock.calls[0]![1]
+  const event = { sender: window.webContents, senderFrame: frame } as unknown as IpcMainInvokeEvent
+  return { window, frame, event, handler, detach: () => { current = undefined } }
+}
+
+describe('Desktop directory picker', () => {
+  it('restores and focuses the parent window and shares an unanswered dialog', async () => {
+    const f = fixture()
+    f.window.isMinimized.mockReturnValue(true)
+    let settle!: (value: OpenDialogReturnValue) => void
+    electron.showOpenDialog.mockImplementation(() => new Promise((resolve) => { settle = resolve }))
+    const first = f.handler(f.event)
+    const second = f.handler(f.event)
+    expect(electron.showOpenDialog).toHaveBeenCalledExactlyOnceWith(f.window, { properties: ['openDirectory', 'createDirectory'] })
+    expect(f.window.restore).toHaveBeenCalledOnce()
+    expect(f.window.show).toHaveBeenCalledOnce()
+    expect(f.window.focus).toHaveBeenCalledOnce()
+    settle({ canceled: false, filePaths: ['/workspace'] })
+    await expect(Promise.all([first, second])).resolves.toEqual(['/workspace', '/workspace'])
+    electron.showOpenDialog.mockResolvedValue({ canceled: true, filePaths: [] })
+    await expect(f.handler(f.event)).resolves.toBeNull()
+    expect(electron.showOpenDialog).toHaveBeenCalledTimes(2)
+  })
+
+  it('releases a failed dialog so the user can retry', async () => {
+    const f = fixture()
+    electron.showOpenDialog.mockRejectedValueOnce(new Error('chooser failed'))
+    await expect(f.handler(f.event)).rejects.toThrow('chooser failed')
+    electron.showOpenDialog.mockResolvedValue({ canceled: false, filePaths: [] })
+    await expect(f.handler(f.event)).resolves.toBeNull()
+  })
+
+  it('discards the selected path after the parent is destroyed', async () => {
+    const f = fixture()
+    electron.showOpenDialog.mockImplementation(async () => {
+      f.window.isDestroyed.mockReturnValue(true)
+      return { canceled: false, filePaths: ['/workspace'] }
+    })
+    await expect(f.handler(f.event)).resolves.toBeNull()
+  })
+
+  it('rejects other windows, subframes, shell pages, remote pages, and missing windows', async () => {
+    const f = fixture()
+    await expect(f.handler({ ...f.event, sender: {} } as IpcMainInvokeEvent)).rejects.toThrow('unowned renderer')
+    await expect(f.handler({ ...f.event, senderFrame: {} } as IpcMainInvokeEvent)).rejects.toThrow('unowned renderer')
+    for (const url of ['dsh-app://shell/startup.html', 'https://example.com/', 'http://127.0.0.1/']) {
+      f.frame.url = url
+      await expect(f.handler(f.event)).rejects.toThrow('unowned renderer')
+    }
+    f.window.isDestroyed.mockReturnValue(true)
+    await expect(f.handler(f.event)).rejects.toThrow('unowned renderer')
+    f.detach()
+    await expect(f.handler(f.event)).rejects.toThrow('unowned renderer')
+    expect(electron.showOpenDialog).not.toHaveBeenCalled()
+  })
+})

+ 23 - 2
apps/desktop/tests/main-startup.spec.ts

@@ -1,4 +1,5 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { IpcMainInvokeEvent } from 'electron'
 import { join } from 'node:path'
 import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
 import { DESKTOP_IPC } from '../src/ipc.ts'
@@ -29,6 +30,7 @@ const harness = await vi.hoisted(async () => {
     readonly urls: string[] = []
     readonly webContents = Object.assign(new EventEmitter(), {
       id: 42,
+      mainFrame: { url: 'dsh-app://app/' },
       setWindowOpenHandler: vi.fn(),
       openDevTools: vi.fn(),
       getURL: () => this.urls.at(-1) ?? '',
@@ -87,7 +89,11 @@ const harness = await vi.hoisted(async () => {
     popup,
     socketHeaders: vi.fn(),
     menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn(() => ({ popup })) },
-    dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn<(options: MessageBoxOptions) => Promise<MessageBoxReturnValue>>() },
+    dialog: {
+      showOpenDialog: vi.fn(),
+      showErrorBox: vi.fn(),
+      showMessageBox: vi.fn<(options: MessageBoxOptions) => Promise<MessageBoxReturnValue>>(),
+    },
     openExternal: vi.fn(),
     applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
     mutateFailure: vi.fn<() => void>(),
@@ -118,7 +124,7 @@ vi.mock('electron', () => ({
   nativeTheme: { themeSource: 'system' },
   ipcMain: {
     on: vi.fn(),
-    handle: (channel: string, handler: (event: { senderFrame: { url: string } }) => unknown) => { harness.handlers.set(channel, handler) },
+    handle: (channel: string, handler: (event: { senderFrame: { url: string } }) => unknown) => { if (harness.handlers.has(channel)) throw new Error(`duplicate IPC handler ${channel}`); harness.handlers.set(channel, handler) },
   },
   Menu: harness.menu,
   session: { defaultSession: { webRequest: { onBeforeSendHeaders: harness.socketHeaders } } },
@@ -220,6 +226,20 @@ describe('desktop main startup', () => {
     expect(callback).toHaveBeenLastCalledWith({})
   })
 
+  it('registers the window-owned directory picker during startup and rejects foreign callers', async () => {
+    await import('../src/main.ts')
+    await harness.preparing.promise
+    const window = harness.windows[0]!
+    const handler = harness.handlers.get(DESKTOP_IPC.directoryPick) as (event: IpcMainInvokeEvent) => Promise<string | null>
+    expect(handler).toBeTypeOf('function')
+    const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame } as unknown as IpcMainInvokeEvent
+    harness.dialog.showOpenDialog.mockResolvedValue({ canceled: false, filePaths: ['/workspace'] })
+    await expect(handler(event)).resolves.toBe('/workspace')
+    expect(harness.dialog.showOpenDialog).toHaveBeenCalledExactlyOnceWith(window, { properties: ['openDirectory', 'createDirectory'] })
+    window.webContents.mainFrame.url = 'https://other.example/'
+    await expect(handler(event)).rejects.toThrow('unowned renderer')
+  })
+
   it('holds boot injections until the Host is ready and rejects foreign boot callers', async () => {
     await import('../src/main.ts')
     await harness.preparing.promise
@@ -456,6 +476,7 @@ describe('desktop main startup', () => {
       profileResolution: 'runtime',
       profile: 'desktop-test-profile',
     })
+    expect(harness.hosts[0]!.environment).toBe(process.env)
     expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1)
     expect(harness.windows).toHaveLength(1)
     expect(window.urls).toEqual(['dsh-app://app/'])

+ 19 - 0
apps/desktop/tests/node-environment.spec.ts

@@ -0,0 +1,19 @@
+import { delimiter } from 'node:path'
+import { expect, it } from 'vitest'
+import { desktopNodeEnvironment } from '../src/node-environment.ts'
+
+it('keeps private Desktop launchers out of the Host environment inherited by PTC', () => {
+  const environment = { PATH: '/user/bin', HOME: '/user' }
+  expect(desktopNodeEnvironment('/desktop/electron', undefined, environment)).toEqual({
+    ...environment, ELECTRON_RUN_AS_NODE: '1',
+  })
+  expect(environment).toEqual({ PATH: '/user/bin', HOME: '/user' })
+})
+
+it('provides private launchers only to package installation processes', () => {
+  expect(desktopNodeEnvironment('/desktop/electron', '/desktop/bin', { PATH: '/user/bin' })).toEqual({
+    ELECTRON_RUN_AS_NODE: '1',
+    DSH_DESKTOP_NODE_EXECUTABLE: '/desktop/electron',
+    PATH: `/desktop/bin${delimiter}/user/bin`,
+  })
+})

+ 17 - 1
apps/desktop/tests/preload-app.spec.ts

@@ -11,7 +11,7 @@ vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() }))
 
 afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() })
 
-it.each(['dsh-app://app/index.html', 'dsh-app://shell/plugin-manager.html'])('exposes only the carrier marker to %s', async (url) => {
+it.each(['dsh-app://app/index.html', 'dsh-app://shell/plugin-manager.html'])('exposes the carrier marker to %s', async (url) => {
   vi.stubGlobal('location', new URL(url))
   await import('../src/preload-app.ts')
   expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 })
@@ -31,3 +31,19 @@ it('exposes asynchronous boot only to the local application document', async ()
   await import('../src/preload-app.ts')
   expect(electron.contextBridge.exposeInMainWorld.mock.calls.some(([name]) => name === 'dshDesktopBoot')).toBe(false)
 })
+
+it('exposes a directory picker only to the local application document', async () => {
+  vi.stubGlobal('location', new URL('dsh-app://app/'))
+  await import('../src/preload-app.ts')
+  const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === '__DSH_DIRECTORY_PICKER__')?.[1] as { pick(): Promise<string | null> }
+  electron.ipcRenderer.invoke.mockResolvedValue('/workspace')
+  await expect(api.pick()).resolves.toBe('/workspace')
+  expect(electron.ipcRenderer.invoke).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.directoryPick)
+  for (const url of ['dsh-app://shell/startup.html', 'https://example.com/']) {
+    vi.resetModules()
+    electron.contextBridge.exposeInMainWorld.mockClear()
+    vi.stubGlobal('location', new URL(url))
+    await import('../src/preload-app.ts')
+    expect(electron.contextBridge.exposeInMainWorld.mock.calls.some(([name]) => name === '__DSH_DIRECTORY_PICKER__')).toBe(false)
+  }
+})

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

+ 50 - 0
apps/desktop/tests/ptc-runtime.spec.ts

@@ -0,0 +1,50 @@
+/** Desktop owns the Electron devDependency; the PTC helper supplies its real runtime providers. */
+
+import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
+import { existsSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { homedir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { Context } from '@deepseek-ai/cordis'
+import { expect, it, onTestFinished, vi } from 'vitest'
+import { mountRuntime } from '../../../packages/ptc-runtime/ptc-runtime-node/tests/setup.ts'
+
+const require = createRequire(import.meta.url)
+const electronInstalled = existsSync(join(dirname(require.resolve('electron')), 'path.txt'))
+
+// Both fixture-owned and shared-provider teardown await native process cleanup.
+vi.setConfig({ hookTimeout: 30_000 })
+
+// Desktop's downloaded Electron binary is not installed by ordinary workspace dependency setup.
+it.skipIf(process.platform !== 'darwin' || !electronInstalled).each(['workspace-write', 'danger-full-access'] as const)('runs PTC writes under Electron with %s', { timeout: 120_000 }, async (mode) => {
+  const electron = require('electron') as string
+  const root = await mkdtemp(join(homedir(), '.dsh-electron-ptc-'))
+  const ctx = new Context()
+  onTestFinished(async () => {
+    try { await ctx.fiber.dispose() } finally {
+      vi.unstubAllEnvs()
+      await rm(root, { recursive: true, force: true })
+    }
+  })
+  const cwd = join(root, 'workspace')
+  await mkdir(cwd)
+  vi.stubEnv('ELECTRON_RUN_AS_NODE', '1')
+  vi.stubEnv('DSH_TEST_RUNTIME_SECRET', 'must-not-inherit')
+  const runtime = await mountRuntime(ctx, { nodeExecutable: electron }, { mode, workspaceRoot: cwd })
+  const result = await runtime.run(runtime.resolve({
+    // Bound startup failure before the outer test deadline, leaving time for managed cleanup.
+    timeoutMs: 30_000,
+    program: `await tools.write({});
+      const fs = await import('node:fs/promises');
+      await fs.writeFile('direct.txt', 'direct');
+      let outside;
+      try { await fs.writeFile('../outside.txt', 'outside'); outside = true } catch (error) { if (error.code !== 'EPERM' && error.code !== 'EACCES') throw error; outside = false }
+      return { electron: Boolean(process.versions.electron), env: { ...process.env }, outside };`,
+    bindings: [{ global: 'tools', functions: { write: async () => { await writeFile(join(cwd, 'note.txt'), 'hello'); return null } } }],
+  }))
+  expect(result.error).toBeUndefined()
+  expect(result.value).toEqual({ electron: true, env: {}, outside: mode === 'danger-full-access' })
+  expect(await readFile(join(cwd, 'note.txt'), 'utf8')).toBe('hello')
+  expect(await readFile(join(cwd, 'direct.txt'), 'utf8')).toBe('direct')
+  expect(existsSync(join(root, 'outside.txt'))).toBe(mode === 'danger-full-access')
+})

+ 9 - 9
apps/web/tests/expected/plugin-manager/live-enabled.expected.md

@@ -8,17 +8,17 @@
 - text: "3"
 - list:
   - listitem:
-    - button "查看 experimental-agent-team-profile": experimental-agent-team-profile
-    - text: 官方 Experimental profile bundle enabling Agent Teams over dsh-base
-    - switch "启用 experimental-agent-team-profile"
+    - button "查看 智能体团队(实验性)": 智能体团队(实验性)
+    - text: 官方 启用智能体团队协作与团队工具。
+    - switch "启用 智能体团队(实验性)"
   - listitem:
-    - button "查看 experimental-agent-team-web-profile": experimental-agent-team-web-profile
-    - text: 官方 Experimental Web profile layer for Agent Teams Remote and UI plugins
-    - switch "启用 experimental-agent-team-web-profile"
+    - button "查看 智能体团队 Web 界面(实验性)": 智能体团队 Web 界面(实验性)
+    - text: 官方 在浏览器中查看团队成员、任务看板和成员会话。
+    - switch "启用 智能体团队 Web 界面(实验性)"
   - listitem:
-    - button "查看 experimental-auto-review": experimental-auto-review
-    - text: 官方 Per-tool LLM authorization review for the DeepSeek Harness Auto permission preset
-    - switch "启用 experimental-auto-review"
+    - button "查看 自动授权审查(实验性)": 自动授权审查(实验性)
+    - text: 官方 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
+    - switch "启用 自动授权审查(实验性)"
 - heading "已安装" [level=3]
 - text: "1"
 - list:

+ 9 - 9
apps/web/tests/expected/plugin-manager/manager.expected.md

@@ -8,17 +8,17 @@
 - text: "3"
 - list:
   - listitem:
-    - button "查看 experimental-agent-team-profile": experimental-agent-team-profile
-    - text: 官方 Experimental profile bundle enabling Agent Teams over dsh-base
-    - switch "启用 experimental-agent-team-profile"
+    - button "查看 智能体团队(实验性)": 智能体团队(实验性)
+    - text: 官方 启用智能体团队协作与团队工具。
+    - switch "启用 智能体团队(实验性)"
   - listitem:
-    - button "查看 experimental-agent-team-web-profile": experimental-agent-team-web-profile
-    - text: 官方 Experimental Web profile layer for Agent Teams Remote and UI plugins
-    - switch "启用 experimental-agent-team-web-profile"
+    - button "查看 智能体团队 Web 界面(实验性)": 智能体团队 Web 界面(实验性)
+    - text: 官方 在浏览器中查看团队成员、任务看板和成员会话。
+    - switch "启用 智能体团队 Web 界面(实验性)"
   - listitem:
-    - button "查看 experimental-auto-review": experimental-auto-review
-    - text: 官方 Per-tool LLM authorization review for the DeepSeek Harness Auto permission preset
-    - switch "启用 experimental-auto-review"
+    - button "查看 自动授权审查(实验性)": 自动授权审查(实验性)
+    - text: 官方 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
+    - switch "启用 自动授权审查(实验性)"
 - heading "已安装" [level=3]
 - text: "1"
 - list:

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

+ 38 - 0
apps/web/tests/plugin-manager.e2e.ts

@@ -88,6 +88,44 @@ describe('web e2e: plugin manager', () => {
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
+  it('updates built-in names and descriptions when the UI language changes', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-locale'))
+    const panel = await openPluginsPanel()
+    await panel.getByRole('button', { name: '查看 智能体团队(实验性)' }).click()
+    const packageName = panel.locator('[data-plugin-name]')
+    expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile')
+    expect(await panel.getByText('启用智能体团队协作与团队工具。').count()).toBe(1)
+    try {
+      await page.getByRole('button', { name: '设置', exact: true }).click()
+      await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '中文' }).click()
+      await page.getByRole('menuitem', { name: 'English' }).click()
+      await page.getByRole('dialog', { name: 'Settings' }).waitFor()
+      await page.keyboard.press('Escape')
+      await panel.getByRole('heading', { name: 'Experimental Agent Teams', exact: true }).waitFor()
+      expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile')
+      expect(await panel.getByText('Enable agent team collaboration and team tools.').count()).toBe(1)
+      await panel.getByRole('button', { name: 'Back to plugins' }).click()
+      for (const title of ['Experimental Agent Teams', 'Experimental Agent Teams Web UI', 'Experimental Auto Authorization Review']) {
+        await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor()
+        expect(await panel.getByRole('switch', { name: `Enable ${title}`, exact: true }).count()).toBe(1)
+      }
+      expect(await panel.getByText('View team members, the task board, and teammate sessions in the browser.').count()).toBe(1)
+      expect(await panel.getByText('Add an Auto review permission mode that uses the model to assess authorization before each tool call.').count()).toBe(1)
+    } finally {
+      if (await page.locator('html').getAttribute('lang') === 'en') {
+        if (await page.getByRole('dialog', { name: 'Settings' }).count() === 0) {
+          await page.getByRole('button', { name: 'Settings', exact: true }).click()
+        }
+        await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click()
+        await page.getByRole('menuitem', { name: '中文' }).click()
+        await page.getByRole('dialog', { name: '设置' }).waitFor()
+      }
+      await closeSettings()
+    }
+    await panel.getByRole('button', { name: '查看 智能体团队(实验性)', exact: true }).waitFor()
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
   it('checks a spec before installing it and words what the check refused', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-install'))
     const panel = await openPluginsPanel()

+ 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: 929173ab44c9f4cb4dbff8871fb473d9abff0141
-capability-seams.zh.md: f0526445a86fb0b0875bbca1382607c28b9db867
+capability-seams.md: 6b0b48b86cc5413fdc122ce3225577f86cbd6001
+capability-seams.zh.md: 7d714141a5e8deb4b7be0ca176db520a58a48832

+ 3 - 1
docs/capability-seams.md

@@ -147,6 +147,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"]
@@ -349,6 +350,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) | - | - | - | 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

@@ -149,6 +149,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"]
@@ -351,6 +352,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
@@ -578,7 +580,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: ffe4d39af3a87b76d4bc6100c9575d8d6975696d
-config-catalog.zh.md: 62031ac825baf929b4465a1c608978558facf3c8
+config-catalog.md: a9885dec32cf39a260960559d628b9c1ce219c1a
+config-catalog.zh.md: 481aa16a57f6e8828809a2956bcc04d573c6cabe

+ 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>
 
@@ -2452,6 +2452,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>
 
@@ -2454,6 +2454,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: db0f7d4bbe6a0e3a513863fb2d16f56cdeec0488
-event-producer-consumer.zh.md: e0f8db40dad9be12c0568e151dc0f40342f71b6d
+event-producer-consumer.md: 7997fd2ed50db0e375ea0c40eb76cf402494bb06
+event-producer-consumer.zh.md: 2c3ddb928cae1a1e53822aaacdd09ed3ab2c2d46

+ 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: 719273c14784fcf5dd98b2fddabfb7666c666139
-module-graph.zh.md: b1098cc39fdc1d44f9d7edc3555d18a2f2c26bc5
+module-graph.md: ae72394aa8ab8cf0d00d9d6cf707a17281292517
+module-graph.zh.md: 65fac7e5ccf176d65a2b600c8652820c95884788

+ 3 - 0
docs/module-graph.md

@@ -64,6 +64,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"]
@@ -456,6 +457,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
@@ -1418,6 +1420,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

@@ -66,6 +66,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"]
@@ -458,6 +459,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
@@ -1420,6 +1422,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: 99c24562c178b35944e5616729076d015eb3f4c6
-persistence-catalog.zh.md: a2c9590a799a1bd63745afaaad989be051015ae0
+persistence-catalog.md: c01193930901e1b01def017ee5529b0e66efa5c1
+persistence-catalog.zh.md: fe11795b919e1f88cfc358896fbceb1388e22edc

+ 3 - 3
docs/persistence-catalog.md

@@ -1784,9 +1784,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`
 
@@ -5140,7 +5140,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)

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

@@ -1786,9 +1786,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`
 
@@ -5142,7 +5142,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)

+ 1 - 1
docs/persistence-schema.json

@@ -37978,7 +37978,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"

+ 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: cf3cf088e5d2ac521df22507bbb41ae3b3be36f0
-workspace.zh.md: 83a9411aaf52559e300009041d7e666a02260ad0
+workspace.md: 50db26be228bf39880891c3c4ca7d47c097b9d3e
+workspace.zh.md: fd4110041fe1b22e580b17835e4a896b96d062c8

+ 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 - 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/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/README.md
-README.md: 42b9fc8bf7592a9e9d47281cf11c602a09028136
-README.zh.md: 5e135c26ec37579026352402b73cc021eafcbc68
+README.md: 6df95c24c8e9a70f8beb0e1cdd066c9f0facae2b
+README.zh.md: 12024690b09e4a013dae3b6dcffa58e2040d9251

+ 1 - 1
packages/client/README.md

@@ -77,7 +77,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
 | [`ui-deliverables/`](ui-deliverables/README.md) | Produces the produced-files turn tail and clickable final-response file references | — |
 | [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind both ratings and `/feedback` | — |
 | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — |
-| [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the host's OS chooser | — |
+| [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the local Desktop or Host OS chooser | — |
 | [`ui-open-in-app/`](ui-open-in-app/README.md) | Session-header split button opening the workspace directory in an installed application | — |
 
 -----

+ 1 - 1
packages/client/README.zh.md

@@ -77,7 +77,7 @@ kind: "package-group"
 | [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成已产出文件的轮次尾部与可点击的最终响应文件引用 | — |
 | [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点赞、点踩与 `/feedback` 背后的反馈弹窗 | — |
 | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — |
-| [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动宿主 OS 选择器的原生目录选择界面 | — |
+| [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动本地 Desktop 或 Host OS 选择器的原生目录选择界面 | — |
 | [`ui-open-in-app/`](ui-open-in-app/README.zh.md) | 在已安装应用中打开工作区目录的会话标题栏拆分按钮 | — |
 
 -----

+ 2 - 2
packages/client/ui-directory-picker-native/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-directory-picker-native/README.md
-README.md: 4d35b30e093f06e939d0831a1ea45c050c43bf03
-README.zh.md: 98bdf1b15eea5de9dee2f0a7dab583f0f124b504
+README.md: 73115c441399fca56d0fd208307240226faf653a
+README.zh.md: 5cf92adfb43eb3b0e3b7cc4e441b503e9a0757af

+ 8 - 5
packages/client/ui-directory-picker-native/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Native directory-picker surface: the browser half that drives the host OS chooser for workspace-directory flows; for users and maintainers choosing a picking interaction."
+description: "Native directory-picker surface: the browser half that drives the local Desktop or Host OS chooser for workspace-directory flows; for users and maintainers choosing a picking interaction."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This package provides the native directory-picking surface for the Web GUI: when a workspace flow asks for a directory, a renderless browser occupant opens the operating system's own chooser on the machine running the Host and reports the single outcome — a picked path, a cancellation, or a failure. It fills the two directory-flow slots declared by `ui-workspace`, composing the client side of the native picking interaction in one `cordis.yml` row. Choose it when the browser runs on the same machine as the Host; in-process and remote-browser deployments need the [`-browse`](../ui-directory-picker-browse/README.md) surface instead.
+This package provides the native directory-picking surface for the Web GUI: when a workspace flow asks for a directory, a renderless browser occupant opens the operating system's own chooser on the local machine and reports the single outcome — a picked path, a cancellation, or a failure. It fills the two directory-flow slots declared by `ui-workspace`, composing the client side of the native picking interaction in one `cordis.yml` row. Choose it when the browser runs on the same machine as the Host; in-process and remote-browser deployments need the [`-browse`](../ui-directory-picker-browse/README.md) surface instead.
 
 ## Table of Contents
 
@@ -27,6 +27,8 @@ This package provides the native directory-picking surface for the Web GUI: when
 
 Mount this plugin alongside `ui-workspace` and the host backend [`dsh-host-directory-picker-native`](../../host/directory-picker-native/README.md); one `cordis.yml` row then composes the whole native picking interaction. When a workspace add or picker flow opens a directory request, the user sees the operating system's folder dialog; the picked path is adopted by the workspace flow, and cancelling closes the dialog.
 
+In the local Electron application, this flow uses the narrow preload directory-picker bridge. Cancellation and failure never retry through the Host chooser. Ordinary Web uses the Host call; the separate browse composition always lists Host directories.
+
 ### When to choose it
 
 Choose this surface when the browser runs on the same machine as the Host, so an OS dialog can open there. Choose the [`-browse`](../ui-directory-picker-browse/README.md) surface when the browser is remote or in-process and no local chooser exists. The two surfaces fill the same slots, so switching is a composition change, not a code change.
@@ -39,7 +41,7 @@ Choose this surface when the browser runs on the same machine as the Host, so an
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-Both slot registrations install as one transactional effect through nested `ctx.slots.inject()` calls, because either declaring entry may activate later or replace its declaration. The occupant arms once per rising `open` edge, so re-renders never launch a second chooser; settlements ride a ref so the answer reaches the owner's latest handlers. An unmount (HMR replacing the occupant) discards the settlement wholesale: the wire carries no per-request abort, so the host-side chooser survives until answered and its answer lands nowhere. The node half is an empty `apply` that keeps the plugin on the host roster.
+Both slot registrations install as one transactional effect through nested `ctx.slots.inject()` calls, because either declaring entry may activate later or replace its declaration. The occupant arms once per rising `open` edge, so re-renders never launch a second chooser; settlements ride a ref so the answer reaches the owner's latest handlers. An unmount (HMR replacing the occupant) discards the settlement wholesale: the wire carries no per-request abort, so the native chooser survives until answered and its answer lands nowhere. The node half is an empty `apply` that keeps the plugin on the host roster.
 
 </details>
 
@@ -73,8 +75,9 @@ None; this package neither assembles nor sends a provider request.
 
 These limits define when the native chooser fits. They are current package constraints, not a general picker comparison or a task backlog.
 
-- **No cancellation of an open chooser** — the wire has no per-request abort, so a chooser already on the host display cannot be closed from the browser; a discarded settlement is ignored.
-- **Local Host carriers only** — an OS dialog opens on the machine running the Host, so in-process and remote-browser deployments need the `-browse` composition instead. Platform failures surface through the owner's retryable folder dialog.
+- **No cancellation of an open chooser** — the wire has no per-request abort, so a chooser already on the local display cannot be closed from the browser; a discarded settlement is ignored.
+- **Local carriers only** — the Electron dialog selects local paths; ordinary Web opens the Host chooser. Remote-browser and in-process deployments use the `-browse` composition. Platform failures surface through the owner's retryable folder dialog.
+- **Linux automatic selection** — without zenity or kdialog, the Host selects browse even in Desktop; the Electron dialog is not used.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 7 - 4
packages/client/ui-directory-picker-native/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "原生目录选择表面:驱动 Host 操作系统选择器的浏览器半部,用于工作区目录流程;供选择拾取交互的用户与维护者阅读。"
+description: "原生目录选择表面:驱动本地 Desktop 或 Host 操作系统选择器的浏览器半部,用于工作区目录流程;供选择拾取交互的用户与维护者阅读。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本包提供 Web GUI 的原生目录拾取表面:当工作区流程请求一个目录时,一个无渲染的浏览器填充会在运行 Host 的机器上打开操作系统自带的选择器,并回报唯一结果——拾取的路径、取消或失败。它填充 `ui-workspace` 声明的两个目录流程 slot,用一行 `cordis.yml` 组合出原生拾取交互的客户端一侧。当浏览器与 Host 运行在同一台机器上时选择它;进程内与远程浏览器部署则需要 [`-browse`](../ui-directory-picker-browse/README.zh.md) 表面。
+本包提供 Web GUI 的原生目录拾取表面:当工作区流程请求一个目录时,一个无渲染的浏览器填充会在本地机器上打开操作系统自带的选择器,并回报唯一结果——拾取的路径、取消或失败。它填充 `ui-workspace` 声明的两个目录流程 slot,用一行 `cordis.yml` 组合出原生拾取交互的客户端一侧。当浏览器与 Host 运行在同一台机器上时选择它;进程内与远程浏览器部署则需要 [`-browse`](../ui-directory-picker-browse/README.zh.md) 表面。
 
 ## 目录
 
@@ -27,6 +27,8 @@ kind: "package-reference"
 
 与 `ui-workspace` 及 Host 后端 [`dsh-host-directory-picker-native`](../../host/directory-picker-native/README.zh.md) 一起挂载本插件;一行 `cordis.yml` 随即组合出完整的原生拾取交互。当工作区添加或选择器流程发起目录请求时,用户看到操作系统的文件夹对话框;拾取的路径被工作区流程采纳,取消则关闭对话框。
 
+在本地 Electron 应用中,此流程使用 preload 提供的窄目录选择接口。取消和失败都不会改用 Host 选择器重试。普通 Web 使用 Host 调用;独立的浏览组合始终列出 Host 目录。
+
 ### 何时选择
 
 当浏览器与 Host 运行在同一台机器上、操作系统对话框可以在那里打开时,选择此表面。当浏览器为远程或进程内、没有本地选择器时,选择 [`-browse`](../ui-directory-picker-browse/README.zh.md) 表面。两个表面填充相同的 slot,因此切换只是组合改动,而非代码改动。
@@ -73,8 +75,9 @@ kind: "package-reference"
 
 这些限制界定了原生选择器的适用时机。它们是当前包约束,不是通用选择器对比或任务积压。
 
-- **无法取消已打开的选择器**——wire 没有按请求中止的机制,因此已显示在 Host 上的选择器无法从浏览器关闭;被丢弃的结算会被忽略。
-- **仅限本地 Host 承载**——操作系统对话框在运行 Host 的机器上打开,因此进程内与远程浏览器部署需要 `-browse` 组合。平台失败经由持有方的可重试文件夹对话框呈现。
+- **无法取消已打开的选择器**——wire 没有按请求中止的机制,因此已显示在本地的选择器无法从浏览器关闭;被丢弃的结算会被忽略。
+- **仅限本地承载**——Electron 对话框选择本地路径;普通 Web 打开 Host 选择器。远程浏览器与进程内部署使用 `-browse` 组合。平台失败经由持有方的可重试文件夹对话框呈现。
+- **Linux 自动选择**——缺少 zenity 或 kdialog 时,Host 即使在 Desktop 中也选择浏览模式,不使用 Electron 对话框。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 2 - 1
packages/client/ui-directory-picker-native/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-directory-picker-native",
-  "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser",
+  "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the local Desktop or Host OS chooser",
   "version": "0.1.6-alpha.1",
   "publishConfig": {
     "access": "public"
@@ -43,6 +43,7 @@
     "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
     "@testing-library/react": "^16.1.0",

+ 4 - 4
packages/client/ui-directory-picker-native/src/client/flow.ts

@@ -8,9 +8,9 @@ import type { ReactElement } from 'react'
 // Type-only: the owner contract of the directory-flow holes.
 import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
 
-/** Injected face: the wire call the flow drives (bound in apply's closure). */
+/** Injected face: the native chooser call the flow drives (bound in apply's closure). */
 export interface NativeFlowInjected {
-  /** Ask the local Host to open its native single-directory chooser. */
+  /** Open the local desktop or Host single-directory chooser. */
   pick: () => Promise<string | null>
 }
 
@@ -31,11 +31,11 @@ export function NativeDirectoryFlow(props: DirectoryFlowOwnerProps & NativeFlowI
   outcome.current = props
   // Unmount (HMR replacing the occupant) discards settlements wholesale: the
   // dead instance must neither adopt a path nor drive the owner's error
-  // surface. The wire carries no per-request abort, so the host-side chooser
+  // surface. The wire carries no per-request abort, so the native chooser
   // survives until answered — its answer just lands nowhere; the replacement
   // instance re-arms under the owner's still-open request. An injected-face
   // identity change alone (re-registration) keeps the pending settlement:
-  // the chooser on the host display is still the same dialog.
+  // the native chooser is still the same dialog.
   const alive = useRef(true)
   useEffect(() => {
     // StrictMode's development replay runs the cleanup once before the real

+ 6 - 10
packages/client/ui-directory-picker-native/src/client/index.ts

@@ -1,12 +1,4 @@
-/**
- * Browser half of the native directory-picker backend: fills ui-workspace's
- * two directory-flow holes with a renderless occupant that answers each
- * `open` by driving `directoryPicker/pick` (the node half's OS chooser) and
- * reporting the one outcome — picked path, cancellation, or failure — back
- * through the owner conversation. Mounting this package therefore composes
- * both sides of the native interaction with one cordis.yml row; no client
- * code branches on a capability kind.
- */
+/** Native directory flow using the local desktop bridge or the Host's OS chooser. */
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 // Type-only: pulls the SlotMap merge declaring the directory-flow holes.
 import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
@@ -26,7 +18,11 @@ export const inject = ['slots', 'uiWorkspace']
  * @param ctx - client root context.
  */
 export function apply(ctx: ClientContext): void {
-  const injected = (): NativeFlowInjected => ({ pick: () => ctx.uiWorkspace.pickDirectory() })
+  const desktop = (globalThis as typeof globalThis & {
+    __DSH_DIRECTORY_PICKER__?: NativeFlowInjected
+  }).__DSH_DIRECTORY_PICKER__
+  const pick = desktop === undefined ? () => ctx.uiWorkspace.pickDirectory() : () => desktop.pick()
+  const injected = (): NativeFlowInjected => ({ pick })
   // Both declaration lifetimes must be live before the pair installs; the
   // generator makes the two registrations one transactional effect. The
   // outer/inner nesting order is arbitrary; neither hole has precedence.

+ 58 - 1
packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx

@@ -9,7 +9,21 @@ import { apply, inject } from '../src/client/index.ts'
 import { NativeDirectoryFlow } from '../src/client/flow.ts'
 import { apply as nodeApply } from '../src/index.ts'
 
-afterEach(cleanup)
+const desktopIpc = await vi.hoisted(async () => {
+  const { createRequire } = await import('node:module')
+  const path = await import('node:path')
+  // Electron belongs to the Desktop app; resolve its mock from that workspace.
+  const electron = createRequire(path.resolve(import.meta.dirname, '../../../../apps/desktop/package.json')).resolve('electron')
+  return { invoke: vi.fn(), electron }
+})
+vi.mock(desktopIpc.electron, () => ({
+  ipcRenderer: { invoke: desktopIpc.invoke },
+  contextBridge: { exposeInMainWorld: (name: string, value: unknown) => { vi.stubGlobal(name, value) } },
+}))
+vi.mock('../../../../apps/desktop/src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn() }))
+vi.mock('../../../../apps/desktop/src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() }))
+
+afterEach(() => { cleanup(); vi.unstubAllGlobals() })
 
 const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const
 
@@ -149,6 +163,49 @@ describe('directory-picker-native client half', () => {
     expect(b.pickDirectory).toHaveBeenCalledOnce()
   })
 
+  it('consumes the actual Desktop preload bridge and sends its directory-pick IPC', async () => {
+    vi.stubGlobal('location', new URL('dsh-app://app/'))
+    // Desktop's preload is typechecked by its own compiler program.
+    const preload = '../../../../apps/desktop/src/preload-app.ts'
+    await import(/* @vite-ignore */ preload)
+    desktopIpc.invoke.mockResolvedValue('/desktop/workspace')
+    const b = await bench()
+    const dispose = b.declare()
+    const fiber = b.ctx.plugin({ inject: [...inject], apply })
+    try {
+      await fiber.await()
+      const entry = b.slots.entries(HOLES[0])[0]!
+      const injected = (entry.inject as () => { pick: () => Promise<string | null> })()
+      await expect(injected.pick()).resolves.toBe('/desktop/workspace')
+      expect(desktopIpc.invoke).toHaveBeenCalledExactlyOnceWith('dsh-desktop:directory-pick')
+      expect(b.pickDirectory).not.toHaveBeenCalled()
+    } finally {
+      await fiber.dispose()
+      dispose()
+    }
+  })
+
+  it('uses the desktop bridge without calling the Host and preserves cancellation and errors', async () => {
+    const pick = vi.fn<() => Promise<string | null>>().mockResolvedValue('/desktop/workspace')
+    vi.stubGlobal('__DSH_DIRECTORY_PICKER__', { pick })
+    const b = await bench()
+    b.declare()
+    const fiber = b.ctx.plugin({ inject: [...inject], apply })
+    await fiber.await()
+    try {
+      const entry = b.slots.entries(HOLES[0])[0]!
+      const injected = (entry.inject as () => { pick: () => Promise<string | null> })()
+      await expect(injected.pick()).resolves.toBe('/desktop/workspace')
+      pick.mockResolvedValue(null)
+      await expect(injected.pick()).resolves.toBeNull()
+      pick.mockRejectedValue(new Error('desktop dialog failed'))
+      await expect(injected.pick()).rejects.toThrow('desktop dialog failed')
+      expect(b.pickDirectory).not.toHaveBeenCalled()
+    } finally {
+      await fiber.dispose()
+    }
+  })
+
   it('runs one pick per open edge and reports the path to the latest onPicked', async () => {
     let resolve!: (path: string | null) => void
     const pick = vi.fn(() => new Promise<string | null>((settle) => { resolve = settle }))

+ 33 - 0
packages/client/ui-directory-picker-native/tests/desktop-picker.client.spec.tsx

@@ -0,0 +1,33 @@
+// @vitest-environment jsdom
+/** Desktop directory flow through the Web bundle roster and production client boot. */
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { afterEach, expect, vi } from 'vitest'
+import { cleanup, render, waitFor } from '@testing-library/react'
+import type { ComponentType } from 'react'
+import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
+import { ClientRoster, createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
+
+const manifest = JSON.parse(readFileSync(resolve(import.meta.dirname, '../package.json'), 'utf8')) as {
+  name: string
+  dsh: { client: { inject: string[] } }
+}
+// Auto mounts the native row dynamically; use that package's actual dependency declaration.
+const test = createClientTest({ roster: ClientRoster.of([...webApp.rows, {
+  name: manifest.name, inject: manifest.dsh.client.inject, immediately: false,
+}]) })
+afterEach(() => { cleanup(); vi.unstubAllGlobals() })
+
+test('the composed native flow cancels through Desktop without invoking the Host chooser', async ({ start, remote }) => {
+  const pick = vi.fn<() => Promise<string | null>>().mockResolvedValue(null)
+  vi.stubGlobal('__DSH_DIRECTORY_PICKER__', { pick })
+  const client = await start()
+  const entry = client.ctx.slots.entries('sidebar.workspaces.directoryFlow')[0]!
+  const injected = (entry.inject as () => { pick: () => Promise<string | null> })()
+  const Component = entry.component as ComponentType<DirectoryFlowOwnerProps & typeof injected>
+  const onCancel = vi.fn()
+  render(<Component {...injected} open busy={false} onCancel={onCancel} onPicked={vi.fn()} onError={vi.fn()} />)
+  await waitFor(() => { expect(onCancel).toHaveBeenCalledOnce() })
+  expect(pick).toHaveBeenCalledOnce()
+  expect(remote.directoryPicker.pick).not.toHaveBeenCalled()
+}, 60_000)

+ 2 - 2
packages/client/ui-plugin-manager/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md
-README.md: 313ebf7df21ea73c0c20b1c84cac6767c2079037
-README.zh.md: 81b15b459d8bea4721ed2f84607b1c737101b3b8
+README.md: 1a3ae0f0b5e6cfca4fdbb272f84ca4b641933ba6
+README.zh.md: 0d4d1aeb98fecb24a6bc3e9d0ac84389b0fe939e

+ 2 - 0
packages/client/ui-plugin-manager/README.md

@@ -27,6 +27,8 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b
 
 Select **Plugins** in the sidebar. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built in** comes first and lists the bundles the installation ships for switching on, each tagged official, off until switched on and without an uninstall; **Installed** lists the bundles the profile holds. Cards are listed by name, so switching a bundle on or off does not move its card. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Plugins** section.
 
+The built-in Agent Teams, Agent Teams Web UI, and Auto Authorization Review bundles have localized names and descriptions that follow the UI language. Their detail pages retain the full npm package name; other packages display their short package name and original description.
+
 ### Installing a bundle
 
 **Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. **Not sure what to enter?** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`): a name the list already shows, a name the registry does not have, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. A failed install says what went wrong in one line — the registry or network could not be reached, the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate.

+ 2 - 0
packages/client/ui-plugin-manager/README.zh.md

@@ -27,6 +27,8 @@ kind: "package-reference"
 
 在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置**排在前面,列出安装随附、供开启的组合包,每个带官方标签,开启前保持关闭,且没有卸载;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**插件**分区中编辑。
 
+内置的 Agent Teams、Agent Teams Web UI 和 Auto Authorization Review 组合包使用随界面语言切换的本地化名称和描述。详情页保留完整 npm 包名;其他包显示简写包名和原始描述。
+
 ### 安装一个组合包
 
 **添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。输入框下方的**不知道该填什么?**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。**安装**先让 Host 读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、注册表没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。安装失败时用一行话说明原因——注册表或网络不可达、包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。

+ 6 - 6
packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx

@@ -21,7 +21,7 @@ import {
   type ConfirmState, type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView,
   type PluginManagerFace,
 } from './manager-store.ts'
-import { managementText, noticeText, shortName, type Translate } from './presentation.ts'
+import { managementText, noticeText, packageText, type Translate } from './presentation.ts'
 import css from './PluginManagerPage.module.css'
 
 /** Full component props assembled by the main slot renderer. */
@@ -213,7 +213,7 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: {
   readonly onOpen: () => void
   readonly onSetEnabled: (enabled: boolean) => void
 }): ReactNode {
-  const title = shortName(pkg.name)
+  const { title, description } = packageText(pkg, t)
   const status = packageStatus(pkg)
   return (
     <li
@@ -230,7 +230,7 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: {
             {pkg.optional ? <Tag className={css.statusTag} tone="info">{t('statusOfficial')}</Tag> : null}
             {status === 'problem' ? <Tag className={css.statusTag} tone="danger">{t('statusProblem')}</Tag> : null}
           </div>
-          {pkg.description === undefined ? null : <span className={css.cardDesc}>{pkg.description}</span>}
+          {description === undefined ? null : <span className={css.cardDesc}>{description}</span>}
         </div>
         <div className={css.cardEnd}>
           <EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />
@@ -262,7 +262,7 @@ function PackageDetail({
   readonly onUninstall: () => void
   readonly onSetRowEnabled: (row: PackageRow, enabled: boolean) => void
 }): ReactNode {
-  const title = shortName(pkg.name)
+  const { title, description } = packageText(pkg, t)
   const status = packageStatus(pkg)
   return (
     <div className={css.detail} data-plugin-detail={pkg.name}>
@@ -299,7 +299,7 @@ function PackageDetail({
           {status === 'problem' ? <Tag className={css.statusTag} tone="danger">{t('statusProblem')}</Tag> : null}
         </div>
         <p className={css.detailName}><code data-plugin-name>{pkg.name}</code></p>
-        <p className={css.detailDesc}>{pkg.description ?? t('noDescription')}</p>
+        <p className={css.detailDesc}>{description ?? t('noDescription')}</p>
       </div>
       {pkg.error === undefined ? null : <p className={css.reason} role="status">{t('reasonLabel')}: {managementText(pkg.error, t)}</p>}
       {pkg.readOnlyReason === undefined ? null : <p className={css.reason} role="status">{managementText({ code: pkg.readOnlyReason }, t)}</p>}
@@ -635,7 +635,7 @@ function ConfirmDialog({ confirm, t, onConfirm, onCancel }: {
   readonly onConfirm: () => void
   readonly onCancel: () => void
 }): ReactNode {
-  const name = shortName(confirm.packageName)
+  const { title: name } = packageText({ name: confirm.packageName }, t)
   return (
     <Modal
       open

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels