Bläddra i källkod

feat(document): decouple shared Office conversion from preview

yudshj 2 dagar sedan
förälder
incheckning
9d2a3bf2b7
100 ändrade filer med 3467 tillägg och 55 borttagningar
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml
  2. 2 0
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
  3. 2 0
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md
  4. 6 0
      .agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml
  5. 29 0
      .agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md
  6. 29 0
      .agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md
  7. 6 0
      .agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml
  8. 33 0
      .agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md
  9. 33 0
      .agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md
  10. 6 0
      .agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml
  11. 29 0
      .agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md
  12. 29 0
      .agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md
  13. 8 0
      THIRD_PARTY_NOTICES.md
  14. 2 2
      apps/desktop/README.i18n.yaml
  15. 3 1
      apps/desktop/README.md
  16. 3 1
      apps/desktop/README.zh.md
  17. 4 0
      apps/desktop/scripts/prepare-dsh.ts
  18. 8 3
      apps/desktop/scripts/prepare-package-set.ts
  19. 4 0
      apps/desktop/scripts/runtime-file-policy.ts
  20. 20 3
      apps/desktop/tests/prepare-package-set.spec.ts
  21. 5 0
      apps/desktop/tests/runtime-file-policy.spec.ts
  22. 2 2
      docs/capability-seams.i18n.yaml
  23. 6 0
      docs/capability-seams.md
  24. 6 0
      docs/capability-seams.zh.md
  25. 2 2
      docs/config-catalog.i18n.yaml
  26. 51 0
      docs/config-catalog.md
  27. 51 0
      docs/config-catalog.zh.md
  28. 2 2
      docs/subsystems/README.i18n.yaml
  29. 1 0
      docs/subsystems/README.md
  30. 1 0
      docs/subsystems/README.zh.md
  31. 6 0
      docs/subsystems/document-render.i18n.yaml
  32. 62 0
      docs/subsystems/document-render.md
  33. 62 0
      docs/subsystems/document-render.zh.md
  34. 2 2
      docs/subsystems/workspace.i18n.yaml
  35. 10 0
      docs/subsystems/workspace.md
  36. 10 0
      docs/subsystems/workspace.zh.md
  37. 2 2
      packages/README.i18n.yaml
  38. 1 0
      packages/README.md
  39. 1 0
      packages/README.zh.md
  40. 2 2
      packages/api/workspace-files/README.i18n.yaml
  41. 2 0
      packages/api/workspace-files/README.md
  42. 2 0
      packages/api/workspace-files/README.zh.md
  43. 15 1
      packages/api/workspace-files/src/index.ts
  44. 11 0
      packages/api/workspace-files/tests/read-all.spec.ts
  45. 2 2
      packages/bundle/web-app/README.i18n.yaml
  46. 1 1
      packages/bundle/web-app/README.md
  47. 1 1
      packages/bundle/web-app/README.zh.md
  48. 3 0
      packages/bundle/web-app/cordis.patch.yml
  49. 3 1
      packages/bundle/web-app/package.json
  50. 76 0
      packages/bundle/web-app/tests/document-conversion.e2e.ts
  51. BIN
      packages/bundle/web-app/tests/fixtures/document-conversion.docx
  52. 6 0
      packages/document/README.i18n.yaml
  53. 51 0
      packages/document/README.md
  54. 51 0
      packages/document/README.zh.md
  55. 6 0
      packages/document/document-render-libreoffice/README.i18n.yaml
  56. 97 0
      packages/document/document-render-libreoffice/README.md
  57. 97 0
      packages/document/document-render-libreoffice/README.zh.md
  58. 43 0
      packages/document/document-render-libreoffice/package.json
  59. 169 0
      packages/document/document-render-libreoffice/src/index.ts
  60. 39 0
      packages/document/document-render-libreoffice/src/output.ts
  61. 232 0
      packages/document/document-render-libreoffice/src/queue.ts
  62. 64 0
      packages/document/document-render-libreoffice/tests/output.spec.ts
  63. 269 0
      packages/document/document-render-libreoffice/tests/provider.spec.ts
  64. 563 0
      packages/document/document-render-libreoffice/tests/queue.spec.ts
  65. 21 0
      packages/document/document-render-libreoffice/tsconfig.json
  66. 6 0
      packages/document/document-render/README.i18n.yaml
  67. 75 0
      packages/document/document-render/README.md
  68. 75 0
      packages/document/document-render/README.zh.md
  69. 43 0
      packages/document/document-render/package.json
  70. 44 0
      packages/document/document-render/src/index.ts
  71. 71 0
      packages/document/document-render/src/types.ts
  72. 18 0
      packages/document/document-render/tsconfig.json
  73. 2 2
      packages/experimental/webworker-runtime/README.i18n.yaml
  74. 1 0
      packages/experimental/webworker-runtime/README.md
  75. 1 0
      packages/experimental/webworker-runtime/README.zh.md
  76. 1 0
      packages/experimental/webworker-runtime/src/module-proxies.ts
  77. 2 0
      packages/experimental/webworker-runtime/src/node/builtins.ts
  78. 12 0
      packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts
  79. 1 0
      packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts
  80. 9 0
      packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts
  81. 53 0
      packages/extensions/tool-cordis/src/api-catalog.ts
  82. 179 5
      pnpm-lock.yaml
  83. 7 0
      pnpm-workspace.yaml
  84. 2 2
      python/sdk-runtime/README.i18n.yaml
  85. 5 1
      python/sdk-runtime/README.md
  86. 5 1
      python/sdk-runtime/README.zh.md
  87. 11 0
      python/sdk-runtime/hatch_build.py
  88. 1 1
      python/sdk-runtime/pyproject.toml
  89. 14 1
      python/sdk-runtime/runtime-bootstrap.mjs
  90. 9 1
      python/sdk-runtime/src/deepseek_harness_runtime/__init__.py
  91. 46 0
      python/sdk/tests/test_release_version.py
  92. 54 0
      python/sdk/tests/test_runtime_resolution.py
  93. 137 0
      scripts/build-exe-for-python-sdk-office.spec.ts
  94. 94 0
      scripts/build-exe-for-python-sdk-office.ts
  95. 21 5
      scripts/build-exe-for-python-sdk.ts
  96. 52 6
      scripts/build-python-release.py
  97. 15 0
      scripts/fixtures/python-sdk-office.mjs
  98. 5 0
      scripts/gen-cordis-catalog.ts
  99. 6 0
      scripts/gen-doc-graphs.ts
  100. 23 0
      scripts/gen-third-party-notices.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-07-10-single-file-executable-sdk-runtime-distribution.md
-2026-07-10-single-file-executable-sdk-runtime-distribution.md: 665364e6d39a78f7ac497198f18ed9aa160a4cbd
-2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 8b39df71c615dc59f3ef1bf622a5736a70b085b3
+2026-07-10-single-file-executable-sdk-runtime-distribution.md: 45191be901f8df048d48798158c83fdfd8134737
+2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: b76035e6c81841e5eda4dcbcd575479996eff4b9

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 2 - 0
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 2 - 0
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md


+ 6 - 0
.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.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-14-independent-libreoffice-kit.md
+2026-09-14-independent-libreoffice-kit.md: aaecb7468c16451ed812c9b2441e55e00abf6c98
+2026-09-14-independent-libreoffice-kit.zh.md: 8cea9890596bc53ddcbe346c60c654b49e0362c8

+ 29 - 0
.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md

@@ -0,0 +1,29 @@
+# Agent Note: Independent LibreOffice kit ownership
+
+Status: implemented
+
+English | [中文](2026-09-14-independent-libreoffice-kit.zh.md)
+
+## Problem
+
+LibreOffice compilation, source patches, platform qualification, and large binary releases have a different maintenance cycle from Harness plugins. Keeping them in the application workspace expands routine CI and couples engine repairs to monorepo package rules.
+
+## Decision
+
+The `deepseek-harness/libreoffice-kit` repository owns the reusable `@deepseek-ai/libreoffice-kit` Node API, its Worker, font handling, engine selection, build recipes, patches, tests, and releases. The API has no Cordis dependency. Harness owns the adapter from its `DocumentRenderer` service to this API, Session authorization, conversion lifetime, transport, Web UI, and application packaging.
+
+Kit releases run independently of Harness releases. The kit repository qualifies and publishes the Node API and engine npm packages at a shared version, starting at `0.0.1`. Harness consumes an exact npm version and commits its dependency resolution in `pnpm-lock.yaml`; Harness releases neither build nor publish kit packages.
+
+The upstream API declares platform engines as optional dependencies. The [platform engine decision](2026-09-15-platform-office-engines.md) supersedes the original required-WASM fallback policy. Desktop installs the kit as an external npm dependency. Python sidecars keep the Worker, selected engine, and their dependency closure on the real filesystem, outside the executable’s virtual filesystem. Conversion requires neither downloads nor GitHub credentials.
+
+The kit repository owns engine qualification and corresponding source materials. MPL-2.0 declarations, source availability, and redistribution notices accompany the API and engines; Harness retains these materials when packaging them. The notices check accepts the exact API, WASM, macOS ARM64/x64, and Windows ARM64/x64 package identities only at MPL-2.0; unrelated packages and changed license terms still reject.
+
+## Alternatives considered
+
+**Co-locate the public API and Core build in Harness.** This synchronizes source changes but makes application maintenance own long engine builds and special package rules. The Cordis provider is the application integration point; the reusable conversion API belongs with its engine tests.
+
+**Prepare GitHub Release archives before installation.** This requires separate authentication, hashes, decompression, workspace overrides, and distribution repacking. Published npm packages use the application's ordinary dependency installation and platform selection.
+
+## Consequences
+
+Changing the kit requires qualifying a release in its own repository and updating the Harness dependency versions and lockfile. A missing required npm package fails installation; Harness does not compile an engine to recover. Native and WASM conversion smokes validate the installed packages, while Desktop and Python checks cover application packaging.

+ 29 - 0
.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 独立 LibreOffice kit 的归属
+
+Status: implemented
+
+[English](2026-09-14-independent-libreoffice-kit.md) | 中文
+
+## Problem
+
+LibreOffice 编译、源码补丁、平台资格验证和大型二进制发布的维护周期不同于 Harness 插件。将它们放在应用工作区会扩大常规 CI,并让引擎修复依赖 monorepo 包规则。
+
+## Decision
+
+`deepseek-harness/libreoffice-kit` 仓库维护可复用的 `@deepseek-ai/libreoffice-kit` Node API、Worker、字体处理、引擎选择、构建配方、补丁、测试和发布。API 不依赖 Cordis。Harness 负责将自己的 `DocumentRenderer` 服务适配到此 API,以及 Session 授权、转换生命周期、传输、Web UI 和应用打包。
+
+kit 发布流程独立于 Harness 发布流程。kit 仓库验证并以统一版本发布 Node API 和引擎 npm 包,起始版本为 `0.0.1`。Harness 消费精确的 npm 版本,并在 `pnpm-lock.yaml` 中提交依赖解析结果;Harness 发布既不构建也不发布 kit 包。
+
+上游 API 将平台引擎声明为可选依赖。[平台引擎决策](2026-09-15-platform-office-engines.zh.md)取代最初强制携带 WASM 回退引擎的策略。Desktop 将 kit 作为外部 npm 依赖安装。Python sidecar 将 Worker、所选引擎及其依赖闭包保留在真实文件系统中,位于可执行文件的虚拟文件系统之外。转换无需下载或 GitHub 凭据。
+
+kit 仓库负责引擎资格验证和对应源码材料。API 和引擎携带 MPL-2.0 声明、可访问源码及再分发声明;Harness 打包时保留这些材料。再分发声明检查仅在许可为 MPL-2.0 时接受 API、WASM、macOS ARM64/x64 和 Windows ARM64/x64 的精确包标识;无关包和改变后的许可条款仍被拒绝。
+
+## Alternatives considered
+
+**将公开 API 和 Core 构建放在 Harness。** 这能同步源码变更,却让应用维护承担耗时的引擎构建和特殊包规则。Cordis provider 是应用集成点;可复用转换 API 应与引擎测试放在一起。
+
+**安装前准备 GitHub Release 归档。** 这需要单独的鉴权、哈希、解压、工作区 overrides 和分发重打包。已发布的 npm 包使用应用常规的依赖安装与平台选择流程。
+
+## Consequences
+
+更新 kit 需要在其独立仓库验证并发布新版本,再更新 Harness 依赖版本和锁文件。必需的 npm 包缺失时安装失败;Harness 不通过编译引擎恢复。原生和 WASM 转换冒烟验证已安装的包,Desktop 与 Python 检查覆盖应用打包。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.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-15-bounded-office-rendering.md
+2026-09-15-bounded-office-rendering.md: 539eb7e4824897861f350f294365f283678a7fee
+2026-09-15-bounded-office-rendering.zh.md: 928d93a6500d6c7818f28dd20029648453d9f9cf

+ 33 - 0
.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md

@@ -0,0 +1,33 @@
+# Agent Note: Bounded shared Office rendering
+
+Status: implemented
+
+English | [中文](2026-09-15-bounded-office-rendering.zh.md)
+
+## Problem
+
+Office preview and explicit document inspection can request the same conversion. A completed-result cache alone leaves source reads, queued payloads, and concurrent readers unbounded. Speculation can also occupy capacity needed by a user, and canceling one consumer must not destroy another consumer's conversion.
+
+## Decision
+
+The [Host provider](../../../../packages/document/document-render-libreoffice/README.md) owns a shared conversion queue and transient content cache. Authorized source metadata enters admission before source bytes are loaded. The source callback receives reserved byte capacity and returns its read version; changed sources fail without publishing aliases. Exact source bytes and Office extension determine the digest. Each renderer lifetime adds a generation so engine/font/configuration replacement invalidates reuse.
+
+A bounded source-version index avoids repeated reads after authorization; the digest remains the identity for sharing conversion across distinct paths. Ready PDFs use an entry/byte-bounded LRU. Queued jobs contain metadata and deferred callbacks. Reader, queue, source-byte, and conversion limits also apply before work completes. Each active source locator belongs to live readers, so cancellation cannot grow retained source metadata independently of reader admission. Unknown source sizes reserve the input cap; cancellation retains active capacity until actual read/conversion cleanup settles.
+
+Foreground preview and explicit QA requests precede background work. Disabling background conversions rejects speculative readers before they can join queued or running jobs, while completed alias hits remain available. Removing a queued foreground blocker immediately admits eligible background work. Queued priority follows live readers: a foreground join promotes a prewarm, and the final foreground cancellation demotes it and admits eligible work. Full queues evict queued speculation for foreground admission. Background concurrency reserves a foreground slot when total concurrency permits it. A speculative admission remains occupied through settlement, so promotion cannot admit additional speculation into capacity reserved for user work. Shared readers cancel independently, including readers joined after content hashing. The cache owns private output bytes and returns a copy to each caller.
+
+## Alternatives considered
+
+**Cache only completed PDFs.** This cannot bound pending source buffers, engine work, or response fanout, and separate consumers still duplicate conversion.
+
+**Use source metadata as the final cache identity.** Metadata is useful before reading, but distinct authorized paths can contain identical bytes. Content hashing provides cross-path reuse without treating a path as document content.
+
+**Cancel the entire conversion when one reader leaves.** An open preview can share work with speculation or explicit QA. Only the final reader owns cancellation of shared work.
+
+**Build a second prewarm or QA converter.** Independent queues duplicate resource ownership and cannot prioritize shared foreground work.
+
+## Consequences
+
+The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish.
+
+Controlled source and engine completions verify pre-read admission, content joining, priority, cancellation isolation, delayed resource release, LRU/alias limits, stale versions, and renderer replacement. Loader composition and native conversion checks exercise the shared provider independently of presentation consumers.

+ 33 - 0
.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: 有界的共享 Office 渲染
+
+Status: implemented
+
+[English](2026-09-15-bounded-office-rendering.md) | 中文
+
+## 问题
+
+Office 预览和显式文档检查可能请求相同转换。仅缓存已完成结果无法限制源读取、排队载荷和并发读取方。推测工作也可能占用用户需要的容量,而取消一个消费者不能破坏另一个消费者的转换。
+
+## 决策
+
+[宿主提供方](../../../../packages/document/document-render-libreoffice/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个渲染器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。
+
+有界的源版本索引在授权后避免重复读取;摘要仍是不同路径间共享转换的身份。已就绪 PDF 使用按条目与字节限制的 LRU。排队任务包含元数据和延迟回调。读取方、队列、源字节与转换限制在工作完成前也适用。每个在途源定位信息归属于活跃读取方,因此取消操作不能让保留的源元数据脱离读取方准入限制增长。未知源大小预留输入上限;取消后仍保留活动容量,直至实际读取、转换与清理结束。
+
+前台预览和显式 QA 请求优先于后台工作。禁用后台转换时,推测读取方在加入排队或运行任务前即被拒绝,但仍可命中已完成的别名缓存。移除排队的前台阻塞任务后,符合条件的后台工作立即准入。排队优先级取决于活跃读取方:前台加入会提升预热,最后一个前台读取方取消后则恢复后台优先级,并准入符合条件的工作。队列满时为前台准入移除排队推测工作。总并发允许时,后台并发为前台预留一个槽位。推测工作的准入名额保留到工作结束,因此提权不会把为用户预留的容量用于更多推测工作。共享读取方独立取消,包括内容哈希后加入的读取方。缓存拥有私有输出字节,并为各调用方返回副本。
+
+## 考虑过的替代方案
+
+**仅缓存已完成 PDF。** 无法限制待完成源缓冲区、引擎工作和响应扇出,不同消费者仍会重复转换。
+
+**以源元数据作为最终缓存身份。** 元数据在读取前有用,但不同的已授权路径可能包含相同字节。内容哈希提供跨路径复用,而不把路径当作文档内容。
+
+**一个读取方离开就取消整个转换。** 打开的预览可能与推测工作或显式 QA 共享工作。只有最后一个读取方拥有共享工作取消权。
+
+**另建预热或 QA 转换器。** 独立队列重复拥有资源,无法优先调度共享前台工作。
+
+## 后果
+
+缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。
+
+受控的源读取和引擎完成验证读取前准入、内容合并、优先级、取消隔离、延迟资源释放、LRU 与别名限额、过期版本及渲染器替换。Loader 组合与原生转换检查独立于展示消费者验证共享提供方。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.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-15-platform-office-engines.md
+2026-09-15-platform-office-engines.md: fa8656eb7505b599f76640077f9b957fd867269c
+2026-09-15-platform-office-engines.zh.md: eed1879b9a0bd5fe5056670c414c4db4b81d9d9b

+ 29 - 0
.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md

@@ -0,0 +1,29 @@
+# Agent Note: One Office engine per platform
+
+Status: implemented
+
+English | [中文](2026-09-15-platform-office-engines.zh.md)
+
+## Problem
+
+Installing WASM beside a usable native engine adds a second LibreOffice payload to application downloads and installed resources. The original fallback policy in [independent kit ownership](2026-09-14-independent-libreoffice-kit.md) requires that extra payload even on fixed-platform Desktop and Python distributions.
+
+## Decision
+
+Harness depends on the kit API and uses its platform-filtered optional engine dependencies. macOS and Windows install their matching ARM64 or x64 native package; Linux installs the shared WASM package. WASM declares Linux as its npm OS. The provider has no direct WASM dependency. Missing native engines on macOS/Windows reject converter creation instead of selecting WASM.
+
+Python sidecar assembly copies only the target engine and its dependency closure. Wheel packaging and runtime lookup require that engine; the relocated conversion smoke checks the selected backend. Desktop carries the ordinary npm installation in application resources. Engine compilation, qualification, and publication remain in the kit repository.
+
+## Alternatives considered
+
+**Keep WASM beside every native engine.** This tolerates a missing optional native package but increases every macOS/Windows installation. Fixed-platform distributions require their native engine to be present and tested instead.
+
+**Remove WASM on every platform.** Linux has no released native engine in this kit family. Keeping the shared WASM package on Linux preserves Office conversion without introducing a new native release target.
+
+**Make the Python Office sidecar optional.** The runtime carries the shared `dsh` CLI and its Web profile as well as the default SDK profile. Requiring the target engine gives the installed wheel a complete shipped profile set and reports an incomplete payload before launch. SDK and headless users also pay the engine download and installed-size cost.
+
+## Consequences
+
+macOS/Windows distributions omit the WASM download and expanded assets. A missing native package becomes an installation defect that must be repaired; conversion does not download an engine. Linux retains its WASM resource and font requirements. The kit’s engine-selection tests and actual npm installation tests cover platform selection; Harness sidecar, wheel, and runtime-resolution tests cover packaging requirements. New package bytes require kit qualification and matching dependency integrity records before publication.
+
+The [public Python release workflow](../../../../.github/workflows/python-release.yml) rejects any wheel at or above 100,000,000 bytes. Selecting one engine reduces payload size but does not establish that a runtime wheel meets this limit; npm engine publication and local conversion are separate from wheel upload eligibility.

+ 29 - 0
.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 每个平台使用一个 Office 引擎
+
+Status: implemented
+
+[English](2026-09-15-platform-office-engines.md) | 中文
+
+## Problem
+
+在可用的原生引擎之外安装 WASM,会为应用下载和安装资源增加第二份 LibreOffice 载荷。[独立 kit 归属](2026-09-14-independent-libreoffice-kit.zh.md)中最初的回退策略要求固定平台的 Desktop 和 Python 分发物也携带这份额外载荷。
+
+## Decision
+
+Harness 依赖 kit API,并使用它按平台筛选的可选引擎依赖。macOS 和 Windows 安装匹配的 ARM64 或 x64 原生包;Linux 安装共享 WASM 包。WASM 的 npm OS 声明为 Linux。provider 不直接依赖 WASM。macOS/Windows 缺少原生引擎时拒绝创建转换器,不会选择 WASM。
+
+Python sidecar 组装仅复制目标引擎及其依赖闭包。wheel 打包和运行时查找要求该引擎存在;迁移后的转换冒烟检查所选 backend。Desktop 在应用资源中携带常规 npm 安装结果。引擎编译、资格验证和发布仍由 kit 仓库负责。
+
+## Alternatives considered
+
+**在每个原生引擎旁保留 WASM。** 这能容忍可选原生包缺失,却会增大每个 macOS/Windows 安装。固定平台分发物改为要求其原生引擎存在且经过测试。
+
+**在所有平台移除 WASM。** 当前 kit 家族没有已发布的 Linux 原生引擎。在 Linux 保留共享 WASM 包,既能继续转换 Office 文档,也无需引入新的原生发布目标。
+
+**将 Python Office 伴随目录设为可选。** 运行时承载共享的 `dsh` CLI 及其 Web profile,而不只有默认 SDK profile。强制携带目标引擎使已安装 wheel 具备完整的内置 profile 集合,并在启动前报告载荷不完整。SDK 和 headless 用户也承担引擎的下载与安装体积。
+
+## Consequences
+
+macOS/Windows 分发物省去 WASM 下载和展开资源。原生包缺失成为必须修复的安装缺陷;转换不会下载引擎。Linux 保留 WASM 的资源和字体要求。kit 的引擎选择测试与真实 npm 安装测试覆盖平台选择;Harness 的 sidecar、wheel 和运行时解析测试覆盖打包要求。新包字节在发布前需要 kit 资格验证和匹配的依赖完整性记录。
+
+[公开 Python 发布工作流](../../../../.github/workflows/python-release.yml)拒绝任何大于等于 100,000,000 字节的 wheel。只选择一个引擎会减少载荷,但不能据此认定运行时 wheel 已满足此限制;npm 引擎发布、本地转换与 wheel 上传资格是不同的验证。

+ 8 - 0
THIRD_PARTY_NOTICES.md

@@ -36,6 +36,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
 | [`@anthropic-ai/sdk`](https://github.com/anthropics/anthropic-sdk-typescript) | MIT |
 | [`@babel/code-frame`](https://github.com/babel/babel) | MIT |
 | [`@browserbasehq/stagehand`](https://github.com/browserbase/stagehand) | MIT |
+| [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) | MPL-2.0 |
 | [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT |
 | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT |
 | [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT |
@@ -146,6 +147,13 @@ The installed SDK 0.3.263 declares the following optional platform packages. Eac
 | [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.263 | SEE LICENSE IN LICENSE.md |
 
 
+## LibreOffice conversion kit
+
+`@deepseek-ai/libreoffice-kit`, `@deepseek-ai/libreoffice-kit-wasm`, `@deepseek-ai/libreoffice-kit-darwin-arm64`, `@deepseek-ai/libreoffice-kit-darwin-x64`, `@deepseek-ai/libreoffice-kit-win32-arm64`, `@deepseek-ai/libreoffice-kit-win32-x64` declare MPL-2.0, which remains outside the permissive-license allowlist; the notices check accepts only these package identities at those terms. The [distribution decision](.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) records the source obligations.
+
+The [kit repository](https://github.com/deepseek-harness/libreoffice-kit) supplies the corresponding LibreOffice source pin, modifications, build instructions, Node API, and artifact validation. Its engine packages retain their license and third-party notices; the Node API retains its MPL-2.0 declaration and NOTICE. Recipients must have access to those corresponding sources and notices.
+
+
 ## Development-only npm dependencies
 
 External packages **directly declared** for development, tests, types, or tooling, without a runtime installation or browser-build relationship. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on that full closure.

+ 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: 393f41f82ed20fbc71b1659cd2417a8d7f9b5013
-README.zh.md: e5bdb032b47795dc28848561f08f0464f83e49d3
+README.md: 497f086217521dc081aa094d2a61a7ae7a362893
+README.zh.md: 687166b0c04994e503d62fb77bf07a2c030223d4

+ 3 - 1
apps/desktop/README.md

@@ -122,7 +122,9 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t
 
 ### Runtime file selection
 
-Production packages first pass through npm's publication rules and dependency installation. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files.
+Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files.
+
+The [Office conversion provider](../../packages/document/document-render-libreoffice/README.md) carries one matching native engine on macOS/Windows or the WASM engine on Linux. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree.
 
 The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; `prepare:dsh` runs the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) under Electron RunAsNode before the Host smoke and final inventory verification.
 

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

@@ -122,7 +122,9 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 
 
 ### 运行时文件筛选
 
-生产包首先经过 npm 发布规则和依赖安装。[桌面文件规则](scripts/runtime-file-policy.ts)随后在签名和完整性封存之前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、明确属于 JavaScript/CSS/TypeScript 的 source map、TypeScript 构建缓存、Domino 测试目录、指定的原生编译产物,以及其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助程序、WASM、未知资源、许可证和声明。规则不会修改 npm tarball、内置包管理器或用户安装的插件文件。
+Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。
+
+[Office 转换提供方](../../packages/document/document-render-libreoffice/README.zh.md)在 macOS/Windows 携带一个匹配的原生引擎,在 Linux 携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。
 
 打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。
 

+ 4 - 0
apps/desktop/scripts/prepare-dsh.ts

@@ -137,6 +137,10 @@ async function main(): Promise<void> {
         throw new Error(`desktop runtime: missing private Host file ${file}`)
       }
     }
+    const officeEngine = target.platform === 'linux' ? 'wasm' : `${target.platform}-${target.arch}`
+    if (!existsSync(join(DSH_OUTPUT_ROOT, 'node_modules', '@deepseek-ai', `libreoffice-kit-${officeEngine}`, 'prebuilds.json'))) {
+      throw new Error(`desktop runtime: missing required LibreOffice engine ${officeEngine}`)
+    }
     if (process.platform === 'darwin') {
       await signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env))
       await signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env))

+ 8 - 3
apps/desktop/scripts/prepare-package-set.ts

@@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'
 import {
   constants,
   copyFileSync,
+  globSync,
   mkdirSync,
   readFileSync,
   readdirSync,
@@ -13,6 +14,7 @@ import {
 } from 'node:fs'
 import { basename, join, resolve } from 'node:path'
 import { parseArgs } from 'node:util'
+import * as yaml from 'js-yaml'
 import {
   DESKTOP_HOST_PACKAGE,
   DESKTOP_HOST_RUNTIME_FILES,
@@ -49,13 +51,16 @@ function dependencyNames(manifest: Readonly<Record<string, unknown>>, section: s
 }
 
 /**
- * Select the complete available first-party dependency closures rooted at dsh and its private Host.
+ * Select workspace dependencies rooted at dsh and its private Host; npm resolves external packages.
  * @param available - Packed packages indexed by package name.
  * @returns Selected packages sorted by name.
  */
 export function selectDesktopPackageClosure(
   available: ReadonlyMap<string, PackedDesktopPackage>,
 ): PackedDesktopPackage[] {
+  const workspace = yaml.load(readFileSync(join(REPOSITORY_ROOT, 'pnpm-workspace.yaml'), 'utf8')) as { packages: string[] }
+  const workspaceNames = new Set(globSync(workspace.packages.map(pattern => `${pattern}/package.json`), { cwd: REPOSITORY_ROOT })
+    .map(path => (JSON.parse(readFileSync(join(REPOSITORY_ROOT, path), 'utf8')) as { name: string }).name))
   const selected = new Map<string, PackedDesktopPackage>()
   const visit = (name: string): void => {
     if (selected.has(name)) return
@@ -65,8 +70,8 @@ export function selectDesktopPackageClosure(
     for (const section of REQUIRED_DEPENDENCY_SECTIONS) {
       for (const dependency of dependencyNames(packed.manifest, section)) {
         if (available.has(dependency)) visit(dependency)
-        else if (dependency.startsWith('@deepseek-ai/')) {
-          throw new Error(`desktop package set: ${name} requires unpacked internal package ${dependency}`)
+        else if (workspaceNames.has(dependency)) {
+          throw new Error(`desktop package set: ${name} requires unpacked package ${dependency}`)
         }
       }
     }

+ 4 - 0
apps/desktop/scripts/runtime-file-policy.ts

@@ -23,6 +23,10 @@ export function desktopRuntimeFileExclusion(
   const nameParts = packageParts[0]?.startsWith('@') ? 2 : 1
   const name = packageParts.slice(0, nameParts).join('/')
   const entry = packageParts.slice(nameParts).join('/')
+  if (name.startsWith('@deepseek-ai/libreoffice-kit-')) {
+    const engine = target.platform === 'linux' ? 'wasm' : `${target.platform}-${target.arch}`
+    if (name !== `@deepseek-ai/libreoffice-kit-${engine}`) return 'LibreOffice other platform'
+  }
   if (name === 'fs-ext' && /^build\/(?:Release|Debug)\/(?:obj(?:\/|$)|fs_ext\.(?:exp|lib|pdb|iobj|ipdb)$)/u.test(entry)) {
     return 'fs-ext compiler output'
   }

+ 20 - 3
apps/desktop/tests/prepare-package-set.spec.ts

@@ -46,21 +46,38 @@ describe('desktop package-set selection', () => {
     ])
   })
 
-  it('rejects a required internal package absent from the packed release inputs', () => {
+  it.each([
+    '@deepseek-ai/dsh-base', '@deepseek-ai/cordis', '@deepseek-ai/node-addon-system',
+  ])('rejects required prepared package %s absent from the packed release inputs', (dependency) => {
     const available = new Map<string, PackedDesktopPackage>([
       ['@deepseek-ai/dsh', packed('@deepseek-ai/dsh', {
-        dependencies: { '@deepseek-ai/dsh-base': '^1.0.0' },
+        dependencies: { [dependency]: '^1.0.0' },
       })],
       ['@deepseek-ai/dsh-desktop-host', packed('@deepseek-ai/dsh-desktop-host', {
         dependencies: { '@deepseek-ai/dsh': '^1.0.0' },
       })],
     ])
-    expect(() => selectDesktopPackageClosure(available)).toThrow(/unpacked internal package/u)
+    expect(() => selectDesktopPackageClosure(available)).toThrow(/unpacked package/u)
     expect(() => selectDesktopPackageClosure(new Map([
       ['@deepseek-ai/dsh', packed('@deepseek-ai/dsh')],
     ]))).toThrow(/omit @deepseek-ai\/dsh-desktop-host/u)
   })
 
+  it('leaves independently published Office packages to npm resolution', () => {
+    const available = new Map<string, PackedDesktopPackage>([
+      ['@deepseek-ai/dsh', packed('@deepseek-ai/dsh', {
+        dependencies: {
+          '@deepseek-ai/libreoffice-kit': '0.0.1',
+          '@deepseek-ai/libreoffice-kit-wasm': '0.0.1',
+        },
+      })],
+      ['@deepseek-ai/dsh-desktop-host', packed('@deepseek-ai/dsh-desktop-host')],
+    ])
+    expect(selectDesktopPackageClosure(available).map(entry => entry.manifest.name)).toEqual([
+      '@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host',
+    ])
+  })
+
   it('requires the Desktop Host entry', () => {
     const files = [
       'package/lib/index.js',

+ 5 - 0
apps/desktop/tests/runtime-file-policy.spec.ts

@@ -27,6 +27,8 @@ it('omits development artifacts while preserving executable modules, assets and
     'node-pty/prebuilds/win32-x64/conpty.pdb',
     '@koromix/koffi-win32-x64/win32_x64/koffi.lib',
     '@mixmark-io/domino/test/entities.html',
+    '@deepseek-ai/libreoffice-kit-wasm/assets/soffice.data',
+    '@deepseek-ai/libreoffice-kit-darwin-arm64/bin/libreoffice-kit',
     '.modules.yaml', '.pnpm-workspace-state-v1.json', '.bin/tool', '.pnpm/cache',
   ]
   const retained = [
@@ -44,6 +46,9 @@ it('omits development artifacts while preserving executable modules, assets and
     '@koromix/koffi-win32-x64/win32_x64/koffi.node',
     '@mixmark-io/domino/lib/HTMLParser.js', '@mixmark-io/domino/lib/EntityParser.js',
     '@img/sharp-win32-x64/lib/libvips-42.dll',
+    '@deepseek-ai/libreoffice-kit-win32-x64/bin/libreoffice-kit.exe',
+    '@deepseek-ai/libreoffice-kit-win32-x64/sources/core-source.json',
+    '@deepseek-ai/libreoffice-kit-win32-x64/licenses/MPL-2.0.txt',
   ]
   try {
     const runtime = runtimeFixture(source)

+ 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: df39022715cf1000daca9fdeaf668e9cedf88bb1
-capability-seams.zh.md: fc9c8fd46375d77fb42fcc6cf0aef160527d100e
+capability-seams.md: 90d77872b47c1ac610d566c29f6c172e74bd79f3
+capability-seams.zh.md: 62a0b545e47cfc6b3c5cf30fa76e3ca332c7ed7d

+ 6 - 0
docs/capability-seams.md

@@ -26,6 +26,9 @@ flowchart LR
   svc_computerUse["ctx.computerUse<br/>Computer-use provider registration"]
   pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"]
   pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"]
+  pkg_document_render["document-render"]
+  svc_documentRender["ctx.documentRender<br/>Office to PDF rendering"]
+  pkg_document_render_libreoffice["document-render-libreoffice"]
   pkg_attachment["attachment"]
   svc_attachments["ctx.attachments<br/>Durable binary attachment storage"]
   pkg_attachment_local["attachment-local"]
@@ -283,6 +286,8 @@ flowchart LR
   pkg_credentials --> svc_credentials
   pkg_credentials_local --> svc_credentials
   pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions
+  pkg_document_render --> svc_documentRender
+  pkg_document_render_libreoffice --> svc_documentRender
   pkg_experimental_agent_team --> svc_agentTeams
   pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse
   pkg_experimental_browser_use_playwright_mcp --> svc_browserUse
@@ -533,6 +538,7 @@ flowchart LR
 | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. |
 | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | One provider-owned name per service instance. Providers own their tools and browser resources per live Session; the shared service has no browser operation API. |
 | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock. |
+| `ctx.documentRender` | `seam` | [`document-render`](../packages/document/document-render) | [`document-render-libreoffice`](../packages/document/document-render-libreoffice) | - | - | Authorized Office bytes are converted on the Host using native engines on macOS/Windows and Node WASM on Linux. |
 | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. |
 | `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | Owns streaming intake, durable storage, and staged receipt lifetime; the Session controller binds receipts to accepted submissions. |
 | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. |

+ 6 - 0
docs/capability-seams.zh.md

@@ -28,6 +28,9 @@ flowchart LR
   svc_computerUse["ctx.computerUse<br/>Computer-use provider registration"]
   pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"]
   pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"]
+  pkg_document_render["document-render"]
+  svc_documentRender["ctx.documentRender<br/>Office to PDF rendering"]
+  pkg_document_render_libreoffice["document-render-libreoffice"]
   pkg_attachment["attachment"]
   svc_attachments["ctx.attachments<br/>Durable binary attachment storage"]
   pkg_attachment_local["attachment-local"]
@@ -285,6 +288,8 @@ flowchart LR
   pkg_credentials --> svc_credentials
   pkg_credentials_local --> svc_credentials
   pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions
+  pkg_document_render --> svc_documentRender
+  pkg_document_render_libreoffice --> svc_documentRender
   pkg_experimental_agent_team --> svc_agentTeams
   pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse
   pkg_experimental_browser_use_playwright_mcp --> svc_browserUse
@@ -535,6 +540,7 @@ flowchart LR
 | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 |
 | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | 每个服务实例注册一个提供方拥有的名称。提供方按实时 Session 拥有自己的工具与浏览器资源;共享服务不提供浏览器操作 API。 |
 | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | 每个服务实例只注册一个提供方自定的名称。各提供方也拥有自己的模型工具;服务不提供通用操作 API、运行时选择或 Session 流程锁。 |
+| `ctx.documentRender` | `seam` | [`document-render`](../packages/document/document-render) | [`document-render-libreoffice`](../packages/document/document-render-libreoffice) | - | - | 已授权的 Office 字节在宿主上转换;macOS/Windows 使用原生引擎,Linux 使用 Node WASM。 |
 | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 |
 | `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | 负责流式接收、持久存储和暂存回执生命周期;Session Controller 将回执绑定到已接受的提交。 |
 | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 |

+ 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: c3e84ba47ad687915ae1c641e8600bd43a2102b6
-config-catalog.zh.md: b8b86030d96ebd8ea49cc6e545007a0c319720f3
+config-catalog.md: 146af3692b84672a09039ed56b19a54470c3c47f
+config-catalog.zh.md: 8e140676fca4ce6ea81258c1e8b0cbd21e254ccf

+ 51 - 0
docs/config-catalog.md

@@ -548,6 +548,56 @@ export interface Config {
 
 Source: [`packages/credentials/credentials-local/src/index.ts:64`](../packages/credentials/credentials-local/src/index.ts)
 
+<a id="deepseek-aidsh-document-render-libreoffice"></a>
+
+## `@deepseek-ai/dsh-document-render-libreoffice`
+
+```ts config-catalog
+/** Provider concurrency and kit rendering/font configuration. */
+export interface Config {
+  /** Maximum simultaneous conversions; queued callers remain cancellable. */
+  maxConcurrentConversions: number
+  /** Maximum metadata-only jobs awaiting source admission. */
+  maxQueuedJobs: number
+  /** Maximum outstanding conversion readers. */
+  maxReaders: number
+  /** Maximum reserved bytes across admitted source reads and conversions. */
+  maxSourceBytes: number
+  /** Maximum concurrent background jobs; zero refuses speculative work. */
+  maxBackgroundConversions: number
+  /** Maximum retained content-addressed PDFs. */
+  maxCachedEntries: number
+  /** Maximum retained PDF bytes. */
+  maxCachedBytes: number
+  /** Maximum retained source-version aliases to cached content. */
+  maxSourceEntries: number
+  /** Conversion deadline in milliseconds; excludes the DSH queue. */
+  timeoutMs: number
+  /** Maximum authorized source bytes. */
+  maxInputBytes: number
+  /** Maximum complete PDF bytes. */
+  maxOutputBytes: number
+  /** Exported raster-image DPI. */
+  maxImageResolution: number
+  /** Maximum OOXML ZIP entries. */
+  maxArchiveEntries: number
+  /** Maximum total declared uncompressed OOXML bytes. */
+  maxUncompressedBytes: number
+  /** Absolute font roots; omission uses the kit's platform defaults. */
+  fontDirectories?: string[]
+  /** Ordered font-family preference groups; omission retains the kit defaults. */
+  fontFallbacks?: string[][]
+  /** Maximum physical font files indexed by each converter. */
+  maxFontFiles: number
+  /** Maximum individual font-file bytes. */
+  maxFontFileBytes: number
+  /** Maximum original font bytes loaded for a conversion. */
+  maxLoadedFontBytes: number
+}
+```
+
+Source: [`packages/document/document-render-libreoffice/src/index.ts:14`](../packages/document/document-render-libreoffice/src/index.ts)
+
 <a id="deepseek-aidsh-experimental-agent-team"></a>
 
 ## `@deepseek-ai/dsh-experimental-agent-team`
@@ -3798,6 +3848,7 @@ Abstract service classes — a deployment loads a concrete implementation packag
 - `@deepseek-ai/dsh-attachment` — abstract `AttachmentStore` ([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts))
 - `@deepseek-ai/dsh-compaction` — abstract `CompactionEngine` ([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts))
 - `@deepseek-ai/dsh-credentials` — abstract `CredentialProvider` ([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts))
+- `@deepseek-ai/dsh-document-render` — abstract `DocumentRenderer` ([`packages/document/document-render/src/index.ts`](../packages/document/document-render/src/index.ts))
 - `@deepseek-ai/dsh-file-reference` — abstract `FileReferenceService` ([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts))
 - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts))
 - `@deepseek-ai/dsh-host-directory-picker` — abstract `DirectoryPicker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts))

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

@@ -550,6 +550,56 @@ export interface Config {
 
 来源:[`packages/credentials/credentials-local/src/index.ts:64`](../packages/credentials/credentials-local/src/index.ts)
 
+<a id="deepseek-aidsh-document-render-libreoffice"></a>
+
+## `@deepseek-ai/dsh-document-render-libreoffice`
+
+```ts config-catalog
+/** Provider concurrency and kit rendering/font configuration. */
+export interface Config {
+  /** Maximum simultaneous conversions; queued callers remain cancellable. */
+  maxConcurrentConversions: number
+  /** Maximum metadata-only jobs awaiting source admission. */
+  maxQueuedJobs: number
+  /** Maximum outstanding conversion readers. */
+  maxReaders: number
+  /** Maximum reserved bytes across admitted source reads and conversions. */
+  maxSourceBytes: number
+  /** Maximum concurrent background jobs; zero refuses speculative work. */
+  maxBackgroundConversions: number
+  /** Maximum retained content-addressed PDFs. */
+  maxCachedEntries: number
+  /** Maximum retained PDF bytes. */
+  maxCachedBytes: number
+  /** Maximum retained source-version aliases to cached content. */
+  maxSourceEntries: number
+  /** Conversion deadline in milliseconds; excludes the DSH queue. */
+  timeoutMs: number
+  /** Maximum authorized source bytes. */
+  maxInputBytes: number
+  /** Maximum complete PDF bytes. */
+  maxOutputBytes: number
+  /** Exported raster-image DPI. */
+  maxImageResolution: number
+  /** Maximum OOXML ZIP entries. */
+  maxArchiveEntries: number
+  /** Maximum total declared uncompressed OOXML bytes. */
+  maxUncompressedBytes: number
+  /** Absolute font roots; omission uses the kit's platform defaults. */
+  fontDirectories?: string[]
+  /** Ordered font-family preference groups; omission retains the kit defaults. */
+  fontFallbacks?: string[][]
+  /** Maximum physical font files indexed by each converter. */
+  maxFontFiles: number
+  /** Maximum individual font-file bytes. */
+  maxFontFileBytes: number
+  /** Maximum original font bytes loaded for a conversion. */
+  maxLoadedFontBytes: number
+}
+```
+
+源码: [`packages/document/document-render-libreoffice/src/index.ts:14`](../packages/document/document-render-libreoffice/src/index.ts)
+
 <a id="deepseek-aidsh-experimental-agent-team"></a>
 
 ## `@deepseek-ai/dsh-experimental-agent-team`
@@ -3800,6 +3850,7 @@ export interface Config {
 - `@deepseek-ai/dsh-attachment` — 抽象 `AttachmentStore`([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts))
 - `@deepseek-ai/dsh-compaction` — 抽象 `CompactionEngine`([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts))
 - `@deepseek-ai/dsh-credentials` — 抽象 `Credentials`([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts))
+- `@deepseek-ai/dsh-document-render` — 抽象 `DocumentRenderer`([`packages/document/document-render/src/index.ts`](../packages/document/document-render/src/index.ts))
 - `@deepseek-ai/dsh-file-reference` — 抽象 `FileReferenceService`([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts))
 - `@deepseek-ai/dsh-fs` — 抽象 `FileSystem`([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts))
 - `@deepseek-ai/dsh-host-directory-picker` — 抽象 `DirectoryPicker`([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts))

+ 2 - 2
docs/subsystems/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 docs/subsystems/README.md
-README.md: b4d6aa226979525ef658f9cf1d33d5ca3e59dcf6
-README.zh.md: 9134fcf37789911aec05dcdc9e9c3dc036a33873
+README.md: e6c0e0d1b6f7428c6af40af110d744afda6fadbf
+README.zh.md: d7b640a383e88c3aa0364682ddd8df2bf8ffce85

+ 1 - 0
docs/subsystems/README.md

@@ -30,6 +30,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures
 | [mcp.md](mcp.md) | external MCP connections, scoped tools and resources, server instructions, protocol results, and configuration ownership |
 | [user-questions.md](user-questions.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy |
 | [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit events, and answerer contracts |
+| [document-render.md](document-render.md) | authorized Office-to-PDF conversion, native/WASM engines, and bounded shared reuse |
 | [attachment.md](attachment.md) | durable image identity and metadata, validation inputs, verified reads, and the `AttachmentStore` seam |
 | [shell.md](shell.md) | the shell executor seam: `ShellExecRequest`/`Spec`, `ShellRunResult`, background `ShellProcess` handles |
 | [subprocess.md](subprocess.md) | the subprocess seam: fully-explicit `SubprocessSpawnSpec`, offset-based output readers, unclassified `SubprocessOutcome`, and the managed `DSH_*` environment vocabulary |

+ 1 - 0
docs/subsystems/README.zh.md

@@ -30,6 +30,7 @@
 | [mcp.md](mcp.zh.md) | 外部 MCP 连接、作用域工具与资源、服务器指令、协议结果与配置归属 |
 | [user-questions.md](user-questions.zh.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、提供方 API、错误分类体系 |
 | [approval.md](approval.zh.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计事件和 answerer 约定 |
+| [document-render.md](document-render.zh.md) | 已授权的 Office 到 PDF 转换、原生/WASM 引擎与有界共享复用 |
 | [attachment.md](attachment.zh.md) | 持久图片标识与元数据、校验输入、经校验读取,以及 `AttachmentStore` seam |
 | [shell.md](shell.zh.md) | shell 执行器 seam:`ShellExecRequest`/`Spec`、`ShellRunResult`、后台 `ShellProcess` 句柄 |
 | [subprocess.md](subprocess.zh.md) | 子进程 seam:完全显式的 `SubprocessSpawnSpec`、基于偏移的输出读取器、不含分类的 `SubprocessOutcome`,以及受管 `DSH_*` 环境词汇 |

+ 6 - 0
docs/subsystems/document-render.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 docs/subsystems/document-render.md
+document-render.md: f3e49fb45f2cc19956b6385cf7390825fa9d4c60
+document-render.zh.md: 79028858e9e3f95ac1a4618d3b9342b78aa29cf2

+ 62 - 0
docs/subsystems/document-render.md

@@ -0,0 +1,62 @@
+# Document rendering
+
+English | [中文](document-render.zh.md)
+
+The [document package family](../../packages/document/README.md) converts Office files to PDFs on the Node Host. Consumers authorize source reads and own presentation; the shared provider owns conversion, bounded admission, and transient PDF reuse. This subsystem creates no model-facing tool or Session event.
+
+## Ownership
+
+| Owner | Responsibility |
+|---|---|
+| [document-render](../../packages/document/document-render/README.md) | `ctx.documentRender`: authorized Office bytes and complete PDF results |
+| [document-render-libreoffice](../../packages/document/document-render-libreoffice/README.md) | Host concurrency, reusable kit converters, private scratch files, and bounded PDF reads |
+| [Web bundle](../../packages/bundle/web-app/README.md) | One configurable conversion provider shared by Host consumers |
+
+## Requests and results
+
+[`DocumentRenderRequest`](../../packages/document/document-render/src/types.ts) contains an already-authorized source key/version, optional stat size, a deferred `read(signal, maxBytes)` callback, foreground/background priority, and a `DocumentExtension`: `doc`, `docx`, `xls`, `xlsx`, `ppt`, or `pptx`. `DocumentRenderer.render(request, signal?)` returns one complete PDF result. Cancellation follows the caller and provider lifetimes; validation, output, and engine failures reject with a classified `DocumentRenderError`.
+
+`DocumentRenderPriority` is `foreground` for requested preview/QA and `background` for speculation. `DocumentSourceKey` brands the caller-owned authorized source locator. `DocumentRendererGeneration` brands a provider lifetime, and `DocumentRenderKey` brands its content identity; neither opaque value is parsed by consumers.
+
+| Result field | Meaning |
+|---|---|
+| `pdf` | Caller-owned `Uint8Array` containing the complete PDF |
+| `missingFonts` | Requested document font families unavailable to this conversion |
+| `cacheKey` | Opaque renderer generation plus extension/source-content identity |
+| `generation` | Provider lifetime; replacement invalidates cached PDF reuse |
+
+The provider admits the deferred read before allocating source bytes, shares conversions by content identity, and removes its private scratch directory before returning. Returned PDF bytes remain valid after provider disposal. Source and PDF bytes do not enter Session storage. Consumers can use [Workspace Files](../../packages/api/workspace-files/README.md) for authorized bounded reads.
+
+## Engine selection and limits
+
+The external [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API selects its precompiled engines. The kit has an independent version and release workflow, defined by the [release ownership decision](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md). Application builds install the published npm packages. macOS and Windows require their matching ARM64 or x64 native engine; Linux uses Node WASM. The [platform engine decision](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging. Invalid metadata, missing required assets, and conversion errors reject without switching engines. Conversion uses disk input and output paths on the Host, with no browser conversion engine or font RPC.
+
+The [Host provider configuration](../../packages/document/document-render-libreoffice/README.md#use-this-package) owns concurrency, deadlines, input/output limits, archive limits, image resolution, and font access. Native/WASM implementation and asset distribution belong to the kit workspace. System LibreOffice discovery, runtime engine downloads, persistent PDF caching, and model-facing rendering are outside this provider.
+
+<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
+
+<a id="cordis-surface"></a>
+
+## Cordis API
+
+Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+<a id="ctxdocumentrender--documentrenderer-abstract-seam"></a>
+
+### `ctx.documentRender` — `DocumentRenderer` (abstract seam)
+
+Load one provider subclass per context; consumers own source authorization.
+
+```ts cordis-catalog
+/**
+ * Convert Office bytes without modifying the source or writing Session events.
+ * @param request - authorized metadata and deferred bounded source read.
+ * @param signal - caller cancellation; provider disposal also stops active work.
+ * @returns caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently.
+ * @throws {DocumentRenderError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.
+ */
+abstract render(request: DocumentRenderRequest, signal?: AbortSignal): Promise<DocumentRenderResult>
+```
+
+Source: [`packages/document/document-render/src/index.ts`](../../packages/document/document-render/src/index.ts)
+<!-- END GENERATED cordis-surface -->

+ 62 - 0
docs/subsystems/document-render.zh.md

@@ -0,0 +1,62 @@
+# 文档渲染
+
+[English](document-render.md) | 中文
+
+[document 包族](../../packages/document/README.zh.md) 在 Node 宿主上将 Office 文件转换为 PDF。消费者负责源文件读取授权与展示;共享提供方负责转换、有界准入和临时 PDF 复用。此子系统不创建面向模型的工具或 Session 事件。
+
+## 所有权
+
+| 所有者 | 职责 |
+|---|---|
+| [document-render](../../packages/document/document-render/README.zh.md) | `ctx.documentRender`:已授权 Office 字节和完整 PDF 结果 |
+| [document-render-libreoffice](../../packages/document/document-render-libreoffice/README.zh.md) | Host 并发、可复用 kit 转换器、私有临时文件和有上限的 PDF 读取 |
+| [Web bundle](../../packages/bundle/web-app/README.zh.md) | 由宿主消费者共享的单个可配置转换提供方 |
+
+## 请求和结果
+
+[`DocumentRenderRequest`](../../packages/document/document-render/src/types.ts)包含已授权的源键与版本、可选 stat 大小、延迟的 `read(signal, maxBytes)` 回调、前台或后台优先级以及 `DocumentExtension`:`doc`、`docx`、`xls`、`xlsx`、`ppt` 或 `pptx`。`DocumentRenderer.render(request, signal?)` 返回一个完整 PDF 结果。取消遵循调用方和提供方生命周期;校验、输出和引擎失败以分类的 `DocumentRenderError` 拒绝。
+
+`DocumentRenderPriority` 对请求的预览或 QA 使用 `foreground`,对推测工作使用 `background`。`DocumentSourceKey` 为调用方拥有的已授权源定位符增加品牌类型。`DocumentRendererGeneration` 表示提供方生命周期,`DocumentRenderKey` 表示其内容身份;消费者不解析这两种不透明值。
+
+| 结果字段 | 含义 |
+|---|---|
+| `pdf` | 调用方拥有的 `Uint8Array`,包含完整 PDF |
+| `missingFonts` | 本次转换无法使用的文档请求字体名称 |
+| `cacheKey` | 不透明的渲染 generation 加扩展名与源内容身份 |
+| `generation` | 提供方生命周期;替换后缓存 PDF 不再可复用 |
+
+提供方先准入延迟读取,再分配源文件字节;按内容身份共享转换,并在返回前删除私有临时目录。返回的 PDF 字节在提供方释放后仍有效。源文件和 PDF 字节不会进入 Session 存储。消费者可通过[工作区文件](../../packages/api/workspace-files/README.zh.md)执行已授权的有界读取。
+
+## 引擎选择和限制
+
+外部 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API 选择其预编译引擎。kit 独立维护版本和发布流程,具体归属由[发布归属决策](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义。应用构建时安装已发布的 npm 包。macOS 和 Windows 要求匹配的 ARM64 或 x64 原生引擎;Linux 使用 Node WASM。[平台引擎决策](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装和打包规则。元数据无效、必需资源缺失和转换错误都会拒绝请求,不切换引擎。转换在 Host 使用磁盘输入输出路径,不使用浏览器转换引擎或字体 RPC。
+
+[Host 提供方配置](../../packages/document/document-render-libreoffice/README.zh.md#use-this-package)负责并发、期限、输入输出上限、归档上限、图像分辨率和字体访问。原生/WASM 实现和资产分发属于 kit 工作区。系统 LibreOffice 探测、运行时引擎下载、持久 PDF 缓存和面向模型的渲染不属于此提供方。
+
+<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
+
+<a id="cordis-surface"></a>
+
+## Cordis API
+
+Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+<a id="ctxdocumentrender--documentrenderer-abstract-seam"></a>
+
+### `ctx.documentRender` — `DocumentRenderer` (abstract seam)
+
+Load one provider subclass per context; consumers own source authorization.
+
+```ts cordis-catalog
+/**
+ * Convert Office bytes without modifying the source or writing Session events.
+ * @param request - authorized metadata and deferred bounded source read.
+ * @param signal - caller cancellation; provider disposal also stops active work.
+ * @returns caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently.
+ * @throws {DocumentRenderError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.
+ */
+abstract render(request: DocumentRenderRequest, signal?: AbortSignal): Promise<DocumentRenderResult>
+```
+
+Source: [`packages/document/document-render/src/index.ts`](../../packages/document/document-render/src/index.ts)
+<!-- END GENERATED cordis-surface -->

+ 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: 80ea3157dbd904e168e2809ce8aa0b8641c1b2d9
-workspace.zh.md: 727c612e58cdbe54b0ba0e6292803d4543e5a32e
+workspace.md: 50db26be228bf39880891c3c4ca7d47c097b9d3e
+workspace.zh.md: fd4110041fe1b22e580b17835e4a896b96d062c8

+ 10 - 0
docs/subsystems/workspace.md

@@ -386,6 +386,16 @@ Host Remote file reads and workspace directory observations over the composed fi
  */
 @Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
 
+/**
+ * Read a complete authorized file within a Host consumer's reserved byte capacity.
+ * @param workspaceFileScope - Session authorization and execution scope.
+ * @param path - absolute or workspace-relative file path.
+ * @param maxBytes - positive reserved capacity; the configured full-file cap still applies.
+ * @param signal - caller cancellation.
+ * @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel.
+ */
+async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise<WorkspaceFileBytes>
+
 /**
  * Read a complete file relative to another file's directory, including outside the workspace.
  * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.

+ 10 - 0
docs/subsystems/workspace.zh.md

@@ -386,6 +386,16 @@ Host Remote file reads and workspace directory observations over the composed fi
  */
 @Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
 
+/**
+ * Read a complete authorized file within a Host consumer's reserved byte capacity.
+ * @param workspaceFileScope - Session authorization and execution scope.
+ * @param path - absolute or workspace-relative file path.
+ * @param maxBytes - positive reserved capacity; the configured full-file cap still applies.
+ * @param signal - caller cancellation.
+ * @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel.
+ */
+async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise<WorkspaceFileBytes>
+
 /**
  * Read a complete file relative to another file's directory, including outside the workspace.
  * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.

+ 2 - 2
packages/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/README.md
-README.md: 113d5d11a0b108b84c0b8983602b7d99c0f79425
-README.zh.md: b86f98cc4c6ad8f748d3bd31d9a4bcaa7a10f27e
+README.md: 5f759989eda6588e707a0c80c3035d0ba51ec950
+README.zh.md: 8a2784f5a3f99c9842b10d98d89b6d40936380b2

+ 1 - 0
packages/README.md

@@ -56,6 +56,7 @@ Every package lives in exactly one group; new packages join existing groups, and
 | [`workflow/`](workflow/README.md) | Workflow seam, PTC process engine, and model-facing `workflow`/`ralph` tools |
 | [`webhook/`](webhook/README.md) | Verified external events, trusted rules, and fire-and-forget Workspace Sessions |
 | [`web/`](web/README.md) | Web capability family: seam, search/fetch providers, model-facing web tools |
+| [`document/`](document/README.md) | Shared Host Office-to-PDF conversion |
 | [`attachment/`](attachment/README.md) | Durable attachment identity, validation, local content-addressed storage |
 | [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy |
 | [`todo/`](todo/README.md) | The model-facing `todo_write` tool |

+ 1 - 0
packages/README.zh.md

@@ -56,6 +56,7 @@ harness 由 `packages/` 下的 npm 包组装而成,按能力系列分组:会
 | [`workflow/`](workflow/README.zh.md) | 工作流 seam、PTC 进程引擎、面向模型的 `workflow`/`ralph` 工具 |
 | [`webhook/`](webhook/README.zh.md) | 已验证外部事件、受信规则与即发即弃 Workspace 会话 |
 | [`web/`](web/README.zh.md) | Web 能力系列:seam、搜索/获取提供方、面向模型的 Web 工具 |
+| [`document/`](document/README.zh.md) | 共享宿主 Office 到 PDF 转换 |
 | [`attachment/`](attachment/README.zh.md) | 持久附件标识、校验、本地内容寻址存储 |
 | [`spill/`](spill/README.zh.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 |
 | [`todo/`](todo/README.zh.md) | 面向模型的 `todo_write` 工具 |

+ 2 - 2
packages/api/workspace-files/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/workspace-files/README.md
-README.md: f0f9cb1532fa65954415e10a5e248b775cdff83a
-README.zh.md: 8a510fa5a599037364095e221e7ecf6a8c7bb94a
+README.md: 9da773d65220e355c5dc41e372bc2bb0ca868d91
+README.zh.md: 56276e5da266b3ba81bfa83358df94d19e103ee4

+ 2 - 0
packages/api/workspace-files/README.md

@@ -82,6 +82,8 @@ The provider waits for the Host's `ready` frame before its first `stat`, queues
 
 One supervised `changes` stream serves every followed file in a Session. Followers match absolute paths with backslashes normalized to slashes. Carrier loss reconnects through the Gateway supervisor; a Host-ended or terminally failed feed ends its followers and leaves their last metadata readable until reopened. The last follower leaving disposes the stream, a successor waits for that disposal, and plugin teardown awaits all pending closes. The provider declares `ResourceProtocolMap.file`; the text preview declares its Sidebar line-navigation parameters.
 
+Host consumers can call `readAllBounded(scope, path, maxBytes, signal)` after reserving input capacity. The smaller of that reservation and `maxFileBytes` applies; the read allocates at most one additional overflow sentinel byte.
+
 -----
 
 <a id="understand-the-implementation"></a>

+ 2 - 0
packages/api/workspace-files/README.zh.md

@@ -82,6 +82,8 @@ kind: "package-reference"
 
 每个 Session 的所有被跟随文件共用一条受监督的 `changes` 流。跟随者按反斜杠归一为斜杠的绝对路径匹配。载体掉线由 Gateway 监督器重连;Host 结束或终态失败的流会结束其跟随者,最后的元数据仍可读取,直到重新打开。最后一个跟随者离开时释放流,后继流等待该释放完成,插件拆除等待所有在途关闭。提供者声明 `ResourceProtocolMap.file`;文本预览声明其 Sidebar 行号导航参数。
 
+Host 消费者可在预留输入容量后调用 `readAllBounded(scope, path, maxBytes, signal)`。采用预留值与 `maxFileBytes` 的较小值;读取最多额外分配一个超限哨兵字节。
+
 -----
 
 <a id="understand-the-implementation"></a>

+ 15 - 1
packages/api/workspace-files/src/index.ts

@@ -276,8 +276,22 @@ export class WorkspaceFiles extends TypertRemoteService {
    */
   @Remote
   async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes> {
+    return this.readAllBounded(workspaceFileScope, path, this.config.maxFileBytes, signal)
+  }
+
+  /**
+   * Read a complete authorized file within a Host consumer's reserved byte capacity.
+   * @param workspaceFileScope - Session authorization and execution scope.
+   * @param path - absolute or workspace-relative file path.
+   * @param maxBytes - positive reserved capacity; the configured full-file cap still applies.
+   * @param signal - caller cancellation.
+   * @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel.
+   */
+  async readAllBounded(
+    workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal,
+  ): Promise<WorkspaceFileBytes> {
     const { target, info } = await this.locateFile(workspaceFileScope, path, signal)
-    const limit = this.config.maxFileBytes
+    const limit = Math.min(this.config.maxFileBytes, maxBytes)
     if (info.size !== undefined && info.size > limit) {
       throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit })
     }

+ 11 - 0
packages/api/workspace-files/tests/read-all.spec.ts

@@ -51,6 +51,17 @@ describe('workspaceFiles.readAll', () => {
   })
 })
 
+it('applies the smaller Host reservation before reading a complete file', async () => {
+  await writeFile(join(harness.workspace, 'reserved'), '1234')
+  const read = vi.spyOn(harness.ctx.fs, 'readByteRange')
+  const files = harness.endpoint({ maxFileBytes: 8 })
+  expect(await failureOf(files.readAllBounded(harness.scope, 'reserved', 3, signal())))
+    .toEqual({ code: 'workspace-file/too-large', details: { path: 'reserved', limit: 3 } })
+  expect(read).not.toHaveBeenCalled()
+  expect((await files.readAllBounded(harness.scope, 'reserved', 4, signal())).bytes).toBe(4)
+  expect(read.mock.calls[0]![1]).toEqual({ offset: 0, length: 5 })
+})
+
 describe('workspaceFiles.readRelated', () => {
   it('resolves relative paths from the base file directory on the Host', async () => {
     await mkdir(join(harness.workspace, 'nested'))

+ 2 - 2
packages/bundle/web-app/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md
-README.md: c35bdf219c4c231e2ffde73a592b04070f96a4bc
-README.zh.md: 9a4047525da16cfb8249e4732fc2102f95b9d89f
+README.md: 0057516fb5bfba2708923e08af20f6e59f53f772
+README.zh.md: 1be494520d18311cae1f8dfca00c682b597f889d

+ 1 - 1
packages/bundle/web-app/README.md

@@ -73,7 +73,7 @@ Each browser session composes its own agent from the shipped presets (the `stand
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The bundle is one patch plus one runtime glue plugin. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, trust sampling, prompt sections, the bash variable, and the readiness announcements.
+The bundle is one patch plus one runtime glue plugin. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `document-render` row mounts one lazy [Office conversion provider](../../document/document-render-libreoffice/README.md) for Host consumers, including Desktop compositions using this bundle.
 
 ### Patch semantics
 

+ 1 - 1
packages/bundle/web-app/README.zh.md

@@ -73,7 +73,7 @@ dsh --profile web --no-open --port 8080
 <details>
 <summary>实现细节——点击展开</summary>
 
-本组合包是一份 patch 加一个运行时粘合插件。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的 workspace 与 message-feedback 行使用共享的 `storageDomain` 服务。patch 重述 base 刻意省略的表层专属值,插入仅 Web 使用的宿主行与浏览器名录,然后把 agent 层改由 preset 承载;粘合插件负责 dist 服务、信任采样、提示词段落、bash 变量与就绪宣告
+此 bundle 由一个补丁和一个运行时胶水插件组成。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、信任采样、提示词段落、bash 变量和就绪通知。`document-render` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/document-render-libreoffice/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方
 
 ### patch 语义
 

+ 3 - 0
packages/bundle/web-app/cordis.patch.yml

@@ -225,6 +225,9 @@
       name: '@deepseek-ai/dsh-client-ui-sidebar-right'
 
 
+    - id: document-render
+      name: '@deepseek-ai/dsh-document-render-libreoffice'
+
     # The right Sidebar's document tab: bounded file reads with selectable
     # Markdown, code, HTML, PDF, and plain-text renderers.
     - id: ui-sidebar-documentpreview

+ 3 - 1
packages/bundle/web-app/package.json

@@ -122,7 +122,9 @@
     "commander": "^15.0.0",
     "open": "^11.0.0",
     "@deepseek-ai/dsh-api-terminal-controller": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-sidebar-terminal": "workspace:^"
+    "@deepseek-ai/dsh-client-ui-sidebar-terminal": "workspace:^",
+    "@deepseek-ai/dsh-document-render": "workspace:^",
+    "@deepseek-ai/dsh-document-render-libreoffice": "workspace:^"
   },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^",

+ 76 - 0
packages/bundle/web-app/tests/document-conversion.e2e.ts

@@ -0,0 +1,76 @@
+/** The shipped shared provider converts Office bytes without presentation plugins. */
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Include, { applyEntryPatches } from '@deepseek-ai/cordis-plugin-include'
+import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
+import { DocumentSourceKey, type DocumentRenderRequest } from '@deepseek-ai/dsh-document-render'
+import * as LibreOfficeProvider from '@deepseek-ai/dsh-document-render-libreoffice'
+import { expect, it, onTestFinished } from 'vitest'
+
+it('loads one shared conversion row and retains caller-owned PDFs after disposal', async () => {
+  const directory = await mkdtemp(join(tmpdir(), 'dsh-conversion-loader-'))
+  const ctx = new Context()
+  onTestFinished(async () => {
+    try { await ctx.fiber.dispose() }
+    finally { await rm(directory, { recursive: true, force: true }) }
+  })
+  const rows = loadOverlayPatches('conversion-test', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url)))
+    .flatMap(patch => patch.insert ?? [])
+    .filter(row => row.name === '@deepseek-ai/dsh-document-render-libreoffice')
+  expect(rows.map(row => row.id)).toEqual(['document-render'])
+  const configured = applyEntryPatches(rows, [{ id: 'document-render', config: { maxConcurrentConversions: 1 } }],
+    (message) => { throw new Error(message) })
+  const configPath = join(directory, 'cordis.yml')
+  await writeFile(configPath, JSON.stringify(configured))
+  ctx.baseUrl = pathToFileURL(directory).href + '/'
+  await ctx.plugin(Loader)
+  ctx.loader.builtins.include = Include
+  // The Loader resolves the same source-plane module namespace as the test.
+  ctx.loader.internal = {
+    version: 'v2',
+    async import(specifier: string) {
+      if (specifier !== '@deepseek-ai/dsh-document-render-libreoffice') throw new Error(`Unexpected plugin: ${specifier}`)
+      return LibreOfficeProvider
+    },
+  } as unknown as NonNullable<typeof ctx.loader.internal>
+  await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
+  await ctx.loader.await()
+  const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'document-render')!
+  await entry.fiber!.await()
+  expect(ctx.get('documentRenderController')).toBeUndefined()
+  expect(ctx.get('skills')).toBeUndefined()
+  const bytes = await readFile(new URL('./fixtures/document-conversion.docx', import.meta.url))
+  let reads = 0
+  const request = (key: string): DocumentRenderRequest => ({
+    extension: 'docx', priority: 'foreground',
+    source: { key: DocumentSourceKey(key), version: 'fixture', bytes: bytes.length,
+      async read(signal, maxBytes) {
+        signal.throwIfAborted()
+        expect(bytes.length).toBeLessThanOrEqual(maxBytes)
+        reads++
+        return { bytes: Uint8Array.from(bytes), version: 'fixture' }
+      },
+    },
+  })
+  const provider = ctx.documentRender
+  const [first, second] = await Promise.all([provider.render(request('first')), provider.render(request('second'))])
+  expect(reads).toBe(2)
+  expect(Buffer.from(first.pdf).subarray(0, 5).toString()).toBe('%PDF-')
+  expect(first.cacheKey).toBe(second.cacheKey)
+  expect(first.pdf).toEqual(second.pdf)
+  expect(first.pdf).not.toBe(second.pdf)
+  expect(first.missingFonts).not.toBe(second.missingFonts)
+  first.pdf.fill(0)
+  first.missingFonts.push('caller-owned')
+  const cached = await provider.render(request('first'))
+  expect(reads).toBe(2)
+  expect(cached.pdf).toEqual(second.pdf)
+  expect(cached.missingFonts).toEqual(second.missingFonts)
+  await ctx.fiber.dispose()
+  expect(Buffer.from(cached.pdf).subarray(0, 5).toString()).toBe('%PDF-')
+  await expect(provider.render(request('first'))).rejects.toMatchObject({ code: 'unavailable' })
+})

BIN
packages/bundle/web-app/tests/fixtures/document-conversion.docx


+ 6 - 0
packages/document/README.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 packages/document/README.md
+README.md: 4ccf8e9c7267b7442ee08a218b51e50a4909aae3
+README.zh.md: c91beff7d3f6a028d9a1553308714c0328ab35de

+ 51 - 0
packages/document/README.md

@@ -0,0 +1,51 @@
+---
+description: "Package map for Host Office conversion and reusable PDF results."
+kind: "package-group"
+---
+
+# document/ — Office rendering
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Convert authorized Office files to reusable PDFs on the Host. The rendering family provides a shared conversion operation and a LibreOffice kit provider. macOS and Windows use their matching native engine; Linux uses Node WASM.
+
+## Table of Contents
+
+- [Packages](#packages)
+- [Related documentation](#related-documentation)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="packages"></a>
+## Packages
+
+Each package owns its configuration and lifetime rules; the subsystem reference describes their shared rendering operation.
+
+| Package | Role | ctx key |
+|---|---|---|
+| [document-render](document-render/README.md) | Authorized Office bytes to complete PDF results | `ctx.documentRender` |
+| [document-render-libreoffice](document-render-libreoffice/README.md) | Host kit provider with cancellable concurrency and private scratch files | `ctx.documentRender` |
+
+-----
+
+<a id="related-documentation"></a>
+## Related documentation
+
+Consumers own source authorization and presentation.
+
+- [Document rendering](../../docs/subsystems/document-render.md) — shared operation and generated service reference.
+- [Independent kit ownership](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) — engine distribution and application integration.
+- [Workspace Files](../api/workspace-files/README.md) — authorized bounded source reads.
+
+<a id="dev-note"></a>
+## Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>

+ 51 - 0
packages/document/README.zh.md

@@ -0,0 +1,51 @@
+---
+description: "Host Office 转换和可复用 PDF 结果的包索引。"
+kind: "package-group"
+---
+
+# document/ — Office 渲染
+
+[English](README.md) | 中文
+
+## 摘要
+
+在宿主上将已授权的 Office 文件转换为可复用的 PDF。渲染包族提供共享转换操作和 LibreOffice kit 提供方。macOS 和 Windows 使用匹配的原生引擎;Linux 使用 Node WASM。
+
+## 目录
+
+- [包](#packages)
+- [相关文档](#related-documentation)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="packages"></a>
+## 包
+
+每个包负责自身配置和生命周期规则;子系统参考描述共享渲染操作。
+
+| 包 | 职责 | ctx 键 |
+|---|---|---|
+| [document-render](document-render/README.zh.md) | 将已授权 Office 字节转换为完整 PDF 结果 | `ctx.documentRender` |
+| [document-render-libreoffice](document-render-libreoffice/README.zh.md) | 使用可取消并发和私有临时文件的 Host kit 提供方 | `ctx.documentRender` |
+
+-----
+
+<a id="related-documentation"></a>
+## 相关文档
+
+消费者负责源文件授权与展示。
+
+- [文档渲染](../../docs/subsystems/document-render.zh.md) — 共享操作和生成的服务参考。
+- [独立 kit 所有权](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md) — 引擎分发与应用集成。
+- [工作区文件](../api/workspace-files/README.zh.md) — 已授权的有界源文件读取。
+
+<a id="dev-note"></a>
+## 开发备注
+
+<details>
+<summary>维护者工作上下文 — 点击展开</summary>
+
+无。
+
+</details>

+ 6 - 0
packages/document/document-render-libreoffice/README.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 packages/document/document-render-libreoffice/README.md
+README.md: 3315498c928a5613809ad3f75a1d9fe3e4bc07d2
+README.zh.md: e39f5824d5f969d997914848ebdd6dc767186dc7

+ 97 - 0
packages/document/document-render-libreoffice/README.md

@@ -0,0 +1,97 @@
+---
+description: "Host Office conversion with the independently published LibreOffice kit."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-document-render-libreoffice
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Convert Office documents to PDFs on the Host computer. macOS and Windows use their matching native LibreOffice engine; Linux uses Node WASM. The provider accepts DOC, DOCX, XLS, XLSX, PPT, and PPTX. OOXML conversion returns missing-font names; binary Office conversion returns an empty list.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+The [Web bundle](../../bundle/web-app/README.md) mounts this provider as `document-render`. Independent compositions mount `@deepseek-ai/dsh-document-render-libreoffice` as a `cordis.yml` row.
+
+The provider depends on the independently published [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API at kit version `0.0.1`. npm selects the matching macOS/Windows native package or the Linux-only WASM package. A missing native engine rejects conversion on macOS/Windows without selecting WASM. The [platform engine decision](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging; the [release ownership decision](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) defines the independent kit and Harness responsibilities.
+
+| Field | Default | Meaning |
+|---|---|---|
+| `maxConcurrentConversions` | `2` | Maximum active converters; queued calls remain cancellable. |
+| `timeoutMs` | `60000` | Conversion deadline after a converter is acquired. |
+| `maxInputBytes` | `52428800` | Maximum source bytes. |
+| `maxOutputBytes` | `104857600` | Maximum complete PDF bytes. |
+| `maxImageResolution` | `192` | Maximum raster-image DPI; overrides the kit default of `144`. |
+| `fontFallbacks` | Kit defaults | Ordered font-family preference groups; each group requires at least two names containing non-whitespace characters. |
+
+The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-document-render-libreoffice) owns the full font, archive, and image settings. `fontDirectories` accepts absolute directories; omission uses the kit platform defaults. Explicit `fontFallbacks` replaces the kit's default groups. Installed requested fonts retain precedence, and other system fonts remain eligible for uncovered glyphs. Native engines can select installed metric-compatible fonts before these preferences.
+
+The provider retains successful PDFs by renderer generation, Office extension, and SHA-256 of the exact source bytes. A bounded source-version index avoids rereading known content after an authorized stat; content identity also shares conversion across different source paths. Least-recently-used PDFs leave at either retention limit, together with their aliases. Failures and oversized cache entries are not retained. Every result has independent PDF/font buffers. Ready alias hits consume no reader slot; active source locators are released when their last reader leaves. Reopening a source after its final reader cancels rereads its bytes before sharing by digest, even if another source kept the conversion alive or its PDF is ready.
+
+Admission bounds queued metadata, outstanding readers, active source-byte reservations, and conversions before invoking a source read. Unknown source sizes reserve `maxInputBytes`; known sizes reserve their stat size. Reads receive that capacity and may read one overflow sentinel byte. `maxSourceBytes` must cover `maxInputBytes`. The final reader allowance is reserved for foreground work. Setting `maxBackgroundConversions` to zero rejects background joins to queued and running work; completed alias hits remain available. Background jobs wait while any foreground job is queued, including when it awaits source capacity. Foreground joins promote queued prewarming; when its last foreground reader leaves, the queued job returns to background priority and eligible work can start immediately. Foreground admission can evict queued speculation. Background concurrency leaves a foreground slot when total concurrency exceeds one. A running prewarm keeps its background admission slot until settlement, even after promotion. The final reader cancels shared work. Removing a queued foreground blocker immediately admits other eligible work; active reservations remain held until actual read/conversion cleanup settles.
+
+The defaults retain 8 PDFs/128 MiB and 64 source aliases, admit 32 readers and 8 queued jobs, reserve at most 100 MiB of source bytes, and allow one background conversion. These limits bound owned requests and binary payloads, not engine RSS, base64 transport expansion, caller-retained results, or PDF.js page rendering.
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+Invalid engine metadata, missing required assets, and conversion errors reject the request instead of switching engines. The shared WASM engine uses LibreOffice's CPU image filter. Native conversion uses its separate platform engine.
+
+Each concurrent slot lazily creates and reuses one kit converter. The provider writes authorized input into a private temporary directory, reads a bounded regular PDF, and removes the directory before settling. Canceling an admitted reader does not block later queued work. Reader cancellation releases only that reader; the final reader and provider disposal cancel shared work. Disposal reports `unavailable` to outstanding readers and joins conversions and converter teardown. No runtime invariant companion is published because active operations and scratch cleanup have one lifetime owner.
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+- [Rendering service](../document-render/README.md) — input and result ownership.
+- [Workspace Files](../../api/workspace-files/README.md) — Session file authorization and bounded reads.
+
+-----
+
+<a id="model-experience"></a>
+## Model Experience
+
+None, as this package converts bytes without model-facing tools, messages, or Session events.
+
+#### KV Cache effect
+
+None; conversion does not construct or modify model requests.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- Conversion fidelity and installed native/WASM assets belong to `@deepseek-ai/libreoffice-kit`; this provider neither searches for system LibreOffice nor downloads an engine at runtime.
+- Queue waiting time is not bounded by `timeoutMs`, which starts only when kit conversion begins.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>

+ 97 - 0
packages/document/document-render-libreoffice/README.zh.md

@@ -0,0 +1,97 @@
+---
+description: "通过独立发布的 LibreOffice kit 在 Host 转换 Office 文档。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-document-render-libreoffice
+
+[English](README.md) | 中文
+
+## 概述
+
+在宿主计算机上将 Office 文档转换为 PDF。macOS 和 Windows 使用匹配的原生 LibreOffice 引擎;Linux 使用 Node WASM。提供方支持 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。OOXML 转换返回缺失字体名称;二进制 Office 转换返回空列表。
+
+## 目录
+
+- [使用此包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [延伸阅读](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延后工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用此包
+
+[Web bundle](../../bundle/web-app/README.zh.md)以 `document-render` 条目挂载此提供方。独立组合通过 `cordis.yml` 条目挂载 `@deepseek-ai/dsh-document-render-libreoffice`。
+
+此 provider 依赖独立发布的 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API,kit 版本为 `0.0.1`。npm 选择匹配的 macOS/Windows 原生包或仅限 Linux 的 WASM 包。macOS/Windows 缺少原生引擎时拒绝转换,不会选择 WASM。[平台引擎决策](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装与打包策略;[发布归属决策](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义独立 kit 与 Harness 各自的职责。
+
+| 字段 | 默认值 | 含义 |
+|---|---|---|
+| `maxConcurrentConversions` | `2` | 最大活跃转换器数量;排队调用仍可取消。 |
+| `timeoutMs` | `60000` | 获取转换器后的转换期限。 |
+| `maxInputBytes` | `52428800` | 最大源字节数。 |
+| `maxOutputBytes` | `104857600` | 最大完整 PDF 字节数。 |
+| `maxImageResolution` | `192` | 最大光栅图像 DPI;覆盖 kit 的默认值 `144`。 |
+| `fontFallbacks` | kit 默认值 | 有序字体族优先组;每组至少包含两个含非空白字符的名称。 |
+
+[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-document-render-libreoffice)定义全部字体、归档和图像设置。`fontDirectories` 接受绝对目录;省略时使用 kit 的平台默认值。显式 `fontFallbacks` 替换 kit 的默认分组。已安装的请求字体仍优先使用,缺失字形仍可由其他系统字体提供。原生引擎可能在应用这些优先规则前选中已安装的度量兼容字体。
+
+提供方按渲染 generation、Office 扩展名和精确源字节的 SHA-256 保留成功 PDF。有界的源版本索引在授权 stat 后避免重读已知内容;内容标识也会在不同源路径之间共享转换。达到任一保留上限时,最近最少使用的 PDF 及其别名一同移除。不保留失败或超过缓存上限的结果。每个结果具有独立的 PDF 与字体缓冲区。已就绪别名命中不占用读取方名额;同一源的最后一个读取方离开时,立即释放其在途定位信息。源的最后一个读取方取消后,再次打开该源会重新读取字节,再按内容摘要共享转换,即使其他源仍保持该转换运行或其 PDF 已就绪。
+
+准入在调用源读取前限制排队元数据、未完成读取方、活动源字节预留和转换。未知源大小预留 `maxInputBytes`;已知大小预留 stat 字节数。读取收到该容量,最多额外读取一个超限哨兵字节。`maxSourceBytes` 必须覆盖 `maxInputBytes`。最后一个读取方额度预留给前台。`maxBackgroundConversions` 设为零时,拒绝后台读取方加入排队或运行中的工作;仍可命中已完成的别名缓存。只要仍有前台任务排队,后台任务就继续等待,包括前台正在等待源容量的情况。前台加入会提升排队预热;最后一个前台读取方离开后,排队任务恢复后台优先级,符合条件的工作可立即开始。前台准入也可移除排队推测工作。总并发大于一时,后台并发为前台保留一个槽位。正在运行的预热即使被提权,也保留后台准入槽位直至结束。最后一个读取方取消共享工作。移除排队的前台阻塞任务后,其他符合条件的工作立即准入;实际读取、转换和清理完成前仍保留活动预留容量。
+
+默认保留 8 个 PDF、128 MiB 和 64 个源别名,准入 32 个读取方与 8 个排队任务,最多预留 100 MiB 源字节,并允许一个后台转换。这些限制约束拥有的请求和二进制载荷,不约束引擎 RSS、base64 传输膨胀、调用方保留的结果或 PDF.js 页面渲染。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 理解实现
+
+<details>
+<summary>实现内部细节 — 点击展开</summary>
+
+引擎元数据无效、必需资源缺失和转换错误会拒绝请求,不会切换引擎。共享的 WASM 引擎使用 LibreOffice 的 CPU 图像过滤器。原生转换使用独立的平台引擎。
+
+每个并发槽按需创建并复用一个 kit 转换器。提供方将已授权输入写入私有临时目录,读取有大小上限的普通 PDF 文件,并在完成前删除目录。取消已准入的读取方不会阻塞后续排队工作。读取方取消只会释放该读取方;最后一个读取方和提供方卸载会取消共享工作。卸载向未完成读取方报告 `unavailable`,并等待转换及转换器清理结束。活跃操作和临时目录清理由同一生命周期负责,因此不发布运行时不变量伴随入口。
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## 延伸阅读
+
+- [渲染服务](../document-render/README.zh.md) — 输入和结果所有权。
+- [工作区文件](../../api/workspace-files/README.zh.md) — Session 文件授权与有界读取。
+
+-----
+
+<a id="model-experience"></a>
+## 模型体验
+
+无,因为此包仅转换字节,不提供面向模型的工具、消息或 Session 事件。
+
+#### KV Cache effect
+
+无;转换不会构造或修改模型请求。
+
+## 已知限制与延后工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- 转换保真度和已安装的原生/WASM 资产由 `@deepseek-ai/libreoffice-kit` 负责;此提供方不查找系统 LibreOffice,也不在运行时下载引擎。
+- `timeoutMs` 仅在 kit 开始转换时计时,不限制队列等待时间。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者工作上下文 — 点击展开</summary>
+
+无。
+
+</details>

+ 43 - 0
packages/document/document-render-libreoffice/package.json

@@ -0,0 +1,43 @@
+{
+  "name": "@deepseek-ai/dsh-document-render-libreoffice",
+  "description": "Host Office conversion with native macOS/Windows engines and Linux Node WASM",
+  "version": "0.1.6-alpha.1",
+  "publishConfig": {
+    "access": "public"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "license": "MIT",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/document/document-render-libreoffice"
+  },
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts"
+  ],
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-document-render": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-document-render": "workspace:^",
+    "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/cordis-plugin-include": "workspace:^"
+  },
+  "dependencies": {
+    "@deepseek-ai/libreoffice-kit": "0.0.1",
+    "@deepseek-ai/schemastery": "workspace:^"
+  }
+}

+ 169 - 0
packages/document/document-render-libreoffice/src/index.ts

@@ -0,0 +1,169 @@
+/** Host LibreOffice kit provider with reusable converters and private disk input/output. */
+import { randomUUID } from 'node:crypto'
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { isAbsolute, join } from 'node:path'
+import type { Context } from '@deepseek-ai/cordis'
+import { createConverter, type Converter, type ConverterOptions } from '@deepseek-ai/libreoffice-kit'
+import { DocumentRenderer, DocumentRenderError, DocumentRendererGeneration, type DocumentExtension, type DocumentRenderRequest, type DocumentRenderResult } from '@deepseek-ai/dsh-document-render'
+import z from '@deepseek-ai/schemastery'
+import { readPdf } from './output.ts'
+import { RenderQueue } from './queue.ts'
+
+/** Provider concurrency and kit rendering/font configuration. */
+export interface Config {
+  /** Maximum simultaneous conversions; queued callers remain cancellable. */
+  maxConcurrentConversions: number
+  /** Maximum metadata-only jobs awaiting source admission. */
+  maxQueuedJobs: number
+  /** Maximum outstanding conversion readers. */
+  maxReaders: number
+  /** Maximum reserved bytes across admitted source reads and conversions. */
+  maxSourceBytes: number
+  /** Maximum concurrent background jobs; zero refuses speculative work. */
+  maxBackgroundConversions: number
+  /** Maximum retained content-addressed PDFs. */
+  maxCachedEntries: number
+  /** Maximum retained PDF bytes. */
+  maxCachedBytes: number
+  /** Maximum retained source-version aliases to cached content. */
+  maxSourceEntries: number
+  /** Conversion deadline in milliseconds; excludes the DSH queue. */
+  timeoutMs: number
+  /** Maximum authorized source bytes. */
+  maxInputBytes: number
+  /** Maximum complete PDF bytes. */
+  maxOutputBytes: number
+  /** Exported raster-image DPI. */
+  maxImageResolution: number
+  /** Maximum OOXML ZIP entries. */
+  maxArchiveEntries: number
+  /** Maximum total declared uncompressed OOXML bytes. */
+  maxUncompressedBytes: number
+  /** Absolute font roots; omission uses the kit's platform defaults. */
+  fontDirectories?: string[]
+  /** Ordered font-family preference groups; omission retains the kit defaults. */
+  fontFallbacks?: string[][]
+  /** Maximum physical font files indexed by each converter. */
+  maxFontFiles: number
+  /** Maximum individual font-file bytes. */
+  maxFontFileBytes: number
+  /** Maximum original font bytes loaded for a conversion. */
+  maxLoadedFontBytes: number
+}
+
+/** Deployment defaults resolved before provider construction. */
+export const Config: z<Partial<Config>, Config> = z.object({
+  maxConcurrentConversions: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(2),
+  maxQueuedJobs: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(8),
+  maxReaders: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(32),
+  maxSourceBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(104857600),
+  maxBackgroundConversions: z.natural().min(0).max(Number.MAX_SAFE_INTEGER).default(1),
+  maxCachedEntries: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(8),
+  maxCachedBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(134217728),
+  maxSourceEntries: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(64),
+  timeoutMs: z.natural().min(1).max(2_147_483_647).default(60_000),
+  maxInputBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER - 1).default(50 * 1024 * 1024),
+  maxOutputBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER - 1).default(100 * 1024 * 1024),
+  maxImageResolution: z.natural().min(1).max(2_147_483_647).default(192),
+  maxArchiveEntries: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(10_000),
+  maxUncompressedBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(250 * 1024 * 1024),
+  fontDirectories: z.array(z.string().min(1)).extra('default', undefined),
+  fontFallbacks: z.array(z.array(z.string().pattern(/\S/)).min(2)).extra('default', undefined),
+  maxFontFiles: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(20_000),
+  maxFontFileBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(256 * 1024 * 1024),
+  maxLoadedFontBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(512 * 1024 * 1024),
+})
+
+interface Slot {
+  busy: boolean
+  converter?: Promise<Converter>
+}
+
+/** A provider lifetime owns all converters, queued calls, and temporary files. */
+export class LibreOfficeRenderer extends DocumentRenderer {
+  static Config = Config
+  readonly generation = DocumentRendererGeneration(randomUUID())
+  private readonly slots: Slot[] = []
+  private readonly queue: RenderQueue
+  private readonly options: ConverterOptions
+
+  /**
+   * @param ctx - owning Host context.
+   * @param config - resolved rendering, font, and concurrency limits.
+   */
+  constructor(ctx: Context, private readonly config: Config) {
+    super(ctx)
+    if (config.fontDirectories?.some(path => !isAbsolute(path))) throw new Error('fontDirectories must contain absolute paths.')
+    if (config.maxSourceBytes < config.maxInputBytes) throw new Error('maxSourceBytes must be at least maxInputBytes.')
+    const { fontDirectories, fontFallbacks, timeoutMs, maxInputBytes, maxOutputBytes, maxImageResolution,
+      maxArchiveEntries, maxUncompressedBytes, maxFontFiles, maxFontFileBytes, maxLoadedFontBytes } = config
+    this.options = { timeoutMs, maxInputBytes, maxOutputBytes, maxImageResolution, maxArchiveEntries, maxUncompressedBytes,
+      maxFontFiles, maxFontFileBytes, maxLoadedFontBytes,
+      ...(fontDirectories === undefined ? {} : { fontDirectories }),
+      ...(fontFallbacks === undefined ? {} : { fontFallbacks }),
+    }
+    this.queue = new RenderQueue(config, this.generation, (bytes, extension, signal) => this.convert(bytes, extension, signal))
+    ctx.effect(() => async () => {
+      await this.queue.dispose()
+      const results = await Promise.allSettled(this.slots.map(async (slot) => {
+        const converter = await slot.converter
+        await converter?.dispose()
+      }))
+      const failures = results.filter(result => result.status === 'rejected')
+      if (failures.length > 0) throw new AggregateError(failures.map((result): unknown => result.reason), 'LibreOffice converter disposal failed.')
+    })
+  }
+
+  render(request: DocumentRenderRequest, signal?: AbortSignal): Promise<DocumentRenderResult> {
+    return this.queue.read(request, signal)
+  }
+
+  private async convert(bytes: Uint8Array, extension: DocumentExtension, signal: AbortSignal): Promise<Pick<DocumentRenderResult, 'pdf' | 'missingFonts'>> {
+    signal.throwIfAborted()
+    let slot = this.slots.find(candidate => !candidate.busy)
+    if (slot === undefined) { slot = { busy: false }; this.slots.push(slot) }
+    slot.busy = true
+    let directory: string | undefined
+    try {
+      if (slot.converter === undefined) {
+        slot.converter = createConverter(this.options).catch((error: unknown) => {
+          delete slot.converter
+          throw error
+        })
+      }
+      const converter = await slot.converter
+      signal.throwIfAborted()
+      directory = await mkdtemp(join(tmpdir(), 'dsh-document-render-'))
+      const inputPath = join(directory, `source.${extension}`)
+      const outputPath = join(directory, 'converted.pdf')
+      await writeFile(inputPath, bytes, { flag: 'wx', mode: 0o600, signal })
+      signal.throwIfAborted()
+      const result = await converter.render({ inputPath, outputPath }, signal)
+      signal.throwIfAborted()
+      let pdf: Uint8Array
+      try { pdf = await readPdf(outputPath, this.config.maxOutputBytes, signal) }
+      catch (cause) {
+        if (cause instanceof DocumentRenderError) throw cause
+        throw new DocumentRenderError('invalid-output', 'The converter PDF could not be read.', { cause })
+      }
+      signal.throwIfAborted()
+      return { pdf, missingFonts: result.missingFonts }
+    } catch (cause) {
+      signal.throwIfAborted()
+      if (cause instanceof DocumentRenderError) throw cause
+      const code = typeof cause === 'object' && cause !== null && 'code' in cause ? cause.code : undefined
+      switch (code) {
+        case 'input-too-large': case 'output-too-large': case 'invalid-document': case 'unsupported-format':
+        case 'invalid-output': case 'timeout': case 'unavailable':
+          throw new DocumentRenderError(code, 'LibreOffice conversion failed.', { cause })
+        default: throw new DocumentRenderError('failed', 'LibreOffice conversion failed.', { cause })
+      }
+    } finally {
+      try { if (directory !== undefined) await rm(directory, { recursive: true, force: true }) }
+      finally { slot.busy = false }
+    }
+  }
+}
+
+export default LibreOfficeRenderer

+ 39 - 0
packages/document/document-render-libreoffice/src/output.ts

@@ -0,0 +1,39 @@
+/** Read only the bounded, regular PDF created inside a private task directory. */
+import { constants } from 'node:fs'
+import { lstat, open } from 'node:fs/promises'
+import { DocumentRenderError } from '@deepseek-ai/dsh-document-render'
+
+/**
+ * Refuse missing, link-shaped, oversized, truncated, and non-PDF output.
+ * @param path - kit output path in the provider-owned scratch directory.
+ * @param limit - inclusive PDF byte limit.
+ * @param signal - conversion lifetime.
+ * @returns complete bytes independent of the scratch file.
+ */
+export async function readPdf(path: string, limit: number, signal: AbortSignal): Promise<Uint8Array> {
+  signal.throwIfAborted()
+  const entry = await lstat(path)
+  if (!entry.isFile()) throw new DocumentRenderError('invalid-output', 'The converter output is not a regular file.')
+  const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW)
+  try {
+    const info = await file.stat()
+    if (!info.isFile()) throw new DocumentRenderError('invalid-output', 'The converter output is not a regular file.')
+    if (info.size > limit) throw new DocumentRenderError('output-too-large', 'The converted PDF exceeds maxOutputBytes.')
+    const bytes = Buffer.alloc(info.size + 1)
+    let length = 0
+    while (length < bytes.length) {
+      signal.throwIfAborted()
+      const read = await file.read(bytes, length, bytes.length - length, null)
+      if (read.bytesRead === 0) break
+      length += read.bytesRead
+    }
+    signal.throwIfAborted()
+    if (length > limit) throw new DocumentRenderError('output-too-large', 'The converted PDF exceeds maxOutputBytes.')
+    const pdf = bytes.subarray(0, length)
+    if (length !== info.size || !pdf.subarray(0, 8).toString('ascii').match(/^%PDF-\d\.\d/u)
+      || !pdf.subarray(-1024).toString('ascii').trimEnd().endsWith('%%EOF')) {
+      throw new DocumentRenderError('invalid-output', 'The converter did not produce a complete PDF.')
+    }
+    return pdf
+  } finally { await file.close() }
+}

+ 232 - 0
packages/document/document-render-libreoffice/src/queue.ts

@@ -0,0 +1,232 @@
+/** Bounded source admission, shared content conversion, and caller-owned PDF delivery. */
+import { createHash } from 'node:crypto'
+import { DocumentRenderError, DocumentRenderKey, type DocumentExtension, type DocumentRendererGeneration,
+  type DocumentRenderRequest, type DocumentRenderResult } from '@deepseek-ai/dsh-document-render'
+import type { Config } from './index.ts'
+
+type Converted = Pick<DocumentRenderResult, 'pdf' | 'missingFonts'>
+type Convert = (bytes: Uint8Array, extension: DocumentExtension, signal: AbortSignal) => Promise<Converted>
+interface Reader {
+  job: Job
+  readonly source: string
+  readonly priority: DocumentRenderRequest['priority']
+  readonly resolve: (value: DocumentRenderResult) => void
+  readonly reject: (error: unknown) => void
+  readonly cleanup: () => void
+}
+interface Job {
+  readonly request: DocumentRenderRequest
+  readonly controller: AbortController
+  readonly readers: Set<Reader>
+  readonly sources: Set<string>
+  priority: DocumentRenderRequest['priority']
+  state: 'queued' | 'running' | 'finished'
+}
+
+/** One renderer generation owns every queued source, conversion, reader, and retained PDF. */
+export class RenderQueue {
+  private readonly ready = new Map<DocumentRenderKey, DocumentRenderResult>()
+  private readonly aliases = new Map<string, DocumentRenderKey>()
+  private readonly sources = new Map<string, Job>()
+  private readonly digests = new Map<DocumentRenderKey, Job>()
+  private readonly queue: Job[] = []
+  private readonly tasks = new Set<Promise<void>>()
+  private readonly jobs = new Set<Job>()
+  private cachedBytes = 0
+  private sourceBytes = 0
+  private running = 0
+  private background = 0
+  private readers = 0
+  private disposed = false
+
+  /**
+   * @param config - validated queue, source, reader, and completed-result limits.
+   * @param generation - provider lifetime; prevents reuse after engine or font replacement.
+   * @param convert - executes one admitted conversion and settles after scratch cleanup.
+   */
+  constructor(private readonly config: Config, private readonly generation: DocumentRendererGeneration,
+    private readonly convert: Convert) {}
+
+  /**
+   * Admit metadata before loading source bytes and share conversion across authorized readers.
+   * @param request - already-authorized metadata and deferred bounded source read.
+   * @param signal - this reader's cancellation; the final reader cancels shared work.
+   * @returns independent PDF bytes; busy or canceled readers reject without releasing active engine capacity early.
+   */
+  async read(request: DocumentRenderRequest, signal?: AbortSignal): Promise<DocumentRenderResult> {
+    signal?.throwIfAborted()
+    if (this.disposed) throw this.unavailable()
+    if (request.source.bytes !== undefined && request.source.bytes > this.config.maxInputBytes) {
+      throw new DocumentRenderError('input-too-large', 'The Office source exceeds maxInputBytes.')
+    }
+    const source = JSON.stringify([request.source.key, request.source.version, request.extension])
+    const alias = this.aliases.get(source)
+    const cached = alias === undefined ? undefined : this.ready.get(alias)
+    if (cached !== undefined) {
+      this.ready.delete(cached.cacheKey)
+      this.ready.set(cached.cacheKey, cached)
+      this.aliases.delete(source)
+      this.aliases.set(source, cached.cacheKey)
+      return this.copy(cached)
+    }
+    const readerLimit = request.priority === 'background' ? this.config.maxReaders - 1 : this.config.maxReaders
+    if (this.readers >= readerLimit) throw this.busy()
+    if (request.priority === 'background' && this.config.maxBackgroundConversions === 0) throw this.busy()
+    let job = this.sources.get(source)
+    if (job === undefined) {
+      if (this.queue.length >= this.config.maxQueuedJobs) {
+        const obsolete = request.priority === 'foreground' ? this.queue.find(item => item.priority === 'background') : undefined
+        if (obsolete === undefined) throw this.busy()
+        this.fail(obsolete, this.busy())
+      }
+      job = { request, priority: request.priority, controller: new AbortController(), readers: new Set(), sources: new Set([source]), state: 'queued' }
+      this.sources.set(source, job)
+      this.jobs.add(job)
+      this.queue.push(job)
+    }
+    const shared = job
+    if (request.priority === 'foreground') shared.priority = 'foreground'
+    this.readers++
+    const promise = new Promise<DocumentRenderResult>((resolve, reject) => {
+      const abort = (): void => {
+        this.release(reader)
+        const reason: unknown = signal?.reason
+        reject(reason instanceof Error ? reason : new Error('Document rendering cancelled', { cause: reason }))
+        if (reader.job.readers.size === 0) this.cancel(reader.job)
+        // Cancellation or demotion can unblock queued background work.
+        this.drain()
+      }
+      const reader: Reader = { job: shared, source, priority: request.priority, resolve, reject, cleanup: () => signal?.removeEventListener('abort', abort) }
+      shared.readers.add(reader)
+      signal?.addEventListener('abort', abort, { once: true })
+    })
+    this.drain()
+    return promise
+  }
+
+  /** Cancel all readers and wait until actual reads, conversions, and scratch cleanup finish. */
+  async dispose(): Promise<void> {
+    this.disposed = true
+    for (const job of this.jobs) this.fail(job, this.unavailable())
+    this.ready.clear()
+    this.aliases.clear()
+    this.cachedBytes = 0
+    await Promise.allSettled(this.tasks)
+  }
+
+  private busy(): DocumentRenderError { return new DocumentRenderError('busy', 'The document renderer has reached its admission limit.') }
+  private unavailable(): DocumentRenderError { return new DocumentRenderError('unavailable', 'The document renderer is unavailable.') }
+  private copy(result: DocumentRenderResult): DocumentRenderResult {
+    return { ...result, pdf: Uint8Array.from(result.pdf), missingFonts: [...result.missingFonts] }
+  }
+  private release(reader: Reader): void {
+    reader.job.readers.delete(reader)
+    if (reader.job.state === 'queued') {
+      reader.job.priority = [...reader.job.readers].some(other => other.priority === 'foreground') ? 'foreground' : 'background'
+    }
+    if (![...reader.job.readers].some(other => other.source === reader.source)) {
+      reader.job.sources.delete(reader.source)
+      if (this.sources.get(reader.source) === reader.job) this.sources.delete(reader.source)
+    }
+    reader.cleanup()
+    this.readers--
+  }
+  private cancel(job: Job): void {
+    job.controller.abort()
+    if (job.state === 'queued') {
+      this.queue.splice(this.queue.indexOf(job), 1)
+      job.state = 'finished'
+      this.jobs.delete(job)
+    }
+  }
+  private fail(job: Job, error: unknown): void {
+    for (const reader of job.readers) { this.release(reader); reader.reject(error) }
+    this.cancel(job)
+  }
+  private reservation(job: Job): number { return Math.max(1, job.request.source.bytes ?? this.config.maxInputBytes) }
+
+  private drain(): void {
+    while (!this.disposed && this.running < this.config.maxConcurrentConversions) {
+      const eligible = (job: Job): boolean => this.sourceBytes + this.reservation(job) <= this.config.maxSourceBytes
+        && (job.priority === 'foreground' || this.background < Math.min(this.config.maxBackgroundConversions, Math.max(1, this.config.maxConcurrentConversions - 1)))
+      const foregroundWaiting = this.queue.some(item => item.priority === 'foreground')
+      const job = this.queue.find(item => (!foregroundWaiting || item.priority === 'foreground') && eligible(item))
+      if (job === undefined) return
+      this.queue.splice(this.queue.indexOf(job), 1)
+      job.state = 'running'
+      const background = job.priority === 'background'
+      const reserved = this.reservation(job)
+      this.running++
+      if (background) this.background++
+      this.sourceBytes += reserved
+      const task = this.execute(job, reserved).catch((error: unknown) => { this.fail(job, error) }).finally(() => {
+        this.running--
+        if (background) this.background--
+        this.sourceBytes -= reserved
+        job.state = 'finished'
+        this.jobs.delete(job)
+        this.tasks.delete(task)
+        this.drain()
+      })
+      this.tasks.add(task)
+    }
+  }
+
+  private async execute(job: Job, reserved: number): Promise<void> {
+    const signal = job.controller.signal
+    signal.throwIfAborted()
+    const input = await job.request.source.read(signal, reserved)
+    signal.throwIfAborted()
+    if (input.version !== job.request.source.version) throw new DocumentRenderError('source-changed', 'The source changed while waiting for conversion.')
+    if (input.bytes.byteLength > reserved) throw new DocumentRenderError('input-too-large', 'The source exceeds its reserved read capacity.')
+    const digest = createHash('sha256').update(job.request.extension).update('\0').update(input.bytes).digest('hex')
+    const key = DocumentRenderKey(`${this.generation}:${digest}`)
+    const cached = this.ready.get(key)
+    if (cached !== undefined) {
+      this.ready.delete(key)
+      this.ready.set(key, cached)
+      this.finish(job, cached)
+      return
+    }
+    const existing = this.digests.get(key)
+    if (existing !== undefined && !existing.controller.signal.aborted) {
+      if (job.priority === 'foreground') existing.priority = 'foreground'
+      for (const reader of job.readers) { reader.job = existing; existing.readers.add(reader) }
+      job.readers.clear()
+      for (const source of job.sources) { existing.sources.add(source); this.sources.set(source, existing) }
+      job.sources.clear()
+      return
+    }
+    this.digests.set(key, job)
+    try {
+      const converted = await this.convert(input.bytes, job.request.extension, signal)
+      signal.throwIfAborted()
+      const result = { ...converted, cacheKey: key, generation: this.generation }
+      this.retain(result)
+      this.finish(job, result)
+    } finally { if (this.digests.get(key) === job) this.digests.delete(key) }
+  }
+
+  private finish(job: Job, result: DocumentRenderResult): void {
+    for (const source of job.sources) this.sources.delete(source)
+    if (this.ready.has(result.cacheKey)) {
+      for (const source of job.sources) {
+        this.aliases.delete(source)
+        this.aliases.set(source, result.cacheKey)
+        while (this.aliases.size > this.config.maxSourceEntries) this.aliases.delete(this.aliases.keys().next().value as string)
+      }
+    }
+    for (const reader of job.readers) { this.release(reader); reader.resolve(this.copy(result)) }
+  }
+  private retain(result: DocumentRenderResult): void {
+    if (result.pdf.byteLength > this.config.maxCachedBytes) return
+    while (this.ready.size >= this.config.maxCachedEntries || this.cachedBytes + result.pdf.byteLength > this.config.maxCachedBytes) {
+      const [key, oldest] = this.ready.entries().next().value as [DocumentRenderKey, DocumentRenderResult]
+      this.ready.delete(key)
+      this.cachedBytes -= oldest.pdf.byteLength
+      for (const [source, digest] of this.aliases) if (digest === key) this.aliases.delete(source)
+    }
+    this.ready.set(result.cacheKey, result)
+    this.cachedBytes += result.pdf.byteLength
+  }
+}

+ 64 - 0
packages/document/document-render-libreoffice/tests/output.spec.ts

@@ -0,0 +1,64 @@
+/** PDF output checks use opened-file metadata and enforce byte bounds during reads. */
+import { appendFile, mkdtemp, rm, stat, writeFile, type FileHandle } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
+import { readPdf } from '../src/output.ts'
+
+const io = vi.hoisted(() => ({ opened: undefined as ((file: FileHandle) => Promise<void>) | undefined }))
+vi.mock('node:fs/promises', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('node:fs/promises')>()
+  return {
+    ...actual,
+    async open(...args: Parameters<typeof actual.open>): ReturnType<typeof actual.open> {
+      const file = await actual.open(...args)
+      await io.opened?.(file)
+      return file
+    },
+  }
+})
+
+afterEach(() => { io.opened = undefined; vi.restoreAllMocks() })
+
+const pdf = Buffer.from('%PDF-1.7\npreview\n%%EOF\n')
+
+async function output(): Promise<{ directory: string; path: string; handles: FileHandle[] }> {
+  const directory = await mkdtemp(join(tmpdir(), 'dsh-render-output-'))
+  const handles: FileHandle[] = []
+  onTestFinished(async () => {
+    try { await Promise.all(handles.map(file => file.close())) }
+    finally { await rm(directory, { recursive: true, force: true }) }
+  })
+  const path = join(directory, 'preview.pdf')
+  await writeFile(path, pdf)
+  return { directory, path, handles }
+}
+
+it('closes an opened output that is not a regular file despite earlier path metadata', async () => {
+  const { directory, path, handles } = await output()
+  const directoryInfo = await stat(directory)
+  io.opened = async (file) => {
+    handles.push(file)
+    // Directory handles cannot be opened on Windows; fstat supplies the same rejection input.
+    vi.spyOn(file, 'stat').mockResolvedValueOnce(directoryInfo)
+  }
+  await expect(readPdf(path, pdf.length, new AbortController().signal)).rejects.toMatchObject({ code: 'invalid-output' })
+  expect(handles).toHaveLength(1)
+  expect(handles[0]!.fd).toBe(-1)
+})
+
+it('rejects output that grows past the limit after opened-file metadata is read', async () => {
+  const { path, handles } = await output()
+  io.opened = async (file) => {
+    handles.push(file)
+    const inspect = file.stat.bind(file)
+    vi.spyOn(file, 'stat').mockImplementationOnce(async () => {
+      const info = await inspect()
+      await appendFile(path, 'extra output')
+      return info
+    })
+  }
+  await expect(readPdf(path, pdf.length, new AbortController().signal)).rejects.toMatchObject({ code: 'output-too-large' })
+  expect(handles).toHaveLength(1)
+  expect(handles[0]!.fd).toBe(-1)
+})

+ 269 - 0
packages/document/document-render-libreoffice/tests/provider.spec.ts

@@ -0,0 +1,269 @@
+/** Disk output, resource bounds, and cancellation around the external kit. */
+import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
+import { dirname } from 'node:path'
+import { DocumentSourceKey, type DocumentRenderRequest } from '@deepseek-ai/dsh-document-render'
+import { Context } from '@deepseek-ai/cordis'
+import type { Converter, ConverterOptions } from '@deepseek-ai/libreoffice-kit'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import LibreOfficeRenderer, { Config } from '../src/index.ts'
+
+const kit = vi.hoisted(() => ({ create: vi.fn<(options?: ConverterOptions) => Promise<Converter>>() }))
+vi.mock('@deepseek-ai/libreoffice-kit', () => ({ createConverter: kit.create }))
+
+const pdf = Buffer.from('%PDF-1.7\npreview\n%%EOF\n')
+const input = new Uint8Array([80, 75, 3, 4])
+const request: DocumentRenderRequest = { extension: 'docx', priority: 'foreground', source: {
+  key: DocumentSourceKey('source'), version: 'v1', bytes: input.length,
+  read: async () => ({ bytes: input, version: 'v1' }),
+} }
+function distinct(index: number): DocumentRenderRequest {
+  return { ...request, source: { ...request.source, key: DocumentSourceKey(`source-${index}`),
+    read: async () => ({ bytes: new Uint8Array([80, 75, 3, index]), version: 'v1' }) } }
+}
+let ctx: Context
+let render: ReturnType<typeof vi.fn<Converter['render']>>
+let dispose: ReturnType<typeof vi.fn<Converter['dispose']>>
+
+beforeEach(() => {
+  ctx = new Context()
+  render = vi.fn<Converter['render']>().mockImplementation(async ({ outputPath }) => {
+    await writeFile(outputPath, pdf)
+    return { backend: 'native', missingFonts: ['Missing Serif'] }
+  })
+  dispose = vi.fn<Converter['dispose']>().mockResolvedValue(undefined)
+  kit.create.mockReset().mockImplementation(async () => ({ backend: 'native', render, dispose }))
+})
+afterEach(async () => { await ctx.fiber.dispose() })
+
+async function mount(config: Partial<Config> = {}): Promise<LibreOfficeRenderer> {
+  await ctx.plugin(LibreOfficeRenderer, config)
+  return ctx.documentRender as LibreOfficeRenderer
+}
+
+it('preserves the kit font defaults when font configuration is omitted', async () => {
+  const renderer = await mount()
+  await renderer.render(request)
+  expect(kit.create.mock.calls[0]![0]).not.toHaveProperty('fontDirectories')
+  expect(kit.create.mock.calls[0]![0]).not.toHaveProperty('fontFallbacks')
+  expect(Config({ fontDirectories: [] }).fontDirectories).toEqual([])
+  expect(Config({ fontFallbacks: [] }).fontFallbacks).toEqual([])
+  expect(() => Config({ fontDirectories: [''] })).toThrow()
+})
+
+it.each([
+  [[]],
+  [['sans-serif']],
+  [['sans-serif', '']],
+  [['sans-serif', ' \t\n ']],
+])('rejects invalid font preference groups %j before creating a converter', (...fontFallbacks) => {
+  expect(() => Config({ fontFallbacks })).toThrow()
+  expect(kit.create).not.toHaveBeenCalled()
+})
+
+it('passes configured limits to the kit, reuses the converter, and removes all scratch files', async () => {
+  const config = Config({ maxConcurrentConversions: 1, maxInputBytes: 4, maxOutputBytes: pdf.length,
+    maxImageResolution: 144, maxArchiveEntries: 32, maxUncompressedBytes: 4096,
+    timeoutMs: 321, fontDirectories: [], fontFallbacks: [['sans-serif', 'Arial'], ['serif', 'Times New Roman']],
+    maxFontFiles: 12, maxFontFileBytes: 4096, maxLoadedFontBytes: 8192 })
+  const renderer = await mount(config)
+  let scratch = ''
+  render.mockImplementation(async ({ inputPath, outputPath }) => {
+    scratch = dirname(inputPath)
+    expect(await readFile(inputPath)).toEqual(Buffer.from(input))
+    if (process.platform !== 'win32') {
+      expect((await stat(scratch)).mode & 0o777).toBe(0o700)
+      expect((await stat(inputPath)).mode & 0o777).toBe(0o600)
+    }
+    await writeFile(outputPath, pdf)
+    return { backend: 'native', missingFonts: ['Missing Serif'] }
+  })
+  const result = await renderer.render(request)
+  expect(result).toMatchObject({ pdf: Uint8Array.from(pdf), missingFonts: ['Missing Serif'] })
+  await expect(access(scratch)).rejects.toMatchObject({ code: 'ENOENT' })
+  await renderer.render(request)
+  const { maxConcurrentConversions: _count, maxQueuedJobs: _queued, maxReaders: _readers, maxSourceBytes: _source,
+    maxBackgroundConversions: _background, maxCachedEntries: _entries, maxCachedBytes: _cached,
+    maxSourceEntries: _aliases, ...options } = config
+  expect(kit.create).toHaveBeenCalledExactlyOnceWith(options)
+  await ctx.fiber.dispose()
+  expect(dispose).toHaveBeenCalledOnce()
+  expect(result.pdf).toEqual(Uint8Array.from(pdf))
+})
+
+it('refuses an oversized input before allocating a converter', async () => {
+  const renderer = await mount({ maxInputBytes: 3 })
+  await expect(renderer.render(request)).rejects.toMatchObject({ code: 'input-too-large' })
+  expect(kit.create).not.toHaveBeenCalled()
+})
+
+it.each(['missing', 'directory', 'not-pdf', 'incomplete', 'too-large'] as const)('rejects %s kit output and removes its directory', async (kind) => {
+  const renderer = await mount({ maxOutputBytes: pdf.length })
+  let scratch = ''
+  render.mockImplementation(async ({ inputPath, outputPath }) => {
+    scratch = dirname(inputPath)
+    if (kind === 'directory') await mkdir(outputPath)
+    else if (kind === 'not-pdf') await writeFile(outputPath, 'engine diagnostic')
+    else if (kind === 'incomplete') await writeFile(outputPath, '%PDF-1.7\n')
+    else if (kind === 'too-large') await writeFile(outputPath, Buffer.concat([pdf, pdf]))
+    return { backend: 'wasm', missingFonts: [] }
+  })
+  await expect(renderer.render(request)).rejects.toMatchObject({ code: kind === 'too-large' ? 'output-too-large' : 'invalid-output' })
+  await expect(access(scratch)).rejects.toMatchObject({ code: 'ENOENT' })
+})
+
+it('retries initialization after a failed kit factory and reports unclassified errors', async () => {
+  const renderer = await mount()
+  kit.create.mockRejectedValueOnce(Object.assign(new Error('absent asset'), { code: 'unavailable' }))
+  await expect(renderer.render(request)).rejects.toMatchObject({ code: 'unavailable' })
+  expect(await renderer.render(request)).toMatchObject({ pdf: Uint8Array.from(pdf), missingFonts: ['Missing Serif'] })
+  expect(kit.create).toHaveBeenCalledTimes(2)
+  render.mockRejectedValueOnce(new Error('unexpected engine failure'))
+  await expect(renderer.render(distinct(9))).rejects.toMatchObject({ code: 'failed' })
+})
+
+it.each([
+  'input-too-large', 'output-too-large', 'invalid-document', 'unsupported-format', 'invalid-output', 'timeout', 'unavailable',
+] as const)('preserves the kit %s failure for the document consumer', async (code) => {
+  const renderer = await mount()
+  const cause = Object.assign(new Error('conversion failed'), { code })
+  render.mockRejectedValueOnce(cause)
+  await expect(renderer.render(request)).rejects.toMatchObject({ code, cause })
+})
+
+it('cancels a queued caller without starting or stopping another conversion', async () => {
+  const renderer = await mount({ maxConcurrentConversions: 1 })
+  const entered = Promise.withResolvers<AbortSignal>()
+  const release = Promise.withResolvers<undefined>()
+  render.mockImplementationOnce(async ({ outputPath }, signal) => {
+    entered.resolve(signal!)
+    await release.promise
+    await writeFile(outputPath, pdf)
+    return { backend: 'wasm', missingFonts: [] }
+  })
+  const first = renderer.render(request)
+  try {
+    const activeSignal = await entered.promise
+    const controller = new AbortController()
+    const second = renderer.render(request, controller.signal)
+    const rejected = expect(second).rejects.toMatchObject({ name: 'AbortError' })
+    await Promise.resolve(undefined)
+    controller.abort()
+    await rejected
+    expect(render).toHaveBeenCalledOnce()
+    expect(activeSignal.aborted).toBe(false)
+  } finally { release.resolve(undefined); await first }
+  expect(await renderer.render(request)).toHaveProperty('pdf')
+})
+
+it('bounds active converters and resumes queued work when a slot becomes free', async () => {
+  const renderer = await mount({ maxConcurrentConversions: 2 })
+  const both = Promise.withResolvers<undefined>()
+  const release = Promise.withResolvers<undefined>()
+  let calls = 0
+  render.mockImplementation(async ({ outputPath }) => {
+    if (++calls === 2) both.resolve(undefined)
+    await release.promise
+    await writeFile(outputPath, pdf)
+    return { backend: 'native', missingFonts: [] }
+  })
+  const work = [renderer.render(distinct(1)), renderer.render(distinct(2)), renderer.render(distinct(3))]
+  try {
+    await both.promise
+    expect(kit.create).toHaveBeenCalledTimes(2)
+    expect(render).toHaveBeenCalledTimes(2)
+  } finally { release.resolve(undefined); await Promise.all(work) }
+  expect(render).toHaveBeenCalledTimes(3)
+  expect(kit.create).toHaveBeenCalledTimes(2)
+})
+
+it.each(['caller', 'provider'] as const)('joins late kit completion and scratch cleanup after %s cancellation', async (owner) => {
+  const renderer = await mount()
+  const entered = Promise.withResolvers<{ signal: AbortSignal; scratch: string }>()
+  const release = Promise.withResolvers<undefined>()
+  render.mockImplementation(async ({ inputPath, outputPath }, signal) => {
+    entered.resolve({ signal: signal!, scratch: dirname(inputPath) })
+    await release.promise
+    await writeFile(outputPath, pdf)
+    return { backend: 'native', missingFonts: [] }
+  })
+  const caller = new AbortController()
+  const work = renderer.render(request, caller.signal)
+  const rejected = expect(work).rejects.toMatchObject(owner === 'caller' ? { name: 'AbortError' } : { code: 'unavailable' })
+  const { signal, scratch } = await entered.promise
+  let settled = false
+  const observed = work.then(() => { settled = true }, () => { settled = true })
+  let closing: Promise<void> | undefined
+  try {
+    if (owner === 'caller') caller.abort()
+    else closing = ctx.fiber.dispose()
+    await vi.waitFor(() => { expect(signal.aborted).toBe(true) })
+    await rejected
+    await observed
+    expect(settled).toBe(true)
+    await access(scratch)
+  } finally { release.resolve(undefined); await rejected; await observed; await closing; await ctx.fiber.dispose() }
+  await expect(access(scratch)).rejects.toMatchObject({ code: 'ENOENT' })
+  if (owner === 'provider') expect(dispose).toHaveBeenCalledOnce()
+})
+
+it('joins initialization during disposal and never starts a render after cancellation', async () => {
+  const renderer = await mount()
+  const entered = Promise.withResolvers<undefined>()
+  const release = Promise.withResolvers<Converter>()
+  kit.create.mockImplementationOnce(() => { entered.resolve(undefined); return release.promise })
+  const work = renderer.render(request)
+  const rejected = expect(work).rejects.toMatchObject({ code: 'unavailable' })
+  await entered.promise
+  const closing = ctx.fiber.dispose()
+  release.resolve({ backend: 'wasm', render, dispose })
+  await rejected
+  await closing
+  expect(render).not.toHaveBeenCalled()
+  expect(dispose).toHaveBeenCalledOnce()
+  await expect(renderer.render(request)).rejects.toMatchObject({ code: 'unavailable' })
+})
+
+it('rejects caller cancellation before allocating any conversion resources', async () => {
+  const renderer = await mount()
+  const reason = new Error('caller cancelled')
+  await expect(renderer.render(request, AbortSignal.abort(reason))).rejects.toBe(reason)
+  expect(kit.create).not.toHaveBeenCalled()
+})
+
+it('rejects relative font directories during provider configuration', async () => {
+  expect(() => new LibreOfficeRenderer(ctx, Config({ fontDirectories: ['relative/fonts'] })))
+    .toThrow('fontDirectories must contain absolute paths')
+})
+
+it('rejects source capacity below one permitted input before allocating a converter', () => {
+  expect(() => new LibreOfficeRenderer(ctx, Config({ maxInputBytes: 4, maxSourceBytes: 3 })))
+    .toThrow('maxSourceBytes must be at least maxInputBytes')
+  expect(kit.create).not.toHaveBeenCalled()
+})
+
+it('joins every converter disposal before reporting an engine cleanup failure', async () => {
+  const renderer = await mount({ maxConcurrentConversions: 2 })
+  const entered = Promise.withResolvers<undefined>()
+  const released = Promise.withResolvers<undefined>()
+  const cleanupEntered = Promise.withResolvers<undefined>()
+  const cleanupRelease = Promise.withResolvers<undefined>()
+  let conversions = 0
+  render.mockImplementation(async ({ outputPath }) => {
+    if (++conversions === 2) entered.resolve(undefined)
+    await released.promise
+    await writeFile(outputPath, pdf)
+    return { backend: 'native', missingFonts: [] }
+  })
+  const failed = new Error('engine cleanup failed')
+  kit.create.mockReset()
+    .mockResolvedValueOnce({ backend: 'native', render, dispose: async () => { throw failed } })
+    .mockResolvedValueOnce({ backend: 'native', render, dispose: async () => { cleanupEntered.resolve(undefined); await cleanupRelease.promise } })
+  const work = [renderer.render(distinct(1)), renderer.render(distinct(2))]
+  try { await entered.promise } finally { released.resolve(undefined); await Promise.all(work) }
+  let disposed = false
+  const closing = ctx.fiber.dispose().then(() => { disposed = true })
+  try {
+    await cleanupEntered.promise
+    expect(disposed).toBe(false)
+  } finally { cleanupRelease.resolve(undefined); await closing }
+})

+ 563 - 0
packages/document/document-render-libreoffice/tests/queue.spec.ts

@@ -0,0 +1,563 @@
+/** Controlled source and engine completions exercise admission and shared content ownership. */
+import { DocumentSourceKey, DocumentRendererGeneration, type DocumentRenderRequest } from '@deepseek-ai/dsh-document-render'
+import { expect, it, onTestFinished, vi } from 'vitest'
+import { RenderQueue } from '../src/queue.ts'
+import { Config } from '../src/index.ts'
+
+const output = { pdf: new Uint8Array([37, 80, 68, 70]), missingFonts: ['Font'] }
+function source(key: string, byte = 1, priority: DocumentRenderRequest['priority'] = 'foreground') {
+  const read = vi.fn<DocumentRenderRequest['source']['read']>().mockResolvedValue({ bytes: new Uint8Array([byte]), version: 'v1' })
+  const request: DocumentRenderRequest = { extension: 'docx', priority, source: { key: DocumentSourceKey(key), version: 'v1', bytes: 1, read } }
+  return { read, request }
+}
+function harness(config: Partial<Config> = {}) {
+  const convert = vi.fn<ConstructorParameters<typeof RenderQueue>[2]>().mockResolvedValue(output)
+  const queue = new RenderQueue(Config(config), DocumentRendererGeneration('test'), convert)
+  onTestFinished(() => queue.dispose())
+  return { queue, convert }
+}
+
+it('shares authorized source metadata before reading and content across distinct source paths', async () => {
+  const h = harness()
+  const entered = Promise.withResolvers<undefined>()
+  const complete = Promise.withResolvers<typeof output>()
+  h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise })
+  const a = source('a'), b = source('b')
+  const first = h.queue.read(a.request)
+  await entered.promise
+  const same = h.queue.read(a.request)
+  const equalContent = h.queue.read(b.request)
+  try {
+    await vi.waitFor(() => { expect(b.read).toHaveBeenCalledOnce() })
+    expect(a.read).toHaveBeenCalledOnce()
+    expect(h.convert).toHaveBeenCalledOnce()
+  } finally { complete.resolve(output) }
+  const results = await Promise.all([first, same, equalContent])
+  expect(new Set(results.map(result => result.cacheKey)).size).toBe(1)
+  results[0].pdf[0] = 0
+  results[0].missingFonts.length = 0
+  expect(results[1].pdf).toEqual(output.pdf)
+  expect((await h.queue.read(b.request)).missingFonts).toEqual(['Font'])
+  expect(b.read).toHaveBeenCalledOnce()
+})
+
+it('continues queued work when the first queued reader cancels immediately after admission', async () => {
+  const h = harness({ maxConcurrentConversions: 1 })
+  const entered = Promise.withResolvers<undefined>(), complete = Promise.withResolvers<typeof output>()
+  h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise })
+  const first = h.queue.read(source('active', 1).request)
+  await entered.promise
+  const cancelled = new AbortController()
+  const second = source('cancelled', 2), third = source('later', 3)
+  second.read.mockImplementation(async () => {
+    cancelled.abort()
+    return { bytes: new Uint8Array([2]), version: 'v1' }
+  })
+  const queued = h.queue.read(second.request, cancelled.signal)
+  const rejected = expect(queued).rejects.toMatchObject({ name: 'AbortError' })
+  const later = h.queue.read(third.request)
+  try {
+    expect(second.read).not.toHaveBeenCalled()
+    expect(third.read).not.toHaveBeenCalled()
+  } finally { complete.resolve(output) }
+  await first
+  await rejected
+  await expect(later).resolves.toMatchObject(output)
+  expect(second.read).toHaveBeenCalledOnce()
+  expect(third.read).toHaveBeenCalledOnce()
+  expect(h.convert).toHaveBeenCalledTimes(2)
+})
+
+it('promotes a queued prewarm when a foreground reader joins, without another source read', async () => {
+  const h = harness({ maxConcurrentConversions: 1 })
+  const entered = Promise.withResolvers<undefined>(), complete = Promise.withResolvers<typeof output>()
+  h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise })
+  const first = h.queue.read(source('active', 1).request)
+  await entered.promise
+  const order: string[] = []
+  const a = source('a', 2, 'background'), b = source('b', 3, 'background')
+  a.read.mockImplementation(async () => { order.push('a'); return { bytes: new Uint8Array([2]), version: 'v1' } })
+  b.read.mockImplementation(async () => { order.push('b'); return { bytes: new Uint8Array([3]), version: 'v1' } })
+  const backgroundA = h.queue.read(a.request), backgroundB = h.queue.read(b.request)
+  const foreground = h.queue.read({ ...b.request, priority: 'foreground' })
+  expect(a.read).not.toHaveBeenCalled()
+  expect(b.read).not.toHaveBeenCalled()
+  complete.resolve(output)
+  await Promise.all([first, backgroundA, backgroundB, foreground])
+  expect(order).toEqual(['b', 'a'])
+  expect(b.read).toHaveBeenCalledOnce()
+})
+
+it('evicts queued speculation for foreground work and bounds the metadata queue', async () => {
+  const h = harness({ maxConcurrentConversions: 1, maxQueuedJobs: 1 })
+  const entered = Promise.withResolvers<undefined>(), complete = Promise.withResolvers<typeof output>()
+  h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise })
+  const first = h.queue.read(source('active').request)
+  await entered.promise
+  const prewarm = source('prewarm', 2, 'background')
+  const discarded = expect(h.queue.read(prewarm.request)).rejects.toMatchObject({ code: 'busy' })
+  const overflow = source('background-overflow', 4, 'background')
+  await expect(h.queue.read(overflow.request)).rejects.toMatchObject({ code: 'busy' })
+  expect(overflow.read).not.toHaveBeenCalled()
+  const requested = source('requested', 3)
+  const next = h.queue.read(requested.request)
+  await expect(h.queue.read(source('overflow', 4).request)).rejects.toMatchObject({ code: 'busy' })
+  await discarded
+  expect(prewarm.read).not.toHaveBeenCalled()
+  expect(requested.read).not.toHaveBeenCalled()
+  complete.resolve(output)
+  await Promise.all([first, next])
+  expect(requested.read).toHaveBeenCalledOnce()
+})
+
+it('refuses disabled prewarming before reading a source while admitting foreground work', async () => {
+  const h = harness({ maxBackgroundConversions: 0 })
+  const a = source('a', 1, 'background')
+  await expect(h.queue.read(a.request)).rejects.toMatchObject({ code: 'busy' })
+  expect(a.read).not.toHaveBeenCalled()
+  expect(h.convert).not.toHaveBeenCalled()
+  await h.queue.read({ ...a.request, priority: 'foreground' })
+  expect(a.read).toHaveBeenCalledOnce()
+})
+
+it('refuses disabled prewarming joins and releases cancelled foreground capacity', async () => {
+  const h = harness({ maxBackgroundConversions: 0, maxConcurrentConversions: 1, maxQueuedJobs: 1, maxReaders: 5 })
+  const entered = Promise.withResolvers<undefined>(), complete = Promise.withResolvers<typeof output>()
+  h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise })
+  const running = source('running', 1), queued = source('queued', 2), later = source('later', 3)
+  const foreground = new AbortController(), background = new AbortController()
+  const pending: Promise<unknown>[] = [h.queue.read(running.request)]
+  const rejected = vi.fn()
+  try {
+    await entered.promise
+    pending.push(h.queue.read(queued.request, foreground.signal).catch((error: unknown) => error))
+    for (const item of [running, queued]) {
+      pending.push(h.queue.read({ ...item.request, priority: 'background' }, background.signal).catch(rejected))
+    }
+    await expect.poll(() => rejected.mock.calls.length).toBe(2)
+    expect(rejected.mock.calls).toEqual([[expect.objectContaining({ code: 'busy' })], [expect.objectContaining({ code: 'busy' })]])
+    foreground.abort()
+    const next = h.queue.read(later.request)
+    pending.push(next)
+    expect(queued.read).not.toHaveBeenCalled()
+    expect(later.read).not.toHaveBeenCalled()
+    complete.resolve(output)
+    await expect(next).resolves.toMatchObject(output)
+    await expect(h.queue.read({ ...running.request, priority: 'background' })).resolves.toMatchObject(output)
+    expect(running.read).toHaveBeenCalledOnce()
+    expect(later.read).toHaveBeenCalledOnce()
+    expect(h.convert).toHaveBeenCalledTimes(2)
+  } finally {
+    foreground.abort()
+    background.abort()
+    complete.resolve(output)
+    await Promise.allSettled(pending)
+  }
+})
+
+it('reserves foreground capacity while limiting concurrent prewarming', async () => {
+  const h = harness({ maxConcurrentConversions: 2, maxBackgroundConversions: 1 })
+  const entered = Promise.withResolvers<undefined>(), complete = Promise.withResolvers<typeof output>()
+  h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise })
+  const first = h.queue.read(source('first', 1, 'background').request)
+  await entered.promise
+  const second = source('second', 2, 'background')
+  const background = h.queue.read(second.request)
+  const foreground = source('foreground', 3)
+  await h.queue.read(foreground.request)
+  expect(second.read).not.toHaveBeenCalled()
+  complete.resolve(output)
+  await Promise.all([first, background])
+})
+
+it('holds reserved source capacity until canceled engine work actually settles', async () => {
+  const h = harness({ maxConcurrentConversions: 2, maxInputBytes: 1, maxSourceBytes: 1 })
+  const entered = Promise.withResolvers<AbortSignal>(), complete = Promise.withResolvers<typeof output>()
+  h.convert.mockImplementationOnce((_bytes, _extension, signal: AbortSignal) => { entered.resolve(signal); return complete.promise })
+  const caller = new AbortController()
+  const firstSource = source('first'), nextSource = source('next', 2)
+  const first = expect(h.queue.read(firstSource.request, caller.signal)).rejects.toMatchObject({ name: 'AbortError' })
+  const signal = await entered.promise
+  caller.abort()
+  await first
+  expect(signal.aborted).toBe(true)
+  const next = h.queue.read(nextSource.request)
+  expect(nextSource.read).not.toHaveBeenCalled()
+  complete.resolve(output)
+  await next
+  await h.queue.read(firstSource.request)
+  expect(firstSource.read).toHaveBeenCalledTimes(2)
+})
+
+it('bounds shared readers and leaves other readers alive after cancellation', async () => {
+  const h = harness({ maxReaders: 2 })
+  const entered = Promise.withResolvers<AbortSignal>(), complete = Promise.withResolvers<typeof output>()
+  h.convert.mockImplementationOnce((_bytes, _extension, signal: AbortSignal) => { entered.resolve(signal); return complete.promise })
+  const a = source('a'), caller = new AbortController()
+  const first = expect(h.queue.read(a.request, caller.signal)).rejects.toMatchObject({ name: 'AbortError' })
+  const signal = await entered.promise
+  const second = h.queue.read(a.request)
+  await expect(h.queue.read(a.request)).rejects.toMatchObject({ code: 'busy' })
+  caller.abort()
+  await first
+  expect(signal.aborted).toBe(false)
+  complete.resolve(output)
+  await second
+})
+
+it('keeps digest-shared readers alive when the original source reader leaves', async () => {
+  const h = harness()
+  const entered = Promise.withResolvers<AbortSignal>(), complete = Promise.withResolvers<typeof output>()
+  h.convert.mockImplementationOnce((_bytes, _extension, signal: AbortSignal) => { entered.resolve(signal); return complete.promise })
+  const a = source('a'), b = source('b'), caller = new AbortController()
+  const first = expect(h.queue.read(a.request, caller.signal)).rejects.toMatchObject({ name: 'AbortError' })
+  const signal = await entered.promise
+  const second = h.queue.read(b.request)
+  await vi.waitFor(() => { expect(b.read).toHaveBeenCalledOnce() })
+  caller.abort()
+  await first
+  expect(signal.aborted).toBe(false)
+  complete.resolve(output)
+  await second
+  expect(h.convert).toHaveBeenCalledOnce()
+})
+
+it('rejects source version changes and oversized reads without converting or retaining them', async () => {
+  const h = harness({ maxInputBytes: 2, maxSourceBytes: 2 })
+  const a = source('a')
+  a.read.mockResolvedValueOnce({ bytes: new Uint8Array([1]), version: 'v2' })
+  await expect(h.queue.read(a.request)).rejects.toMatchObject({ code: 'source-changed' })
+  a.read.mockResolvedValueOnce({ bytes: new Uint8Array([1, 2]), version: 'v1' })
+  await expect(h.queue.read(a.request)).rejects.toMatchObject({ code: 'input-too-large' })
+  expect(h.convert).not.toHaveBeenCalled()
+  await h.queue.read(a.request)
+  expect(a.read).toHaveBeenCalledTimes(3)
+  expect(a.read.mock.calls[0]![1]).toBe(1)
+})
+
+it('reserves the input cap for an unknown stat size and rejects known overflow before reading', async () => {
+  const h = harness({ maxInputBytes: 2, maxSourceBytes: 2 })
+  const a = source('a')
+  await expect(h.queue.read({ ...a.request, source: { ...a.request.source, bytes: 3 } })).rejects.toMatchObject({ code: 'input-too-large' })
+  expect(a.read).not.toHaveBeenCalled()
+  const { bytes: _bytes, ...unknown } = a.request.source
+  await h.queue.read({ ...a.request, source: unknown })
+  expect(a.read.mock.calls[0]![1]).toBe(2)
+})
+
+it('evicts least-recently-used content and bounds pre-read aliases independently', async () => {
+  const h = harness({ maxCachedEntries: 2, maxCachedBytes: 8, maxSourceEntries: 1 })
+  const a = source('a', 1), alias = source('alias', 1), b = source('b', 2), c = source('c', 3)
+  await h.queue.read(a.request)
+  await h.queue.read(alias.request)
+  await h.queue.read(a.request)
+  expect(a.read).toHaveBeenCalledTimes(2)
+  expect(h.convert).toHaveBeenCalledOnce()
+  await h.queue.read(b.request)
+  await h.queue.read(a.request)
+  await h.queue.read(c.request)
+  await h.queue.read(b.request)
+  expect(h.convert).toHaveBeenCalledTimes(4)
+})
+
+it('does not retain failures or PDFs above the retention budget', async () => {
+  const h = harness({ maxCachedBytes: 1 })
+  const a = source('a')
+  h.convert.mockRejectedValueOnce(new Error('engine failure'))
+  await expect(h.queue.read(a.request)).rejects.toThrow('engine failure')
+  await h.queue.read(a.request)
+  await h.queue.read(a.request)
+  expect(h.convert).toHaveBeenCalledTimes(3)
+})
+
+it('evicts every alias of the least-recently-used PDF while preserving other aliases', async () => {
+  const h = harness({ maxCachedEntries: 2 })
+  const a = source('a', 1), alias = source('alias', 1), b = source('b', 2), c = source('c', 3)
+  await h.queue.read(a.request)
+  await h.queue.read(alias.request)
+  await h.queue.read(b.request)
+  await h.queue.read(c.request)
+  await h.queue.read(b.request)
+  expect(b.read).toHaveBeenCalledOnce()
+  await h.queue.read(a.request)
+  await h.queue.read(alias.request)
+  expect(a.read).toHaveBeenCalledTimes(2)
+  expect(alias.read).toHaveBeenCalledTimes(2)
+  expect(h.convert).toHaveBeenCalledTimes(4)
+})
+
+it('keeps a synchronous abort replacement shareable after the old conversion settles late', async () => {
+  const h = harness({ maxConcurrentConversions: 2 })
+  const firstEntered = Promise.withResolvers<undefined>(), firstComplete = Promise.withResolvers<typeof output>()
+  const replacementEntered = Promise.withResolvers<undefined>(), replacementComplete = Promise.withResolvers<typeof output>()
+  const replacementSpawned = Promise.withResolvers<{ work: ReturnType<RenderQueue['read']> }>()
+  const a = source('a'), caller = new AbortController()
+  const pending: Promise<unknown>[] = []
+  h.convert.mockImplementationOnce((_bytes, _extension, signal) => {
+    signal.addEventListener('abort', () => {
+      const work = h.queue.read(a.request)
+      pending.push(work)
+      replacementSpawned.resolve({ work })
+    }, { once: true })
+    firstEntered.resolve(undefined)
+    return firstComplete.promise
+  })
+    .mockImplementationOnce(() => { replacementEntered.resolve(undefined); return replacementComplete.promise })
+  const first = expect(h.queue.read(a.request, caller.signal)).rejects.toMatchObject({ cause: 'reader left' })
+  pending.push(first)
+  try {
+    await firstEntered.promise
+    caller.abort('reader left')
+    await first
+    const { work: replacement } = await replacementSpawned.promise
+    await replacementEntered.promise
+    const next = h.queue.read(source('next', 2).request)
+    pending.push(next)
+    firstComplete.resolve(output)
+    await next
+    const sameSource = h.queue.read(a.request)
+    const alias = source('alias', 1, 'background')
+    const sameContent = h.queue.read(alias.request)
+    pending.push(sameSource, sameContent)
+    await vi.waitFor(() => { expect(alias.read).toHaveBeenCalledOnce() })
+    expect(a.read).toHaveBeenCalledTimes(2)
+    expect(h.convert).toHaveBeenCalledTimes(3)
+    replacementComplete.resolve(output)
+    const results = await Promise.all([replacement, sameSource, sameContent])
+    expect(new Set(results.map(result => result.cacheKey)).size).toBe(1)
+  } finally {
+    firstComplete.resolve(output)
+    replacementComplete.resolve(output)
+    await Promise.allSettled(pending)
+  }
+})
+
+it('separates Office extensions and renderer generations in content identity', async () => {
+  const h = harness(), a = source('a')
+  const first = await h.queue.read(a.request)
+  const otherFormat = await h.queue.read({ ...a.request, extension: 'pptx' })
+  const other = new RenderQueue(Config({}), DocumentRendererGeneration('replacement'), h.convert)
+  onTestFinished(() => other.dispose())
+  const replacement = await other.read(a.request)
+  expect(first.cacheKey).not.toBe(otherFormat.cacheKey)
+  expect(first.cacheKey).not.toBe(replacement.cacheKey)
+})
+
+it('reserves the final reader admission for foreground interest', async () => {
+  const h = harness({ maxReaders: 2 })
+  const entered = Promise.withResolvers<undefined>(), complete = Promise.withResolvers<typeof output>()
+  h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise })
+  const a = source('a', 1, 'background')
+  const background = h.queue.read(a.request)
+  await entered.promise
+  await expect(h.queue.read(a.request)).rejects.toMatchObject({ code: 'busy' })
+  const foreground = h.queue.read({ ...a.request, priority: 'foreground' })
+  complete.resolve(output)
+  await Promise.all([background, foreground])
+  expect(a.read).toHaveBeenCalledOnce()
+})
+
+it('serves ready PDFs while every outstanding reader slot is occupied', async () => {
+  const h = harness({ maxReaders: 1 }), cached = source('cached')
+  await h.queue.read(cached.request)
+  const entered = Promise.withResolvers<undefined>(), complete = Promise.withResolvers<typeof output>()
+  h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise })
+  const pending = h.queue.read(source('active', 2).request)
+  try {
+    await entered.promise
+    await expect(h.queue.read(cached.request)).resolves.toMatchObject(output)
+    expect(cached.read).toHaveBeenCalledOnce()
+    expect(h.convert).toHaveBeenCalledTimes(2)
+  } finally { complete.resolve(output); await pending }
+})
+
+it('keeps background reservations behind foreground work waiting for source capacity', async () => {
+  const h = harness({ maxInputBytes: 50, maxSourceBytes: 60, maxConcurrentConversions: 2 })
+  const running = source('running', 1), foreground = source('foreground', 2), background = source('background', 3, 'background')
+  const entered = Promise.withResolvers<undefined>(), foregroundEntered = Promise.withResolvers<undefined>()
+  const complete = Promise.withResolvers<typeof output>(), foregroundComplete = Promise.withResolvers<typeof output>()
+  const order: number[] = []
+  h.convert.mockImplementation((bytes) => {
+    order.push(bytes[0]!)
+    if (bytes[0] === 1) { entered.resolve(undefined); return complete.promise }
+    if (bytes[0] === 2) { foregroundEntered.resolve(undefined); return foregroundComplete.promise }
+    return Promise.resolve(output)
+  })
+  const first = h.queue.read({ ...running.request, source: { ...running.request.source, bytes: 15 } })
+  await entered.promise
+  const next = h.queue.read({ ...foreground.request, source: { ...foreground.request.source, bytes: 48 } })
+  const speculative = h.queue.read({ ...background.request, source: { ...background.request.source, bytes: 45 } })
+  try {
+    expect(foreground.read).not.toHaveBeenCalled()
+    expect(background.read).not.toHaveBeenCalled()
+    complete.resolve(output)
+    await foregroundEntered.promise
+    expect(order).toEqual([1, 2])
+  } finally {
+    complete.resolve(output)
+    foregroundComplete.resolve(output)
+    await Promise.allSettled([first, next, speculative])
+  }
+  expect(order).toEqual([1, 2, 3])
+})
+
+it('starts eligible background work when its queued foreground blocker is cancelled', async () => {
+  const h = harness({ maxInputBytes: 50, maxSourceBytes: 60, maxConcurrentConversions: 2 })
+  const running = source('running', 1), foreground = source('foreground', 2), background = source('background', 3, 'background')
+  const entered = Promise.withResolvers<undefined>(), complete = Promise.withResolvers<typeof output>()
+  h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise })
+  const first = h.queue.read({ ...running.request, source: { ...running.request.source, bytes: 15 } })
+  const caller = new AbortController()
+  let speculative: Promise<unknown> | undefined
+  try {
+    await entered.promise
+    const cancelled = expect(h.queue.read({ ...foreground.request, source: { ...foreground.request.source, bytes: 48 } }, caller.signal))
+      .rejects.toMatchObject({ name: 'AbortError' })
+    speculative = h.queue.read({ ...background.request, source: { ...background.request.source, bytes: 45 } })
+    expect(background.read).not.toHaveBeenCalled()
+    caller.abort()
+    await cancelled
+    expect(foreground.read).not.toHaveBeenCalled()
+    expect(background.read).toHaveBeenCalledOnce()
+    await speculative
+    expect(h.convert).toHaveBeenCalledTimes(2)
+  } finally { caller.abort(); complete.resolve(output); await Promise.allSettled([first, speculative]) }
+})
+
+it('starts eligible background work after a queued promotion loses its final foreground reader', async () => {
+  const h = harness({ maxInputBytes: 50, maxSourceBytes: 60, maxConcurrentConversions: 2 })
+  const running = source('running', 1), promoted = source('promoted', 2, 'background'), background = source('background', 3, 'background')
+  const entered = Promise.withResolvers<undefined>(), complete = Promise.withResolvers<typeof output>()
+  h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise })
+  const first = h.queue.read({ ...running.request, source: { ...running.request.source, bytes: 15 } })
+  const callers = [new AbortController(), new AbortController()]
+  const pending: Promise<unknown>[] = [first]
+  try {
+    await entered.promise
+    const request = { ...promoted.request, source: { ...promoted.request.source, bytes: 48 } }
+    pending.push(h.queue.read(request))
+    const cancelled = callers.map(caller => expect(h.queue.read({ ...request, priority: 'foreground' }, caller.signal))
+      .rejects.toMatchObject({ name: 'AbortError' }))
+    pending.push(...cancelled)
+    pending.push(h.queue.read({ ...background.request, source: { ...background.request.source, bytes: 45 } }))
+    expect(background.read).not.toHaveBeenCalled()
+    callers[0]!.abort()
+    await cancelled[0]
+    expect(background.read).not.toHaveBeenCalled()
+    callers[1]!.abort()
+    await cancelled[1]
+    expect(promoted.read).not.toHaveBeenCalled()
+    expect(background.read).toHaveBeenCalledOnce()
+  } finally {
+    for (const caller of callers) caller.abort()
+    complete.resolve(output)
+    await Promise.allSettled(pending)
+  }
+})
+
+it('counts a demoted queued prewarm against background concurrency when it starts', async () => {
+  const h = harness({ maxConcurrentConversions: 2, maxBackgroundConversions: 1 })
+  const complete = Promise.withResolvers<typeof output>(), prewarmComplete = Promise.withResolvers<typeof output>()
+  const prewarmEntered = Promise.withResolvers<undefined>()
+  h.convert.mockImplementation((bytes) => {
+    if (bytes[0] === 1 || bytes[0] === 2) return complete.promise
+    if (bytes[0] === 3) { prewarmEntered.resolve(undefined); return prewarmComplete.promise }
+    return Promise.resolve(output)
+  })
+  const first = h.queue.read(source('first', 1).request), second = h.queue.read(source('second', 2).request)
+  const prewarm = source('promoted', 3, 'background'), other = source('other', 4, 'background')
+  const warming = h.queue.read(prewarm.request), waiting = h.queue.read(other.request)
+  const caller = new AbortController()
+  const cancelled = expect(h.queue.read({ ...prewarm.request, priority: 'foreground' }, caller.signal))
+    .rejects.toMatchObject({ name: 'AbortError' })
+  try {
+    expect(prewarm.read).not.toHaveBeenCalled()
+    caller.abort()
+    await cancelled
+    complete.resolve(output)
+    await prewarmEntered.promise
+    await h.queue.read(source('foreground', 5).request)
+    expect(other.read).not.toHaveBeenCalled()
+  } finally {
+    caller.abort()
+    complete.resolve(output)
+    prewarmComplete.resolve(output)
+    await Promise.allSettled([first, second, warming, waiting, cancelled])
+  }
+})
+
+it('releases source indexes when the last reader for each digest-shared path leaves', async () => {
+  const h = harness({ maxReaders: 2, maxSourceEntries: 2 })
+  const entered = Promise.withResolvers<undefined>(), complete = Promise.withResolvers<typeof output>()
+  h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise })
+  const first = h.queue.read(source('retained').request)
+  await entered.promise
+  // Both retained indexes must stay bounded while the underlying converter is blocked.
+  const indexes = h.queue as unknown as { sources: Map<string, { sources: Set<string> }> }
+  const retained = [...indexes.sources.values()][0]!
+  try {
+    for (let index = 0; index < 5; index++) {
+      const alias = source(`cancelled-${index}`), caller = new AbortController()
+      const joined = h.queue.read(alias.request, caller.signal)
+      const rejected = expect(joined).rejects.toMatchObject({ name: 'AbortError' })
+      await vi.waitFor(() => { expect(retained.sources.size).toBe(2) })
+      caller.abort()
+      await rejected
+      expect(indexes.sources.size).toBe(1)
+      expect(retained.sources.size).toBe(1)
+    }
+    expect(h.convert).toHaveBeenCalledOnce()
+  } finally { complete.resolve(output); await first }
+})
+
+it('rereads a cancelled source on reopen while sharing its running or completed conversion', async () => {
+  const h = harness({ maxReaders: 2, maxSourceEntries: 2 })
+  const entered = Promise.withResolvers<undefined>(), complete = Promise.withResolvers<typeof output>()
+  h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise })
+  const first = h.queue.read(source('retained').request), reopened = source('reopened')
+  try {
+    await entered.promise
+    for (const count of [1, 2]) {
+      const caller = new AbortController()
+      const cancelled = expect(h.queue.read(reopened.request, caller.signal)).rejects.toMatchObject({ name: 'AbortError' })
+      try { await vi.waitFor(() => { expect(reopened.read).toHaveBeenCalledTimes(count) }) }
+      finally { caller.abort(); await cancelled }
+    }
+    complete.resolve(output)
+    await first
+    await h.queue.read(reopened.request)
+    expect(reopened.read).toHaveBeenCalledTimes(3)
+    await h.queue.read(reopened.request)
+    expect(reopened.read).toHaveBeenCalledTimes(3)
+    expect(h.convert).toHaveBeenCalledOnce()
+  } finally { complete.resolve(output); await first }
+})
+
+it('keeps speculative admission occupied after promotion while allowing other foreground work', async () => {
+  const h = harness({ maxConcurrentConversions: 2, maxBackgroundConversions: 1 })
+  const entered = Promise.withResolvers<undefined>(), complete = Promise.withResolvers<typeof output>()
+  h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise })
+  const original = source('original', 1, 'background'), waiting = source('waiting', 2, 'background')
+  const first = h.queue.read(original.request)
+  await entered.promise
+  const promoted = h.queue.read({ ...original.request, priority: 'foreground' })
+  const speculative = h.queue.read(waiting.request)
+  try {
+    await h.queue.read(source('requested', 3).request)
+    expect(waiting.read).not.toHaveBeenCalled()
+  } finally { complete.resolve(output); await Promise.all([first, promoted, speculative]) }
+  expect(waiting.read).toHaveBeenCalledOnce()
+})
+
+it('classifies provider disposal independently from caller cancellation', async () => {
+  const h = harness()
+  const entered = Promise.withResolvers<undefined>(), complete = Promise.withResolvers<typeof output>()
+  h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise })
+  const pending = expect(h.queue.read(source('active').request)).rejects.toMatchObject({ code: 'unavailable' })
+  await entered.promise
+  const closing = h.queue.dispose()
+  try {
+    await pending
+    await expect(h.queue.read(source('later').request)).rejects.toMatchObject({ code: 'unavailable' })
+  } finally { complete.resolve(output); await closing }
+})

+ 21 - 0
packages/document/document-render-libreoffice/tsconfig.json

@@ -0,0 +1,21 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../document-render"
+    }
+  ]
+}

+ 6 - 0
packages/document/document-render/README.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 packages/document/document-render/README.md
+README.md: ead6c62b25dcca326ad362af4e28a6431472f55c
+README.zh.md: 45138e306a367833e0acade97b7d13f46fdcefa8

+ 75 - 0
packages/document/document-render/README.md

@@ -0,0 +1,75 @@
+---
+description: "Authorized Office input and complete PDF output for Host conversion providers."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-document-render
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Convert authorized `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, and `.pptx` bytes to a complete PDF. Callers receive PDF bytes and, for OOXML inputs, missing-font families without changing the original file or adding model context.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+Mount the [LibreOffice provider](../document-render-libreoffice/README.md) to supply `ctx.documentRender`. The abstract service has no mountable implementation or configuration. Callers authorize and stat their source before passing its identity, version, optional byte size, deferred bounded read, Office extension, and scheduling priority to `render()`. The provider admits metadata before calling the read callback. A changed source version rejects conversion.
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+Providers return independent caller-owned PDF bytes, a conversion cache key, and their rendering generation. Consumers check `generation` before reusing cached PDFs; configuration replacement creates a new generation. Individual reader cancellation rejects promptly; provider disposal waits for actual reads, conversion, and scratch cleanup. Cancellation rejects with its reason; classified conversion failures use `DocumentRenderError`. Providers and consumers share this package as a peer dependency so `instanceof DocumentRenderError` recognizes their failures. No runtime invariant companion is published because this service declares operations without an independent retained observation.
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+- [Document rendering](../../../docs/subsystems/document-render.md) — composition and ownership.
+- [Workspace Files](../../api/workspace-files/README.md) — Session file authorization and bounded reads.
+
+-----
+
+<a id="model-experience"></a>
+## Model Experience
+
+None, as this package converts bytes without model-facing tools, messages, or Session events.
+
+#### KV Cache effect
+
+None; conversion does not construct or modify model requests.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- The service accepts only the six listed Office formats; source authorization belongs to consumers, while shared conversion reuse belongs to the provider.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>

+ 75 - 0
packages/document/document-render/README.zh.md

@@ -0,0 +1,75 @@
+---
+description: "宿主转换提供方使用的已授权 Office 输入与完整 PDF 输出。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-document-render
+
+[English](README.md) | 中文
+
+## 概述
+
+将已授权的 `.doc`、`.docx`、`.xls`、`.xlsx`、`.ppt` 和 `.pptx` 字节转换为完整 PDF。调用方获得 PDF 字节;OOXML 输入还提供缺失字体名称,不修改原文件或添加模型上下文。
+
+## 目录
+
+- [使用此包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [延伸阅读](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延后工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用此包
+
+挂载 [LibreOffice 提供方](../document-render-libreoffice/README.zh.md)以提供 `ctx.documentRender`。抽象服务没有可挂载实现或配置。调用方须先授权并 stat 源文件,再向 `render()` 传入标识、版本、可选字节数、延迟的有界读取、Office 扩展名和调度优先级。提供方在调用读取回调前准入元数据。源版本变化时拒绝转换。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 理解实现
+
+<details>
+<summary>实现内部细节 — 点击展开</summary>
+
+提供方返回各调用方独立拥有的 PDF 字节、转换缓存键和渲染 generation。消费者在复用缓存 PDF 前检查 `generation`;替换配置会创建新的 generation。单个读取方取消时立即拒绝;提供方销毁会等待实际读取、转换和临时文件清理。取消以其原因拒绝;已分类的转换失败使用 `DocumentRenderError`。提供方和消费者通过 peer dependency 共享此包,使 `instanceof DocumentRenderError` 能识别双方的失败。此服务仅声明操作,没有独立保留的观测,因此不发布运行时不变量伴随入口。
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## 延伸阅读
+
+- [文档渲染](../../../docs/subsystems/document-render.zh.md) — 组合和所有权。
+- [工作区文件](../../api/workspace-files/README.zh.md) — Session 文件授权与有界读取。
+
+-----
+
+<a id="model-experience"></a>
+## 模型体验
+
+无,因为此包仅转换字节,不提供面向模型的工具、消息或 Session 事件。
+
+#### KV Cache effect
+
+无;转换不会构造或修改模型请求。
+
+## 已知限制与延后工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- 服务仅接受上述六种 Office 格式;源文件授权由消费者负责,共享转换复用由提供方负责。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者工作上下文 — 点击展开</summary>
+
+无。
+
+</details>

+ 43 - 0
packages/document/document-render/package.json

@@ -0,0 +1,43 @@
+{
+  "name": "@deepseek-ai/dsh-document-render",
+  "description": "Authorized Office bytes to PDF rendering service",
+  "version": "0.1.6-alpha.1",
+  "publishConfig": {
+    "access": "public"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "license": "MIT",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/document/document-render"
+  },
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./types": {
+      "types": "./lib/types/types.d.ts",
+      "default": "./lib/types/types.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.js",
+    "lib/types/**/*.d.ts"
+  ],
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "dependencies": {
+    "@deepseek-ai/dsh-brand": "workspace:^"
+  }
+}

+ 44 - 0
packages/document/document-render/src/index.ts

@@ -0,0 +1,44 @@
+/** Host document rendering: authorized Office bytes become a complete PDF. */
+import { Context, Service } from '@deepseek-ai/cordis'
+import type { DocumentRenderErrorCode, DocumentRenderRequest, DocumentRenderResult, DocumentRendererGeneration } from './types.ts'
+
+export * from './types.ts'
+
+/** Classified conversion failure; engine details stay in the cause. */
+export class DocumentRenderError extends Error {
+  /**
+   * @param code - category suitable for a conversion consumer.
+   * @param message - diagnostic explaining the failed conversion.
+   * @param options - underlying engine or filesystem failure.
+   */
+  constructor(readonly code: DocumentRenderErrorCode, message: string, options?: ErrorOptions) {
+    super(message, options)
+    this.name = 'DocumentRenderError'
+  }
+}
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    /** One Host renderer for already-authorized Office bytes. */
+    documentRender: DocumentRenderer
+  }
+}
+
+/** Load one provider subclass per context; consumers own source authorization. */
+export abstract class DocumentRenderer extends Service {
+  constructor(ctx: Context) { super(ctx, 'documentRender') }
+
+  /** Changes whenever engine, font, or rendering configuration is replaced. */
+  abstract readonly generation: DocumentRendererGeneration
+
+  /**
+   * Convert Office bytes without modifying the source or writing Session events.
+   * @param request - authorized metadata and deferred bounded source read.
+   * @param signal - caller cancellation; provider disposal also stops active work.
+   * @returns caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently.
+   * @throws {DocumentRenderError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.
+   */
+  abstract render(request: DocumentRenderRequest, signal?: AbortSignal): Promise<DocumentRenderResult>
+}
+
+export default DocumentRenderer

+ 71 - 0
packages/document/document-render/src/types.ts

@@ -0,0 +1,71 @@
+/** Authorized Office input and complete PDF output, independent of the conversion engine. */
+import { brandString, type Branded } from '@deepseek-ai/dsh-brand'
+
+/** Binary Office and Office Open XML formats supported by document preview. */
+export type DocumentExtension = 'doc' | 'docx' | 'xls' | 'xlsx' | 'ppt' | 'pptx'
+
+/** Authorized execution scope and canonical source path, encoded by the consumer. */
+export type DocumentSourceKey = Branded<'DocumentSourceKey'>
+/**
+ * Label an authorized source locator for pre-read deduplication.
+ * @param key - unambiguous encoding of authorization scope, execution world, and canonical path.
+ * @returns branded source locator; source authorization remains the caller's responsibility.
+ */
+export function DocumentSourceKey(key: string): DocumentSourceKey { return brandString<DocumentSourceKey>(key) }
+
+/** One provider lifetime, including its engine, rendering settings, and font configuration. */
+export type DocumentRendererGeneration = Branded<'DocumentRendererGeneration'>
+/**
+ * Label a provider lifetime.
+ * @param value - unique generation created by the provider.
+ * @returns branded renderer generation.
+ */
+export function DocumentRendererGeneration(value: string): DocumentRendererGeneration {
+  return brandString<DocumentRendererGeneration>(value)
+}
+
+/** Provider generation and source-content digest; consumers must not parse it. */
+export type DocumentRenderKey = Branded<'DocumentRenderKey'>
+/**
+ * Label a renderer-owned content identity.
+ * @param value - generation and content identity created by the provider.
+ * @returns branded conversion identity.
+ */
+export function DocumentRenderKey(value: string): DocumentRenderKey { return brandString<DocumentRenderKey>(value) }
+
+/** Foreground previews and explicit QA precede speculative background conversion. */
+export type DocumentRenderPriority = 'foreground' | 'background'
+
+/** Source authorization and metadata lookup must finish before submitting a request. */
+export interface DocumentRenderRequest {
+  readonly extension: DocumentExtension
+  readonly priority: DocumentRenderPriority
+  readonly source: {
+    readonly key: DocumentSourceKey
+    readonly version: string
+    /** Authorized stat size; omission reserves the provider's entire input limit. */
+    readonly bytes?: number
+    /**
+     * Read only after provider admission; do not capture already-buffered input in queued production requests.
+     * @param signal - shared conversion lifetime, independent of an individual reader.
+     * @param maxBytes - reserved source capacity; read at most this plus one overflow sentinel byte.
+     * @returns owned bytes and the actual read version; a changed version rejects conversion.
+     */
+    read(signal: AbortSignal, maxBytes: number): Promise<{ readonly bytes: Uint8Array; readonly version: string }>
+  }
+}
+
+/** Successful conversion; failed and interrupted conversions reject instead. */
+export interface DocumentRenderResult {
+  /** Caller-owned complete PDF, valid after provider disposal. */
+  readonly pdf: Uint8Array
+  /** Requested OOXML font families unavailable to this conversion; binary Office formats return an empty list. */
+  readonly missingFonts: string[]
+  readonly cacheKey: DocumentRenderKey
+  readonly generation: DocumentRendererGeneration
+}
+
+/** Failures a conversion consumer can present without exposing engine diagnostics. */
+export type DocumentRenderErrorCode =
+  | 'input-too-large' | 'output-too-large' | 'invalid-document' | 'unsupported-format'
+  | 'invalid-output' | 'timeout' | 'unavailable' | 'failed' | 'busy' | 'source-changed'

+ 18 - 0
packages/document/document-render/tsconfig.json

@@ -0,0 +1,18 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../util/brand"
+    }
+  ]
+}

+ 2 - 2
packages/experimental/webworker-runtime/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/experimental/webworker-runtime/README.md
-README.md: 724e47a004fac83fe0f19a588faf0c227d843232
-README.zh.md: 4e88b3cf10f98a423268c60b69269d2550d2c1f9
+README.md: 6a6911824c8c71b3deb5037d1ecf629771211a26
+README.zh.md: c0ef4275507725bc6f1178ab2568c4fb6127479a

+ 1 - 0
packages/experimental/webworker-runtime/README.md

@@ -55,6 +55,7 @@ None; this package neither assembles nor sends a provider request.
 - **`node:dns/promises`, `node:vm`, `node:net`, `node:sqlite`, `node:worker_threads` are structural stubs**: every call reports its refusal on the console and throws. Rows needing native DNS, a real process, or realm isolation cannot run here.
 - **Host package commands are unavailable**: `execa` reports an explicit worker-host refusal; the preview cannot run pnpm or install native dependencies.
 - **PTC Node programs are unavailable**: the process shim exposes `/dsh/bin/node` as its executable identity so the provider can activate, but the Worker has neither a Node executable nor `stripTypeScriptTypes`. Program execution fails before launching a child.
+- **Office conversion requires a Node Host**: the LibreOffice kit replacement rejects converter creation with `unavailable`. The browser image excludes the kit and its engine dependencies; the Office preview reports that conversion is unavailable.
 - **Filesystem watchers observe only the mounted VFS**: image seeding is silent and the VFS has no symlinks or external writers. `persistent`, `ref()`, and `unref()` preserve the Node API but cannot control a dedicated Worker's lifetime because browsers expose no ref-counted event loop.
 - **Worker confinement is a VFS boundary, not kernel Landlock**: `read-only` and `workspace-write` run the unchanged `@deepseek-ai/node-addon-system/landlock-run` JavaScript and launcher argv, but the process layer implements the logical `landlock-run` executable and enforces its grants on every shell filesystem request. `full` therefore covers the Worker command table and mounted VFS only; it does not claim arbitrary native-process execution or Linux kernel isolation.
 - **The worker bundle pins a path inside `@yarnpkg/parsers`** — the build resolves the package's own `lib/shell.js` instead of its root, whose barrel also re-exports the Syml parser and so drags js-yaml into a bundle that never parses that format (around 175 kB, plus its module body at worker start). The path is derived from the package manifest, so a layout change fails the build rather than reinstating the barrel; upgrading the dependency means re-checking that the shell parser still lives there.

+ 1 - 0
packages/experimental/webworker-runtime/README.zh.md

@@ -55,6 +55,7 @@ kind: "package-library"
 - **`node:dns/promises`、`node:vm`、`node:net`、`node:sqlite`、`node:worker_threads` 是结构化 stub**:每次调用在 console 报告拒绝并抛出。需要原生 DNS、真进程或真 realm 隔离的行在此无法运行。
 - **宿主包管理命令不可用**:`execa` 明确报告 worker host 不支持该调用;预览无法运行 pnpm 或安装原生依赖。
 - **PTC Node 程序不可用**:process shim 用 `/dsh/bin/node` 表示可执行文件身份,使 provider 能够激活,但 Worker 既没有 Node 可执行文件,也没有 `stripTypeScriptTypes`。程序执行会在启动子进程前失败。
+- **Office 转换需要 Node Host**:LibreOffice kit 的替代模块以 `unavailable` 拒绝创建转换器。浏览器镜像排除 kit 及其引擎依赖;Office 预览提示转换不可用。
 - **文件 watcher 只能观察已挂载的 VFS**:镜像 seed 不产生事件,VFS 也没有符号链接或外部写入方。`persistent`、`ref()` 和 `unref()` 保留 Node API,但浏览器没有引用计数事件循环,因此这些接口不能控制 dedicated Worker 的生存期。
 - **Worker confinement 是 VFS 边界,不是内核 Landlock**:`read-only` 和 `workspace-write` 运行未经修改的 `@deepseek-ai/node-addon-system/landlock-run` JavaScript 与 launcher argv,进程层则实现逻辑 `landlock-run` 可执行文件,并在 shell 的每次文件系统请求上执行其授权。`full` 仅覆盖 Worker 命令表和已挂载 VFS,不表示能够执行任意 native 进程,也不表示 Linux 内核隔离。
 - **worker 束钉住了 `@yarnpkg/parsers` 的包内路径**——构建解析到该包自己的 `lib/shell.js` 而非包根,因为包根 barrel 还 re-export 了 Syml 解析器,会把 js-yaml 拖进一个从不解析该格式的束(约 175 kB,外加 worker 启动时的模块体求值)。该路径由包 manifest 派生,包内布局一变即构建期失败、不会静默退回 barrel;升级这个依赖时须复核 shell 解析器是否仍在那里。

+ 1 - 0
packages/experimental/webworker-runtime/src/module-proxies.ts

@@ -65,6 +65,7 @@ export const MODULE_PROXIES: Record<string, string> = {
   'node:worker_threads': './node/builtin_modules/mock/worker_threads.ts',
   'node:sqlite': './node/builtin_modules/mock/sqlite.ts',
   // External npm replacements, named after the package each stands in for.
+  '@deepseek-ai/libreoffice-kit': './node/external_packages/libreoffice-kit.ts',
   '@deepseek-ai/node-addon-system/flock': './node/external_packages/node-addon-system-flock.ts',
   'koffi': './node/external_packages/koffi.ts',
   'sharp': './node/external_packages/sharp.ts',

+ 2 - 0
packages/experimental/webworker-runtime/src/node/builtins.ts

@@ -47,6 +47,7 @@ import * as nodeVm from './builtin_modules/mock/vm.ts'
 import * as nodeWorkerThreads from './builtin_modules/mock/worker_threads.ts'
 import * as systemFlock from './external_packages/node-addon-system-flock.ts'
 import * as koffi from './external_packages/koffi.ts'
+import * as libreofficeKit from './external_packages/libreoffice-kit.ts'
 import * as nodePty from './external_packages/node-pty.ts'
 import * as execa from './external_packages/execa.ts'
 import * as piAi from './external_packages/pi-ai.ts'
@@ -87,6 +88,7 @@ const BUILTINS: Record<string, StaticModuleFactory> = {
 
 /** Exact package or subpath specifiers served by worker stubs and fakes. */
 const EXTERNALS: Record<string, StaticModuleFactory> = {
+  '@deepseek-ai/libreoffice-kit': () => libreofficeKit,
   '@deepseek-ai/node-addon-system/flock': () => systemFlock,
   'koffi': () => koffi,
   'sharp': () => sharp,

+ 12 - 0
packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts

@@ -0,0 +1,12 @@
+/** Office conversion requires a Node Host; the browser preview carries no engine assets. */
+import { notAvailableError } from '../notImplementedFail.ts'
+
+/**
+ * Refuse converter creation with the kit's unavailable error code.
+ * @returns A rejected promise consumed by the Host document-render provider.
+ */
+export function createConverter(): Promise<never> {
+  return Promise.reject(Object.assign(notAvailableError('@deepseek-ai/libreoffice-kit', 'createConverter'), {
+    code: 'unavailable',
+  }))
+}

+ 1 - 0
packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts

@@ -9,6 +9,7 @@
 export const REPLACED_EXTERNAL_PACKAGES: readonly string[] = [
   '@earendil-works/pi-ai',
   '@vscode/ripgrep',
+  '@deepseek-ai/libreoffice-kit',
   '@deepseek-ai/node-addon-system/flock',
   'koffi',
   'node-pty',

+ 9 - 0
packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts

@@ -21,6 +21,7 @@ import * as stream from '../../src/node/builtin_modules/implemented/stream.ts'
 import * as vm from '../../src/node/builtin_modules/mock/vm.ts'
 import * as workerThreads from '../../src/node/builtin_modules/mock/worker_threads.ts'
 import * as nodePty from '../../src/node/external_packages/node-pty.ts'
+import * as libreofficeKit from '../../src/node/external_packages/libreoffice-kit.ts'
 import * as execa from '../../src/node/external_packages/execa.ts'
 import * as piAi from '../../src/node/external_packages/pi-ai.ts'
 import * as ripgrep from '../../src/node/external_packages/ripgrep.ts'
@@ -118,6 +119,14 @@ describe('constructible-but-inert fakes', () => {
 })
 
 describe('replaced external packages', () => {
+  it('reports Office conversion as unavailable without creating a Node worker', async () => {
+    quiet()
+    await expect(libreofficeKit.createConverter()).rejects.toMatchObject({
+      code: 'unavailable',
+      message: 'web-preview: @deepseek-ai/libreoffice-kit.createConverter is not available in the worker host',
+    })
+  })
+
   it('lists the packages the loader serves from the bundle', () => {
     expect(REPLACED_EXTERNAL_PACKAGES).not.toContain('chokidar')
     expect(REPLACED_EXTERNAL_PACKAGES).not.toContain('@deepseek-ai/node-addon-system')

+ 53 - 0
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -874,6 +874,25 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
     ],
   },
+  {
+    key: 'documentRender',
+    summary: 'Load one provider subclass per context; consumers own source authorization.',
+    description: 'Load one provider subclass per context; consumers own source authorization.',
+    methods: [
+      {
+        signature: 'abstract readonly generation: DocumentRendererGeneration',
+        description: 'Changes whenever engine, font, or rendering configuration is replaced.',
+        parameters: [],
+      },
+      {
+        signature: 'abstract render(request: DocumentRenderRequest, signal?: AbortSignal): Promise<DocumentRenderResult>',
+        description: 'Convert Office bytes without modifying the source or writing Session events.',
+        parameters: [{ name: 'request', description: 'authorized metadata and deferred bounded source read.' }, { name: 'signal', description: 'caller cancellation; provider disposal also stops active work.' }],
+        returns: 'caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently.',
+        throws: ['{DocumentRenderError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.'],
+      },
+    ],
+  },
   {
     key: 'fileReferences',
     summary: 'Host capability for cancellable file-reference discovery.',
@@ -3227,6 +3246,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'path', description: 'absolute or workspace-relative file path.' }, { name: 'signal', description: 'caller cancellation.' }],
         returns: 'one complete base64 window with offset zero and eof true; oversized files fail with too-large.',
       },
+      {
+        signature: 'async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise<WorkspaceFileBytes>',
+        description: 'Read a complete authorized file within a Host consumer\'s reserved byte capacity.',
+        parameters: [{ name: 'workspaceFileScope', description: 'Session authorization and execution scope.' }, { name: 'path', description: 'absolute or workspace-relative file path.' }, { name: 'maxBytes', description: 'positive reserved capacity; the configured full-file cap still applies.' }, { name: 'signal', description: 'caller cancellation.' }],
+        returns: 'complete base64 bytes; reads at most the effective limit plus one overflow sentinel.',
+      },
       {
         signature: '@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise<WorkspaceFileBytes>',
         description: 'Read a complete file relative to another file\'s directory, including outside the workspace.',
@@ -4448,6 +4473,34 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'DirectoryRegistrationHandle',
     declaration: 'export interface DirectoryRegistrationHandle {\n    (): void;\n    replace(entries: readonly LlmConfigurableProvider[]): void;\n}',
   },
+  {
+    name: 'DocumentExtension',
+    declaration: 'export type DocumentExtension = \'doc\' | \'docx\' | \'xls\' | \'xlsx\' | \'ppt\' | \'pptx\';',
+  },
+  {
+    name: 'DocumentRendererGeneration',
+    declaration: 'export type DocumentRendererGeneration = Branded<\'DocumentRendererGeneration\'>;',
+  },
+  {
+    name: 'DocumentRenderKey',
+    declaration: 'export type DocumentRenderKey = Branded<\'DocumentRenderKey\'>;',
+  },
+  {
+    name: 'DocumentRenderPriority',
+    declaration: 'export type DocumentRenderPriority = \'foreground\' | \'background\';',
+  },
+  {
+    name: 'DocumentRenderRequest',
+    declaration: 'export interface DocumentRenderRequest {\n    readonly extension: DocumentExtension;\n    readonly priority: DocumentRenderPriority;\n    readonly source: {\n        readonly key: DocumentSourceKey;\n        readonly version: string;\n        readonly bytes?: number;\n        read(signal: AbortSignal, maxBytes: number): Promise<{\n            readonly bytes: Uint8Array;\n            readonly version: string;\n        }>;\n    };\n}',
+  },
+  {
+    name: 'DocumentRenderResult',
+    declaration: 'export interface DocumentRenderResult {\n    readonly pdf: Uint8Array;\n    readonly missingFonts: string[];\n    readonly cacheKey: DocumentRenderKey;\n    readonly generation: DocumentRendererGeneration;\n}',
+  },
+  {
+    name: 'DocumentSourceKey',
+    declaration: 'export type DocumentSourceKey = Branded<\'DocumentSourceKey\'>;',
+  },
   {
     name: 'Domain',
     declaration: 'export interface Domain<S extends DomainSpec> {\n    readonly name: string;\n    readonly global: DomainGlobalHandleOf<S>;\n    table<N extends keyof S[\'tables\'] & string>(name: N): KvTable<TableKeyOf<S, N>, TableValueOf<S, N>>;\n    close(): Promise<void>;\n}',

+ 179 - 5
pnpm-lock.yaml

@@ -2223,6 +2223,12 @@ importers:
       '@deepseek-ai/dsh-cordis-host-runner':
         specifier: workspace:^
         version: link:../../extensions/cordis-host-runner
+      '@deepseek-ai/dsh-document-render':
+        specifier: workspace:^
+        version: link:../../document/document-render
+      '@deepseek-ai/dsh-document-render-libreoffice':
+        specifier: workspace:^
+        version: link:../../document/document-render-libreoffice
       '@deepseek-ai/dsh-file-reference':
         specifier: workspace:^
         version: link:../../context/file-reference
@@ -5954,6 +5960,38 @@ importers:
         specifier: workspace:^
         version: link:../../core/tools
 
+  packages/document/document-render:
+    dependencies:
+      '@deepseek-ai/dsh-brand':
+        specifier: workspace:^
+        version: link:../../util/brand
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+
+  packages/document/document-render-libreoffice:
+    dependencies:
+      '@deepseek-ai/libreoffice-kit':
+        specifier: 0.0.1
+        version: 0.0.1
+      '@deepseek-ai/schemastery':
+        specifier: link:../../../vendor/schemastery
+        version: link:../../../vendor/schemastery
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/cordis-plugin-include':
+        specifier: workspace:^
+        version: link:../../../vendor/include
+      '@deepseek-ai/cordis-plugin-loader':
+        specifier: workspace:^
+        version: link:../../../vendor/loader
+      '@deepseek-ai/dsh-document-render':
+        specifier: workspace:^
+        version: link:../document-render
+
   packages/experimental/agent-team:
     dependencies:
       '@deepseek-ai/dsh-brand':
@@ -8081,7 +8119,7 @@ importers:
         version: link:../../../vendor/schemastery
       '@earendil-works/pi-ai':
         specifier: ^0.85.1
-        version: 0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3)
+        version: 0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -13255,6 +13293,39 @@ packages:
     resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
     engines: {node: '>=20.19.0'}
 
+  '@deepseek-ai/libreoffice-kit-darwin-arm64@0.0.1':
+    resolution: {integrity: sha512-3vrYUIZihcb+F9qjjHdWNSxW83fLihrBV63qaw5kxW1Q7hbZPPMDsjlRJ2rX6VUi06By4nJH9YLKZ73iGN5iEQ==}
+    engines: {node: '>=22.19.0'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@deepseek-ai/libreoffice-kit-darwin-x64@0.0.1':
+    resolution: {integrity: sha512-xMMXJkjggd9c1g85oLNHiJl1xgowe0EG60ib8up3Pz0qFD9HORMu0/eP1eB19E8jLRwqF9Sb0TTrcqp68aVcyw==}
+    engines: {node: '>=22.19.0'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@deepseek-ai/libreoffice-kit-wasm@0.0.1':
+    resolution: {integrity: sha512-VHGr+MvRQMCXxVvdYZ8qJnVXOMjUivntvNHCDBog+cCjgEfWjUl+/0rfvF2auPjnWUzqvjKMEdqbemhc1zuqFg==}
+    engines: {node: '>=22.19.0'}
+    os: [linux]
+
+  '@deepseek-ai/libreoffice-kit-win32-arm64@0.0.1':
+    resolution: {integrity: sha512-9G7YxXHEFaVn5iFM6IaHusWuXAd3cEprwTYSA1OsYMUREliHzdcmJEqcEyePDpX1y39KNKWQgowYeSpFbfrbgg==}
+    engines: {node: '>=22.19.0'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@deepseek-ai/libreoffice-kit-win32-x64@0.0.1':
+    resolution: {integrity: sha512-+DPPT5V6rfwWfMf7scfgzxwNBQiuePdY4dmVvWb4QAINgquOXUAyWVNZaThWNCO8baRn1l4fb3L5sdbLWib3zQ==}
+    engines: {node: '>=22.19.0'}
+    cpu: [x64]
+    os: [win32]
+
+  '@deepseek-ai/libreoffice-kit@0.0.1':
+    resolution: {integrity: sha512-e4JZqohz5TEVfI3sfUK/dHRLjqlQep96CmqNM93Rm7LY9PuECiJakIX2BdHi5OkmbK9Mo47SiW44HNTTiwW1yw==}
+    engines: {node: '>=22.19.0'}
+
   '@docsearch/css@3.8.2':
     resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==}
 
@@ -15331,6 +15402,9 @@ packages:
     peerDependencies:
       eslint: ^9.0.0 || ^10.0.0
 
+  '@swc/helpers@0.5.23':
+    resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
+
   '@szmarczak/http-timer@4.0.6':
     resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
     engines: {node: '>=10'}
@@ -16210,6 +16284,9 @@ packages:
     resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
     engines: {node: 18 || 20 || >=22}
 
+  brotli@1.3.3:
+    resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==}
+
   browserslist@4.28.6:
     resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==}
     engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -16351,6 +16428,10 @@ packages:
   clone-response@1.0.3:
     resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==}
 
+  clone@2.1.2:
+    resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
+    engines: {node: '>=0.8'}
+
   clsx@2.1.1:
     resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
     engines: {node: '>=6'}
@@ -16715,6 +16796,9 @@ packages:
   devlop@1.1.0:
     resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
 
+  dfa@1.2.0:
+    resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
+
   diff@8.0.4:
     resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
     engines: {node: '>=0.3.1'}
@@ -17058,6 +17142,9 @@ packages:
     resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
     engines: {node: ^12.20 || >= 14.13}
 
+  fflate@0.8.2:
+    resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
+
   fflate@0.8.3:
     resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
 
@@ -17099,6 +17186,9 @@ packages:
       debug:
         optional: true
 
+  fontkit@2.0.4:
+    resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
+
   foreground-child@3.3.1:
     resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
     engines: {node: '>=14'}
@@ -18357,6 +18447,9 @@ packages:
   package-manager-detector@1.6.0:
     resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
 
+  pako@0.2.9:
+    resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
+
   pako@1.0.11:
     resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
 
@@ -18684,6 +18777,9 @@ packages:
   responselike@2.0.1:
     resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==}
 
+  restructure@3.0.2:
+    resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==}
+
   retry@0.12.0:
     resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
     engines: {node: '>= 4'}
@@ -19063,6 +19159,9 @@ packages:
   tiny-async-pool@1.3.0:
     resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==}
 
+  tiny-inflate@1.0.3:
+    resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
+
   tiny-typed-emitter@2.1.0:
     resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==}
 
@@ -19248,6 +19347,12 @@ packages:
     resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
     engines: {node: '>=22.19.0'}
 
+  unicode-properties@1.4.1:
+    resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==}
+
+  unicode-trie@2.0.0:
+    resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
+
   unicorn-magic@0.3.0:
     resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
     engines: {node: '>=18'}
@@ -20414,6 +20519,33 @@ snapshots:
 
   '@csstools/css-tokenizer@4.0.0': {}
 
+  '@deepseek-ai/libreoffice-kit-darwin-arm64@0.0.1':
+    optional: true
+
+  '@deepseek-ai/libreoffice-kit-darwin-x64@0.0.1':
+    optional: true
+
+  '@deepseek-ai/libreoffice-kit-wasm@0.0.1':
+    optional: true
+
+  '@deepseek-ai/libreoffice-kit-win32-arm64@0.0.1':
+    optional: true
+
+  '@deepseek-ai/libreoffice-kit-win32-x64@0.0.1':
+    optional: true
+
+  '@deepseek-ai/libreoffice-kit@0.0.1':
+    dependencies:
+      fflate: 0.8.2
+      fontkit: 2.0.4
+      saxes: 6.0.0
+    optionalDependencies:
+      '@deepseek-ai/libreoffice-kit-darwin-arm64': 0.0.1
+      '@deepseek-ai/libreoffice-kit-darwin-x64': 0.0.1
+      '@deepseek-ai/libreoffice-kit-wasm': 0.0.1
+      '@deepseek-ai/libreoffice-kit-win32-arm64': 0.0.1
+      '@deepseek-ai/libreoffice-kit-win32-x64': 0.0.1
+
   '@docsearch/css@3.8.2': {}
 
   '@docsearch/js@3.8.2(@algolia/client-search@5.55.2)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)':
@@ -20442,14 +20574,14 @@ snapshots:
     transitivePeerDependencies:
       - '@algolia/client-search'
 
-  '@earendil-works/pi-ai@0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3)':
+  '@earendil-works/pi-ai@0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)':
     dependencies:
       '@anthropic-ai/sdk': 0.123.0(zod@4.4.3)
       '@aws-sdk/client-bedrock-runtime': 3.1048.0
       '@earendil-works/pi-telemetry': 0.85.1
       '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))
       '@smithy/node-http-handler': 4.7.3
-      http-proxy-agent: 7.0.2(supports-color@9.4.0)
+      http-proxy-agent: 7.0.2
       https-proxy-agent: 7.0.6
       openai: 6.40.0(ws@8.21.0)(zod@4.4.3)
       partial-json: 0.1.7
@@ -22033,6 +22165,10 @@ snapshots:
       estraverse: 5.3.0
       picomatch: 4.0.4
 
+  '@swc/helpers@0.5.23':
+    dependencies:
+      tslib: 2.8.1
+
   '@szmarczak/http-timer@4.0.6':
     dependencies:
       defer-to-connect: 2.0.1
@@ -23028,6 +23164,10 @@ snapshots:
     dependencies:
       balanced-match: 4.0.4
 
+  brotli@1.3.3:
+    dependencies:
+      base64-js: 1.5.1
+
   browserslist@4.28.6:
     dependencies:
       baseline-browser-mapping: 2.10.43
@@ -23069,7 +23209,7 @@ snapshots:
       cross-spawn: 7.0.6
       debug: 4.4.3(supports-color@9.4.0)
       fs-extra: 10.1.0
-      http-proxy-agent: 7.0.2(supports-color@9.4.0)
+      http-proxy-agent: 7.0.2
       https-proxy-agent: 7.0.6
       js-yaml: 4.3.1
       sanitize-filename: 1.6.4
@@ -23173,6 +23313,8 @@ snapshots:
     dependencies:
       mimic-response: 1.0.1
 
+  clone@2.1.2: {}
+
   clsx@2.1.1: {}
 
   color-convert@2.0.1:
@@ -23533,6 +23675,8 @@ snapshots:
     dependencies:
       dequal: 2.0.3
 
+  dfa@1.2.0: {}
+
   diff@8.0.4: {}
 
   diff@9.0.0: {}
@@ -24030,6 +24174,8 @@ snapshots:
       node-domexception: 1.0.0
       web-streams-polyfill: 3.3.3
 
+  fflate@0.8.2: {}
+
   fflate@0.8.3: {}
 
   figures@6.1.0:
@@ -24073,6 +24219,18 @@ snapshots:
 
   follow-redirects@1.16.0: {}
 
+  fontkit@2.0.4:
+    dependencies:
+      '@swc/helpers': 0.5.23
+      brotli: 1.3.3
+      clone: 2.1.2
+      dfa: 1.2.0
+      fast-deep-equal: 3.1.3
+      restructure: 3.0.2
+      tiny-inflate: 1.0.3
+      unicode-properties: 1.4.1
+      unicode-trie: 2.0.0
+
   foreground-child@3.3.1:
     dependencies:
       cross-spawn: 7.0.6
@@ -24366,7 +24524,7 @@ snapshots:
       statuses: 2.0.2
       toidentifier: 1.0.1
 
-  http-proxy-agent@7.0.2(supports-color@9.4.0):
+  http-proxy-agent@7.0.2:
     dependencies:
       agent-base: 7.1.4
       debug: 4.4.3(supports-color@9.4.0)
@@ -25541,6 +25699,8 @@ snapshots:
 
   package-manager-detector@1.6.0: {}
 
+  pako@0.2.9: {}
+
   pako@1.0.11: {}
 
   parse-ms@4.0.0: {}
@@ -25859,6 +26019,8 @@ snapshots:
     dependencies:
       lowercase-keys: 2.0.0
 
+  restructure@3.0.2: {}
+
   retry@0.12.0: {}
 
   retry@0.13.1: {}
@@ -26383,6 +26545,8 @@ snapshots:
     dependencies:
       semver: 5.7.2
 
+  tiny-inflate@1.0.3: {}
+
   tiny-typed-emitter@2.1.0: {}
 
   tinybench@2.9.0: {}
@@ -26526,6 +26690,16 @@ snapshots:
 
   undici@8.10.0: {}
 
+  unicode-properties@1.4.1:
+    dependencies:
+      base64-js: 1.5.1
+      unicode-trie: 2.0.0
+
+  unicode-trie@2.0.0:
+    dependencies:
+      pako: 0.2.9
+      tiny-inflate: 1.0.3
+
   unicorn-magic@0.3.0: {}
 
   union@0.5.0:

+ 7 - 0
pnpm-workspace.yaml

@@ -52,6 +52,13 @@ allowBuilds:
   msgpackr-extract: false
 
 minimumReleaseAgeExclude:
+  # Office engines and their API are qualified and released together by the kit repository.
+  - '@deepseek-ai/libreoffice-kit@0.0.1'
+  - '@deepseek-ai/libreoffice-kit-darwin-arm64@0.0.1'
+  - '@deepseek-ai/libreoffice-kit-darwin-x64@0.0.1'
+  - '@deepseek-ai/libreoffice-kit-win32-arm64@0.0.1'
+  - '@deepseek-ai/libreoffice-kit-win32-x64@0.0.1'
+  - '@deepseek-ai/libreoffice-kit-wasm@0.0.1'
   # Fresh pi-ai releases carry the model catalog updates that are the whole
   # point of bumping it; waiting out the release age would defeat that.
   - '@earendil-works/pi-ai@0.85.1'

+ 2 - 2
python/sdk-runtime/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 python/sdk-runtime/README.md
-README.md: 8ab226427f89d2746d6da13bcd8bd254731f2460
-README.zh.md: 23a042006c38425a83347322fa63f0fafb319d0d
+README.md: ba1814ce00dea9dd35bcfbb330801942b86af155
+README.zh.md: 792c7e06a29be987a25887882b6df976138b194f

+ 5 - 1
python/sdk-runtime/README.md

@@ -10,6 +10,8 @@ The wheel installs a `dsh` console command and the `deepseek_harness_runtime` Py
 
 Production executables are named `deepseek-harness-sdk-runtime-<platform>-<arch>` under the module's `runtime/` directory; Windows uses the `.exe` suffix. Linux and macOS wheels include a target-native `-rg` sidecar, Windows includes `-rg.exe`, and macOS also includes `-spawn-helper` for `node-pty`. Published targets are Linux x64, Linux arm64, macOS arm64, macOS x64, and Windows x64. The wheel tag and payload must match exactly; no Windows arm64 wheel is published.
 
+Each target also requires `<executable-stem>-office/`, where the stem excludes `.exe`. This directory contains the complete installed Office packages and their dependencies, preserving engine resources, manifests, licenses, source inventories, and helper permissions. Copy this directory together with the executable. A missing target engine fails the sidecar build with its npm package name and target platform/architecture.
+
 Repository builds also materialize a dev-only `runtime/node/` carrier. It runs `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. It is never selected automatically and is excluded from wheels and sdists.
 
 Both carriers execute the same `dsh` grammar and shipped profiles, including the standalone `sdk-minimal` tree and the full `web` profile with its frontend assets. The private `dsh-python-runtime-closure` manifest defines the packaged dependency closure; there is no Python-specific Node application or checked-in default `cordis.yml`.
@@ -27,6 +29,8 @@ Unsupported platforms and missing executables or sidecars raise `FileNotFoundErr
 
 `dsh` initializes shipped profiles under the explicit home, composes their bundle patches, and loads bundled plugins from the executable's virtual filesystem. Because operating-system symlinks cannot enter that filesystem, packaged launches maintain small real ESM proxy packages under `$DSH_HOME/profiles/node_modules`. Each proxy mirrors explicit runtime exports, records the original package identity, and re-exports the virtual module URL. Built-in rows and external plugin peers therefore share one Cordis/module instance. Native shared libraries and Windows ConPTY addons are packaged with native addons, while ripgrep and the macOS PTY helper remain executable sidecars.
 
+The Python bootstrap resolves the Office kit from its adjacent directory so native helpers and URL Workers use real filesystem paths. The kit owns engine selection and validation; the Python bootstrap adds no runtime download or compilation.
+
 External profile management uses `dsh plugin --profile <name> ...`. That command requires `pnpm` on `PATH`; ordinary SDK/profile execution does not.
 
 ## Build and distribution
@@ -35,4 +39,4 @@ Production deployment permits unused workspace patches for packages outside the
 
 From the repository root, `pnpm exec tsx scripts/build-exe-for-python-sdk.ts` verifies the closure, builds packages, deploys a symlink-free tree, packages the selected target, and syncs the executable and sidecars into this module. `scripts/build-python-release.py` stages release-shaped wheels at the root repository version and pins `deepseek-harness-sdk` to the exact runtime version.
 
-The installed-wheel smoke creates a clean virtual environment outside the checkout, proves the installed distribution and executable identities, then exercises default and customized SDK profiles, external plugins, MCP, native tools, direct JSON-RPC, committed snapshots, and the real provider on trusted runs. See the [Python contributor workflow](../development.md) and [installed-wheel testing decision](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md).
+The installed-wheel smoke creates a clean virtual environment outside the checkout, proves the installed distribution and executable identities, then exercises default and customized SDK profiles, external plugins, MCP, native tools, direct JSON-RPC, committed snapshots, and the real provider on trusted runs. Its Office scenario relocates the complete target payload and converts DOCX with the required platform engine: native on macOS/Windows and WASM on Linux. See the [Python contributor workflow](../development.md) and [installed-wheel testing decision](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md).

+ 5 - 1
python/sdk-runtime/README.zh.md

@@ -10,6 +10,8 @@ wheel 包会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python 
 
 生产可执行程序位于模块的 `runtime/` 目录,命名为 `deepseek-harness-sdk-runtime-<platform>-<arch>`;Windows 使用 `.exe` 后缀。Linux 与 macOS wheel 包含目标平台原生的 `-rg` 伴随程序,Windows 包含 `-rg.exe`,macOS 还包含 `node-pty` 使用的 `-spawn-helper`。已发布目标是 Linux x64、Linux arm64、macOS arm64、macOS x64 与 Windows x64。wheel 包标签必须与载荷严格匹配;不发布 Windows arm64 wheel 包。
 
+每个目标还要求 `<executable-stem>-office/`,其中 stem 不含 `.exe`。该目录包含完整的已安装 Office 包及其依赖,保留引擎资源、清单、许可证、源码清单与辅助程序权限。复制可执行文件时必须一并复制此目录。缺少目标引擎会使 sidecar 构建失败,错误会指出其 npm 包名与目标平台/架构。
+
 仓库构建还会物化仅限开发的 `runtime/node/` 载体。它在系统 Node 22.19 或更高版本上运行 `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。系统不会自动选择它,而且 wheel 包与 sdist 均不包含它。
 
 两种载体执行相同的 `dsh` 语法与随附 profile,包括独立的 `sdk-minimal` 配置树,以及包含前端产物的完整 `web` profile。私有 `dsh-python-runtime-closure` manifest(元数据清单)定义打包依赖闭包;不存在 Python 专用 Node 应用或检入的默认 `cordis.yml`。
@@ -27,6 +29,8 @@ wheel 包会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python 
 
 `dsh` 在显式指定的主目录下初始化随附 profile、组合其 bundle patch,并从可执行程序的虚拟文件系统加载内置插件。操作系统符号链接无法进入该文件系统,因此打包运行会在 `$DSH_HOME/profiles/node_modules` 下维护小型真实 ESM 代理包。每个代理复现运行时的显式导出项、记录原包身份,并重新导出虚拟模块 URL。因此,内置配置项与外部插件 peer 会共享同一个 Cordis/模块实例。原生共享库与 Windows ConPTY addon 会同其他原生 addon 一起打包;ripgrep 与 macOS PTY helper 仍是可执行伴随程序。
 
+Python bootstrap 从相邻目录解析 Office kit,让原生辅助程序与 URL Worker 使用真实文件系统路径。kit 负责引擎选择与校验;Python bootstrap 不增加运行时下载或编译。
+
 外部 profile 管理使用 `dsh plugin --profile <name> ...`。该命令要求 `PATH` 中存在 `pnpm`;普通 SDK/profile 运行不需要它。
 
 ## 构建与分发
@@ -35,4 +39,4 @@ wheel 包会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python 
 
 在仓库根目录运行 `pnpm exec tsx scripts/build-exe-for-python-sdk.ts`,会校验闭包、构建包、部署无符号链接的文件树、打包所选目标,并把可执行程序及伴随文件同步到本模块。`scripts/build-python-release.py` 按仓库根版本暂存发布形态的 wheel 包,并将 `deepseek-harness-sdk` 固定到完全相同的运行时版本。
 
-已安装 wheel 包冒烟测试会在检出目录外创建干净的虚拟环境,验证分发物与可执行程序的来源,然后覆盖默认及自定义 SDK profile、外部插件、MCP、原生工具、直接 JSON-RPC、检入快照,以及可信运行中的真实提供方。另见 [Python 贡献者工作流](../development.zh.md) 与 [installed-wheel 测试决策](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)。
+已安装 wheel 包冒烟测试会在检出目录外创建干净的虚拟环境,验证分发物与可执行程序的来源,然后覆盖默认及自定义 SDK profile、外部插件、MCP、原生工具、直接 JSON-RPC、检入快照,以及可信运行中的真实提供方。Office 场景会迁移完整的目标载荷目录,并使用所需的平台引擎转换 DOCX:macOS/Windows 使用原生引擎,Linux 使用 WASM。另见 [Python 贡献者工作流](../development.zh.md) 与 [installed-wheel 测试决策](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)。

+ 11 - 0
python/sdk-runtime/hatch_build.py

@@ -73,6 +73,8 @@ class RuntimeBuildHook(BuildHookInterface):
                 f"unsupported DSH_RUNTIME_PLATFORM_TAG {platform_tag!r}; expected one of {supported}"
             )
         expected_executable = matches[0][1]
+        target = next(name for name, value in _PLATFORMS.items() if value[0] == platform_tag)
+        engine = "wasm" if target.startswith("linux-") else target.replace("win-", "win32-").replace("macos-", "darwin-")
         runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime"
         runtime_files = sorted(
             runtime_dir.glob("deepseek-harness-sdk-runtime-*") if runtime_dir.is_dir() else []
@@ -84,6 +86,8 @@ class RuntimeBuildHook(BuildHookInterface):
         )
         if "-macos-" in expected_executable:
             expected_files.append(f"{expected_executable}-spawn-helper")
+        office = runtime_dir / f"{expected_executable.removesuffix('.exe')}-office"
+        expected_files.append(office.name)
         expected_files.sort()
         found_files = [path.name for path in runtime_files]
         if found_files != expected_files:
@@ -91,6 +95,13 @@ class RuntimeBuildHook(BuildHookInterface):
                 f"runtime wheel {platform_tag} payload must be {expected_files}; found {found_files}"
             )
         for executable in runtime_files:
+            if executable == office:
+                for required in ("@deepseek-ai/libreoffice-kit/package.json", f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json"):
+                    if not (office / "node_modules" / required).is_file():
+                        raise RuntimeError(f"runtime Office dependency is missing: {office / required}")
+                continue
+            if not executable.is_file():
+                raise RuntimeError(f"runtime executable is not a file: {executable}")
             if platform_tag != "win_amd64" and executable.stat().st_mode & stat.S_IXUSR == 0:
                 raise RuntimeError(f"runtime executable is not executable: {executable}")
         build_data["pure_python"] = False

+ 1 - 1
python/sdk-runtime/pyproject.toml

@@ -22,7 +22,7 @@ dsh = "deepseek_harness_runtime:main"
 
 # Include the injected dsh executable and sidecars; exclude the dev-only node closure.
 [tool.hatch.build]
-artifacts = ["src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-*"]
+artifacts = ["src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-*", "src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-*/**"]
 exclude = ["src/deepseek_harness_runtime/runtime/node"]
 
 [tool.hatch.build.targets.wheel]

+ 14 - 1
python/sdk-runtime/runtime-bootstrap.mjs

@@ -1,6 +1,19 @@
 #!/usr/bin/env node
 /** Private entry owned by the Python single-file runtime packaging. */
-import { fileURLToPath } from 'node:url'
+import { registerHooks } from 'node:module'
+import { isSea } from 'node:sea'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+
+if (isSea()) {
+  // Office spawns executable helpers and URL workers; its complete package tree must be real files.
+  const parentURL = pathToFileURL(`${process.execPath.replace(/\.exe$/i, '')}-office/package.json`).href
+  registerHooks({
+    resolve(specifier, context, nextResolve) {
+      const office = specifier === '@deepseek-ai/libreoffice-kit' || specifier === '@deepseek-ai/libreoffice-kit/package.json'
+      return nextResolve(specifier, office ? { ...context, parentURL } : context)
+    },
+  })
+}
 
 const selectorName = 'DSH_SUBPROCESS_RUNNER'
 const selection = process.env[selectorName]

+ 9 - 1
python/sdk-runtime/src/deepseek_harness_runtime/__init__.py

@@ -57,7 +57,7 @@ def bundled_runtime_path() -> Path:
     """Absolute path of the bundled single-file runtime executable for the current platform.
 
     Raises FileNotFoundError when the platform is unsupported, the executable
-    has not been placed into this package, the required ripgrep sidecar is
+    has not been placed into this package, the ripgrep or Office sidecar is
     missing, or the required macOS spawn helper is missing; the message names
     the acquisition routes (acquisition strategy is deliberately separate from
     this lookup interface, so an on-demand download can replace it without
@@ -88,6 +88,14 @@ def bundled_runtime_path() -> Path:
                 f"deepseek-harness-runtime-bin is missing the node-pty spawn helper at {helper}. "
                 + _EXE_ACQUISITION_HINT
             )
+    office = path.with_name(f"{path.name.removesuffix('.exe')}-office")
+    engine = "wasm" if tag.startswith("linux-") else tag.replace("win-", "win32-").replace("macos-", "darwin-")
+    for required in ("@deepseek-ai/libreoffice-kit/package.json", f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json"):
+        if not (office / "node_modules" / required).is_file():
+            raise FileNotFoundError(
+                f"deepseek-harness-runtime-bin is missing the Office sidecar at {office}. "
+                + _EXE_ACQUISITION_HINT
+            )
     return path
 
 

+ 46 - 0
python/sdk/tests/test_release_version.py

@@ -4,6 +4,7 @@ from __future__ import annotations
 
 import json
 import runpy
+import zipfile
 from pathlib import Path
 from types import SimpleNamespace
 
@@ -119,6 +120,10 @@ def test_stage_runtime_copies_platform_payload(
         spawn_helper.write_bytes(b"helper")
         spawn_helper.chmod(0o755)
         expected[spawn_helper.name] = b"helper"
+    office = executable.parent / build_python_release.office_sidecar_name(executable.name)
+    office_asset = office / "node_modules" / "@deepseek-ai" / "libreoffice-kit-wasm" / "assets" / "soffice.data"
+    office_asset.parent.mkdir(parents=True)
+    office_asset.write_bytes(b"office data")
     destination = tmp_path / "staging"
 
     build_python_release.stage_runtime(destination, "1.2.3", executable, executable.name)
@@ -127,7 +132,9 @@ def test_stage_runtime_copies_platform_payload(
     assert {
         path.name: path.read_bytes()
         for path in runtime_dir.glob("deepseek-harness-sdk-runtime-*")
+        if path.is_file()
     } == expected
+    assert (runtime_dir / office.name / office_asset.relative_to(office)).read_bytes() == b"office data"
     pyproject = (destination / "pyproject.toml").read_text()
     assert 'license = "MIT"' in pyproject
     assert 'license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]' in pyproject
@@ -152,3 +159,42 @@ def test_stage_runtime_rejects_a_noncanonical_executable_name(tmp_path: Path) ->
             executable,
             "deepseek-harness-sdk-runtime-win-x64.exe",
         )
+
+
+@pytest.mark.parametrize("platform_tag,selected", [
+    ("manylinux_2_28_x86_64", "wasm"), ("macosx_14_0_arm64", "darwin-arm64"),
+    ("macosx_14_0_x86_64", "darwin-x64"), ("win_amd64", "win32-x64"),
+])
+@pytest.mark.parametrize("invalid", [None, "asset", "missing-engine", "foreign-engine", "helper-mode"])
+def test_office_wheel_requires_only_target_engine(
+    tmp_path: Path, platform_tag: str, selected: str, invalid: str | None,
+) -> None:
+    root = "runtime-office/node_modules"
+    wheel = tmp_path / "office.zip"
+    native = selected != "wasm"
+    if invalid == "helper-mode" and (not native or platform_tag == "win_amd64"):
+        pytest.skip("executable mode applies to POSIX native helpers")
+    engine = ({"kind": "native", "executable": "bin/helper"} if native else
+              {"kind": "wasm", "loader": "loader.cjs", "wasm": "engine.wasm", "data": "engine.data", "metadata": "fonts.json"})
+    with zipfile.ZipFile(wheel, "w") as archive:
+        archive.writestr(f"{root}/@deepseek-ai/libreoffice-kit/package.json", "{}")
+        base = f"{root}/@deepseek-ai/libreoffice-kit-{selected}"
+        if invalid != "missing-engine":
+            archive.writestr(f"{base}/prebuilds.json", json.dumps({"engine": engine}))
+        for field in (("executable",) if native else ("loader", "wasm", "data", "metadata")):
+            if invalid == "asset" and field == ("executable" if native else "data"):
+                continue
+            asset = zipfile.ZipInfo(f"{base}/{engine[field]}")
+            asset.external_attr = (0o644 if invalid == "helper-mode" else 0o755) << 16
+            archive.writestr(asset, b"payload")
+        if invalid == "foreign-engine":
+            foreign = "wasm" if native else "darwin-arm64"
+            archive.writestr(f"{root}/@deepseek-ai/libreoffice-kit-{foreign}/prebuilds.json", "{}")
+    with zipfile.ZipFile(wheel) as archive:
+        if invalid is None:
+            build_python_release.verify_office_payload(archive, root, platform_tag)
+        else:
+            message = {"asset": "asset is missing", "missing-engine": "dependency is missing",
+                       "foreign-engine": "Unexpected Office engine", "helper-mode": "executable bit"}[invalid]
+            with pytest.raises(RuntimeError, match=message):
+                build_python_release.verify_office_payload(archive, root, platform_tag)

+ 54 - 0
python/sdk/tests/test_runtime_resolution.py

@@ -19,6 +19,17 @@ from deepseek_harness_runtime import (
 )
 
 
+def _office_sidecar(executable: Path) -> Path:
+    office = executable.with_name(f"{executable.name.removesuffix('.exe')}-office")
+    tag = executable.name.removeprefix("deepseek-harness-sdk-runtime-").removesuffix(".exe")
+    engine = "wasm" if tag.startswith("linux-") else tag.replace("win-", "win32-").replace("macos-", "darwin-")
+    for required in ("@deepseek-ai/libreoffice-kit/package.json", f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json"):
+        path = office / "node_modules" / required
+        path.parent.mkdir(parents=True, exist_ok=True)
+        path.write_text("{}")
+    return office
+
+
 def test_unknown_explicit_mode_fails_loud() -> None:
     with pytest.raises(ValueError, match="expected 'exe' or 'node'"):
         resolve_bundled_launch_args("bogus")
@@ -47,6 +58,7 @@ def test_runtime_requires_spawn_helper_only_on_macos(
     linux = runtime_dir / "deepseek-harness-sdk-runtime-linux-x64"
     linux.touch()
     Path(f"{linux}-rg").touch()
+    _office_sidecar(linux)
     macos = runtime_dir / "deepseek-harness-sdk-runtime-macos-arm64"
     macos.touch()
     Path(f"{macos}-rg").touch()
@@ -67,6 +79,7 @@ def test_windows_runtime_uses_exe_payload_and_exe_sidecar(
     executable = runtime_dir / "deepseek-harness-sdk-runtime-win-x64.exe"
     executable.touch()
     (runtime_dir / "deepseek-harness-sdk-runtime-win-x64-rg.exe").touch()
+    _office_sidecar(executable)
     monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path)
     monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "win-x64")
 
@@ -103,6 +116,25 @@ def test_runtime_requires_ripgrep_sidecar(
         runtime.bundled_runtime_path()
 
 
+def test_runtime_requires_complete_office_sidecar(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+    executable = tmp_path / "runtime" / "deepseek-harness-sdk-runtime-linux-x64"
+    executable.parent.mkdir()
+    executable.touch()
+    Path(f"{executable}-rg").touch()
+    monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path)
+    monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "linux-x64")
+
+    with pytest.raises(FileNotFoundError, match="Office sidecar"):
+        runtime.bundled_runtime_path()
+    office = _office_sidecar(executable)
+    assert runtime.bundled_runtime_path() == executable
+    (office / "node_modules/@deepseek-ai/libreoffice-kit-wasm/prebuilds.json").unlink()
+    with pytest.raises(FileNotFoundError, match="Office sidecar"):
+        runtime.bundled_runtime_path()
+
+
 def test_node_mode_runs_the_deployed_dsh_cli(
     tmp_path: Path, monkeypatch: pytest.MonkeyPatch
 ) -> None:
@@ -195,3 +227,25 @@ def test_windows_console_branch_preserves_real_child_io_and_completion(tmp_path:
     assert result.stdout == "stdout-中文\n"
     assert result.stderr == "stderr-中文\n"
     assert sentinel.read_text() == "done"
+
+
+@pytest.mark.parametrize("target", ["linux-x64", "linux-arm64", "macos-arm64", "macos-x64", "win-x64"])
+def test_runtime_requires_its_platform_office_engine(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, target: str) -> None:
+    extension = ".exe" if target.startswith("win-") else ""
+    executable = tmp_path / "runtime" / f"deepseek-harness-sdk-runtime-{target}{extension}"
+    executable.parent.mkdir()
+    executable.touch()
+    executable.with_name(f"{executable.stem}-rg{extension}").touch()
+    if target.startswith("macos-"):
+        Path(f"{executable}-spawn-helper").touch()
+    office = _office_sidecar(executable)
+    monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path)
+    monkeypatch.setattr(runtime, "_current_platform_tag", lambda: target)
+    assert runtime.bundled_runtime_path() == executable
+    engine = next(office.glob("node_modules/@deepseek-ai/libreoffice-kit-*/prebuilds.json"))
+    engine.unlink()
+    foreign = office / "node_modules/@deepseek-ai" / ("libreoffice-kit-darwin-arm64" if target.startswith("linux-") else "libreoffice-kit-wasm") / "prebuilds.json"
+    foreign.parent.mkdir(parents=True, exist_ok=True)
+    foreign.write_text("{}")
+    with pytest.raises(FileNotFoundError, match="Office sidecar"):
+        runtime.bundled_runtime_path()

+ 137 - 0
scripts/build-exe-for-python-sdk-office.spec.ts

@@ -0,0 +1,137 @@
+import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterEach, expect, it } from 'vitest'
+import { copyOfficeSidecar } from './build-exe-for-python-sdk-office.ts'
+
+const temporaryDirectories: string[] = []
+
+afterEach(async () => {
+  await Promise.all(temporaryDirectories.splice(0).map(directory => rm(directory, { recursive: true, force: true })))
+})
+
+async function fixture() {
+  const root = await mkdtemp(join(tmpdir(), 'dsh-python-office-'))
+  temporaryDirectories.push(root)
+  const staging = join(root, 'staging')
+  const destination = join(root, 'runtime-office')
+  async function packageAt(name: string, fields: Record<string, unknown> = {}, parent = staging) {
+    const directory = join(parent, 'node_modules', name)
+    await mkdir(directory, { recursive: true })
+    await writeFile(join(directory, 'package.json'), JSON.stringify({ name, version: '1.0.0', ...fields }))
+    return directory
+  }
+  return { root, staging, destination, packageAt }
+}
+
+it('copies package-owned data, licenses, helper permissions, and nested dependencies', async () => {
+  const { staging, destination, packageAt } = await fixture()
+  const entry = await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { '@deepseek-ai/libreoffice-kit-wasm': '0.0.1' }, dependencies: { decoder: '1' } })
+  const engine = await packageAt('@deepseek-ai/libreoffice-kit-wasm')
+  const decoder = await packageAt('decoder', { dependencies: { codec: '1' } }, entry)
+  await packageAt('codec', {}, decoder)
+  await packageAt('codec', { version: '2.0.0' })
+  const assets = ['assets/soffice.data', 'prebuilds.json', 'licenses/LICENSE', 'bin/helper']
+  for (const name of assets) {
+    const path = join(engine, name)
+    await mkdir(dirname(path), { recursive: true })
+    await writeFile(path, name)
+  }
+  await chmod(join(engine, 'bin/helper'), 0o755)
+
+  const packages = await copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })
+
+  expect(packages).toHaveLength(4)
+  expect(await readFile(join(destination, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/assets/soffice.data'), 'utf8')).toBe('assets/soffice.data')
+  expect(await readFile(join(destination, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/licenses/LICENSE'), 'utf8')).toBe('licenses/LICENSE')
+  expect(await readFile(join(destination, 'node_modules/@deepseek-ai/libreoffice-kit/node_modules/decoder/node_modules/codec/package.json'), 'utf8')).toContain('codec')
+  await expect(stat(join(destination, 'node_modules/codec'))).rejects.toMatchObject({ code: 'ENOENT' })
+  if (process.platform !== 'win32') expect((await stat(join(destination, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/bin/helper'))).mode & 0o111).toBe(0o111)
+})
+
+it('copies installed target optionals and leaves other platforms and absent optionals out', async () => {
+  const { staging, destination, packageAt } = await fixture()
+  await packageAt('@deepseek-ai/libreoffice-kit', {
+    optionalDependencies: { native: '1', foreign: '1', absent: '1' },
+  })
+  await packageAt('@deepseek-ai/libreoffice-kit-wasm')
+  await packageAt('native', { os: ['linux'], cpu: ['x64'] })
+  await packageAt('foreign', { os: ['darwin'], cpu: ['arm64'] })
+
+  const packages = await copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })
+
+  expect(packages.map(path => path.replaceAll('\\', '/'))).toEqual([
+    'node_modules/@deepseek-ai/libreoffice-kit',
+    'node_modules/@deepseek-ai/libreoffice-kit-wasm',
+    'node_modules/native',
+  ])
+})
+
+it('rejects a missing required dependency before producing a sidecar', async () => {
+  const { staging, destination, packageAt } = await fixture()
+  await packageAt('@deepseek-ai/libreoffice-kit', { dependencies: { 'dsh-missing-office-fixture': '1' } })
+
+  await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' }))
+    .rejects.toThrow('dsh-missing-office-fixture required by @deepseek-ai/libreoffice-kit is missing')
+  await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' })
+})
+
+it('rejects an incomplete installed optional instead of omitting it', async () => {
+  const { staging, destination, packageAt } = await fixture()
+  await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { broken: '1' } })
+  await mkdir(join(staging, 'node_modules/broken'), { recursive: true })
+
+  await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' }))
+    .rejects.toMatchObject({ code: 'ENOENT' })
+})
+
+it('rejects an ancestor dependency outside the deployed closure', async () => {
+  const { root, staging, destination, packageAt } = await fixture()
+  await packageAt('@deepseek-ai/libreoffice-kit', { dependencies: { 'ancestor-office-fixture': '1' } })
+  await packageAt('ancestor-office-fixture', {}, root)
+
+  await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' }))
+    .rejects.toThrow('outside the deployed closure')
+})
+
+it('rejects an optional WASM engine resolved outside the deployed closure', async () => {
+  const { root, staging, destination, packageAt } = await fixture()
+  await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { '@deepseek-ai/libreoffice-kit-wasm': '0.0.1' } })
+  const ambient = await packageAt('@deepseek-ai/libreoffice-kit-wasm', {}, root)
+  await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' }))
+    .rejects.toThrow(`Python Office dependency is outside the deployed closure: ${ambient}`)
+  await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' })
+})
+
+it.each([
+  ['darwin', 'arm64'], ['darwin', 'x64'], ['win32', 'arm64'], ['win32', 'x64'], ['linux', 'x64'],
+])('copies only the %s/%s engine even when other engines are staged', async (platform, arch) => {
+  const { staging, destination, packageAt } = await fixture()
+  const targets = ['darwin-arm64', 'darwin-x64', 'win32-arm64', 'win32-x64', 'wasm']
+  const names = targets.map(target => `@deepseek-ai/libreoffice-kit-${target}`)
+  await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: Object.fromEntries(names.map(name => [name, '0.0.1'])) })
+  for (const name of names) await packageAt(name)
+  const expected = `@deepseek-ai/libreoffice-kit-${platform === 'linux' ? 'wasm' : `${platform}-${arch}`}`
+  const packages = await copyOfficeSidecar(staging, destination, { platform, arch })
+  expect(packages.map(path => path.replaceAll('\\', '/'))).toEqual([
+    'node_modules/@deepseek-ai/libreoffice-kit', `node_modules/${expected}`,
+  ])
+})
+
+it.each(['darwin', 'win32'])('%s requires its native package even when WASM is staged', async (platform) => {
+  const { staging, destination, packageAt } = await fixture()
+  await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { '@deepseek-ai/libreoffice-kit-wasm': '0.0.1' } })
+  await packageAt('@deepseek-ai/libreoffice-kit-wasm')
+  await expect(copyOfficeSidecar(staging, destination, { platform, arch: 'arm64' }))
+    .rejects.toThrow(`Python Office engine @deepseek-ai/libreoffice-kit-${platform}-arm64 required for ${platform}/arm64 is missing.`)
+  await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' })
+})
+
+it('requires a staged Linux WASM engine even when an ancestor has one', async () => {
+  const { root, staging, destination, packageAt } = await fixture()
+  await packageAt('@deepseek-ai/libreoffice-kit')
+  await packageAt('@deepseek-ai/libreoffice-kit-wasm', {}, root)
+  await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' }))
+    .rejects.toThrow('Python Office engine @deepseek-ai/libreoffice-kit-wasm required for linux/x64 is missing.')
+  await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' })
+})

+ 94 - 0
scripts/build-exe-for-python-sdk-office.ts

@@ -0,0 +1,94 @@
+/** Keep LibreOffice workers, prebuilt engines, and their dependencies on the real filesystem. */
+import { existsSync } from 'node:fs'
+import { cp, mkdir, readFile, rm } from 'node:fs/promises'
+import { createRequire } from 'node:module'
+import { isAbsolute, join, relative, sep } from 'node:path'
+
+/** pkg applies these exclusions to dependency `files` as well as root asset globs. */
+export const OFFICE_ASSET_IGNORES = [
+  '**/node_modules/@deepseek-ai/libreoffice-kit/**',
+  '**/node_modules/@deepseek-ai/libreoffice-kit-*/**',
+]
+
+interface PackageManifest {
+  name: string
+  dependencies?: Record<string, string>
+  optionalDependencies?: Record<string, string>
+  peerDependencies?: Record<string, string>
+  peerDependenciesMeta?: Record<string, { optional?: boolean }>
+  os?: string[]
+  cpu?: string[]
+}
+
+/**
+ * Copy the installed Office dependency tree without changing package contents or executable modes.
+ * Harness sidecars require the target native engine on macOS/Windows and WASM on Linux.
+ * Missing target engines fail with their package name; missing required dependencies or paths outside the deployed closure also fail.
+ * @param staging - Symlink-free deployed Node closure.
+ * @param destination - Target-specific Office directory beside the executable; replaced when present.
+ * @param target - Node platform and CPU of the executable.
+ * @returns Relative package directories included in the sidecar.
+ */
+export async function copyOfficeSidecar(
+  staging: string,
+  destination: string,
+  target: { platform: string; arch: string },
+): Promise<string[]> {
+  const engineName = `@deepseek-ai/libreoffice-kit-${target.platform === 'linux' ? 'wasm' : `${target.platform}-${target.arch}`}`
+  const packages = new Set<string>()
+
+  async function visit(packageDirectory: string): Promise<void> {
+    if (packages.has(packageDirectory)) return
+    const relativeDirectory = relative(staging, packageDirectory)
+    if (isAbsolute(relativeDirectory) || relativeDirectory === '..' || relativeDirectory.startsWith(`..${sep}`)) {
+      throw new Error(`Python Office dependency is outside the deployed closure: ${packageDirectory}`)
+    }
+    const manifestPath = join(packageDirectory, 'package.json')
+    const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as PackageManifest
+    packages.add(packageDirectory)
+    const require = createRequire(manifestPath)
+    const dependencies = new Set([
+      ...Object.keys(manifest.dependencies ?? {}),
+      ...Object.keys(manifest.optionalDependencies ?? {}),
+      ...Object.keys(manifest.peerDependencies ?? {}),
+    ])
+    for (const name of dependencies) {
+      if (name.startsWith('@deepseek-ai/libreoffice-kit-') && name !== engineName) continue
+      const optional = manifest.optionalDependencies?.[name] !== undefined
+        || manifest.peerDependenciesMeta?.[name]?.optional === true
+      const dependencyDirectory = (require.resolve.paths(name) ?? [])
+        .map(directory => join(directory, name))
+        .find(directory => existsSync(directory))
+      if (dependencyDirectory === undefined) {
+        if (optional) continue
+        throw new Error(`Python Office dependency ${name} required by ${manifest.name} is missing.`)
+      }
+      if (optional) {
+        const dependency = JSON.parse(await readFile(join(dependencyDirectory, 'package.json'), 'utf8')) as PackageManifest
+        if (!supports(dependency.os, target.platform) || !supports(dependency.cpu, target.arch)) continue
+      }
+      await visit(dependencyDirectory)
+    }
+  }
+
+  await visit(join(staging, 'node_modules', '@deepseek-ai', 'libreoffice-kit'))
+  const engineDirectory = join(staging, 'node_modules', engineName)
+  if (!existsSync(engineDirectory)) throw new Error(`Python Office engine ${engineName} required for ${target.platform}/${target.arch} is missing.`)
+  await visit(engineDirectory)
+  await rm(destination, { recursive: true, force: true })
+  await mkdir(destination, { recursive: true })
+  const directories = [...packages].sort()
+  for (const source of directories) {
+    const nestedModules = join(source, 'node_modules')
+    await cp(source, join(destination, relative(staging, source)), {
+      recursive: true,
+      filter: path => path !== nestedModules && !path.startsWith(nestedModules + sep),
+    })
+  }
+  return directories.map(directory => relative(staging, directory))
+}
+
+function supports(values: string[] | undefined, value: string): boolean {
+  return values === undefined || (!values.includes(`!${value}`)
+    && (values.every(item => item.startsWith('!')) || values.includes(value) || values.includes('any')))
+}

+ 21 - 5
scripts/build-exe-for-python-sdk.ts

@@ -12,6 +12,7 @@ import { chmod, copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rm, wri
 import { basename, dirname, extname, join, resolve, sep } from 'node:path'
 import { parseArgs } from 'node:util'
 import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './build-exe-for-python-sdk-native-pty.ts'
+import { copyOfficeSidecar, OFFICE_ASSET_IGNORES } from './build-exe-for-python-sdk-office.ts'
 
 const root = resolve(import.meta.dirname, '..')
 
@@ -398,7 +399,7 @@ class SingleExeBuild {
 
   /** Add the executable entry and pkg assets to the staged manifest. */
   async injectPkgConfig(): Promise<void> {
-    const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS } }
+    const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS, ignore: OFFICE_ASSET_IGNORES } }
     const manifestPath = join(this.staging, 'package.json')
     if (this.cli.dryRun) {
       console.log(`build-exe-for-python-sdk: [dry-run] patch ${manifestPath} with ${JSON.stringify(patch)}`)
@@ -418,7 +419,7 @@ class SingleExeBuild {
   /**
    * Package one target; SEA mode accepts one target per invocation.
    * @param target - the pkg target triple to build.
-   * @returns the executable and ripgrep sidecar paths, plus the macOS spawn helper path when required.
+   * @returns the executable, Office directory, ripgrep, and required macOS spawn helper paths.
    */
   async pack(target: Target): Promise<string[]> {
     const productBase = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`)
@@ -438,8 +439,16 @@ class SingleExeBuild {
     if (!this.cli.dryRun && !existsSync(product)) {
       throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`)
     }
+    const office = `${productBase}-office`
+    if (this.cli.dryRun) {
+      console.log(`build-exe-for-python-sdk: [dry-run] copy Office dependency closure from ${this.staging} to ${office}`)
+    } else {
+      const platform = target.platform === 'macos' ? 'darwin' : target.platform === 'win' ? 'win32' : target.platform
+      const packages = await copyOfficeSidecar(this.staging, office, { platform, arch: target.arch })
+      console.log(`build-exe-for-python-sdk: copied ${packages.length} Office packages to ${office}`)
+    }
     const ripgrep = await this.copyRipgrepSidecar(target, product)
-    if (target.platform !== 'macos') return [product, ripgrep]
+    if (target.platform !== 'macos') return [product, ripgrep, office]
     const spawnHelper = `${product}-spawn-helper`
     const source = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper')
     if (this.cli.dryRun) {
@@ -448,7 +457,7 @@ class SingleExeBuild {
       await copyFile(source, spawnHelper)
       await chmod(spawnHelper, 0o755)
     }
-    return [product, ripgrep, spawnHelper]
+    return [product, ripgrep, spawnHelper, office]
   }
 
   /** Copy the target ripgrep binary beside the executable so Node can spawn it outside pkg's virtual filesystem. */
@@ -538,6 +547,10 @@ class SingleExeBuild {
         console.log(`  ${path}`)
         continue
       }
+      if (statSync(path).isDirectory()) {
+        console.log(`  ${path}  (Office dependency directory)`)
+        continue
+      }
       const megabytes = statSync(path).size / (1024 * 1024)
       console.log(`  ${path}  (${megabytes.toFixed(1)} MB)`)
     }
@@ -559,7 +572,10 @@ class SingleExeBuild {
     await mkdir(destDir, { recursive: true })
     for (const path of products) {
       const destination = join(destDir, basename(path))
-      await copyFile(path, destination)
+      if (statSync(path).isDirectory()) {
+        await rm(destination, { recursive: true, force: true })
+        await cp(path, destination, { recursive: true })
+      } else await copyFile(path, destination)
       await chmod(destination, statSync(path).mode & 0o777)
       console.log(`build-exe-for-python-sdk: synced ${destination}`)
     }

+ 52 - 6
scripts/build-python-release.py

@@ -55,6 +55,11 @@ def runtime_filenames(executable_name: str) -> tuple[str, ...]:
     return (*names, f"{executable_name}-spawn-helper") if "-macos-" in executable_name else names
 
 
+def office_sidecar_name(executable_name: str) -> str:
+    """Return the complete Office dependency directory for one executable."""
+    return f"{executable_name.removesuffix('.exe')}-office"
+
+
 def main() -> None:
     parser = argparse.ArgumentParser(description=__doc__)
     parser.add_argument("--package", choices=("sdk", "runtime"), required=True)
@@ -220,6 +225,42 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_
     source_directory = executable.parent
     for filename in runtime_filenames(executable_name):
         shutil.copy2(source_directory / filename, runtime_dir / filename)
+    office = office_sidecar_name(executable_name)
+    shutil.copytree(source_directory / office, runtime_dir / office)
+
+
+def verify_office_payload(archive: zipfile.ZipFile, office_modules: str, platform_tag: str) -> None:
+    """Check packaged engine assets and native helper executable permissions."""
+    names = set(archive.namelist())
+    adapter = f"{office_modules}/@deepseek-ai/libreoffice-kit/package.json"
+    if adapter not in names:
+        raise RuntimeError("Office dependency is missing: libreoffice-kit")
+    engines = f"{office_modules}/@deepseek-ai"
+    target = next(name for name, value in PLATFORMS.items() if value[0] == platform_tag)
+    selected = "wasm" if target.startswith("linux-") else target.replace("win-", "win32-").replace("macos-", "darwin-")
+    for required in (f"libreoffice-kit-{selected}/prebuilds.json",):
+        if f"{engines}/{required}" not in names:
+            raise RuntimeError(f"Office dependency is missing: {required}")
+    manifests = (
+        name for name in names
+        if name.startswith(f"{engines}/libreoffice-kit-")
+        and name.endswith("/prebuilds.json")
+        and name.count("/") == engines.count("/") + 2
+    )
+    for manifest_path in manifests:
+        if manifest_path != f"{engines}/libreoffice-kit-{selected}/prebuilds.json":
+            raise RuntimeError(f"Unexpected Office engine for {platform_tag}: {manifest_path}")
+        manifest = json.loads(archive.read(manifest_path))
+        engine = manifest["engine"]
+        native = engine["kind"] == "native"
+        fields = ("executable",) if native else ("loader", "wasm", "data", "metadata")
+        for field in fields:
+            asset = f"{manifest_path.rsplit('/', 1)[0]}/{engine[field]}"
+            if asset not in names:
+                raise RuntimeError(f"Office engine asset is missing: {asset}")
+            mode = archive.getinfo(asset).external_attr >> 16
+            if native and platform_tag != "win_amd64" and mode & stat.S_IXUSR == 0:
+                raise RuntimeError(f"Office helper lost its executable bit: {asset}")
 
 
 def verify_wheel(
@@ -253,21 +294,26 @@ def verify_wheel(
             raise RuntimeError(
                 f"{wheel} has license files {license_files}, expected {expected_license_files}"
             )
-        runtime_files = [
+        runtime_payload = [
             name for name in archive.namelist() if "/runtime/deepseek-harness-sdk-runtime-" in name
         ]
         if package == "runtime":
             assert platform is not None
-            expected_files = sorted(runtime_filenames(platform[1]))
-            found_files = sorted(Path(name).name for name in runtime_files)
+            office = office_sidecar_name(platform[1])
+            expected_files = sorted((*runtime_filenames(platform[1]), office))
+            found_files = sorted({name.split("/runtime/", 1)[1].split("/", 1)[0] for name in runtime_payload})
             if found_files != expected_files:
                 raise RuntimeError(f"{wheel} runtime payload must be {expected_files}, found {found_files}")
-            for runtime_file in runtime_files:
+            office_modules = f"deepseek_harness_runtime/runtime/{office}/node_modules"
+            verify_office_payload(archive, office_modules, platform[0])
+            for runtime_file in runtime_payload:
+                if "/" in runtime_file.split("/runtime/", 1)[1]:
+                    continue
                 mode = archive.getinfo(runtime_file).external_attr >> 16
                 if platform[0] != "win_amd64" and mode & stat.S_IXUSR == 0:
                     raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {runtime_file}")
-        elif runtime_files:
-            raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}")
+        elif runtime_payload:
+            raise RuntimeError(f"SDK wheel unexpectedly contains runtime files: {runtime_payload}")
         if package == "sdk":
             requirements = metadata.get_all("Requires-Dist") or []
             expected_requirement = f"{RUNTIME_DISTRIBUTION}=={version}"

+ 15 - 0
scripts/fixtures/python-sdk-office.mjs

@@ -0,0 +1,15 @@
+/** Exercise the wheel's external Office package from a shipped dsh profile. */
+import { writeFile } from 'node:fs/promises'
+import { createConverter } from '@deepseek-ai/libreoffice-kit'
+
+export const name = 'python-sdk-office-smoke'
+
+export async function apply(_ctx, config) {
+  const converter = await createConverter({ timeoutMs: 120_000 })
+  try {
+    const result = await converter.render({ inputPath: config.input, outputPath: config.output })
+    await writeFile(config.result, JSON.stringify({ ...result, moduleUrl: import.meta.resolve('@deepseek-ai/libreoffice-kit') }))
+  } finally {
+    await converter.dispose()
+  }
+}

+ 5 - 0
scripts/gen-cordis-catalog.ts

@@ -63,6 +63,7 @@ export const SERVICE_PAGE: Record<string, string> = {
   agentPresets: 'core.md',
   agents: 'core.md',
   approval: 'approval.md',
+  documentRender: 'document-render.md',
   attachments: 'attachment.md',
   shell: 'shell.md',
   shellEnv: 'shell.md',
@@ -276,6 +277,10 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   PluginInstallProgress: 'boot.md',
   BrowserUseProviderName: 'browser-use.md',
   ComputerUseProviderName: 'computer-use.md',
+  DocumentRenderRequest: 'document-render.md',
+  DocumentRenderResult: 'document-render.md',
+  DocumentRenderPriority: 'document-render.md',
+  DocumentRendererGeneration: 'document-render.md',
   Agent: 'core.md',
   AgentCancelCause: 'core.md',
   AgentFactory: 'core.md',

+ 6 - 0
scripts/gen-doc-graphs.ts

@@ -63,6 +63,7 @@ type EventReceiverKind = 'context' | 'agent-dispatch' | 'events-service'
 const GROUP_ORDER = [
   'util',
   'attachment',
+  'document',
   'llm',
   'core',
   'typert',
@@ -149,6 +150,11 @@ const SERVICE_ROLES: ServiceRole[] = [
     consumers: ['experimental-computer-use-cua-driver-mcp', 'experimental-computer-use-cua-driver-native'],
     note: 'One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock.',
   },
+  {
+    key: 'documentRender', pkg: 'document-render', title: 'Office to PDF rendering',
+    mode: 'seam', implementations: ['document-render-libreoffice'], consumers: [],
+    note: 'Authorized Office bytes are converted on the Host using native engines on macOS/Windows and Node WASM on Linux.',
+  },
   {
     key: 'attachments',
     pkg: 'attachment',

+ 23 - 0
scripts/gen-third-party-notices.spec.ts

@@ -34,6 +34,8 @@ describe('THIRD_PARTY_NOTICES.md', () => {
     const generated = await render()
     expect(generated).toContain('It depends on the third-party software listed below.')
     expect(generated).toContain(`| [\`numpy\`](https://github.com/numpy/numpy) | ${desktopRuntimeLock.pythonPackages.numpy} | BSD-3-Clause |`)
+    expect(generated).toContain('## LibreOffice conversion kit')
+    expect(generated).toContain('Recipients must have access to those corresponding sources and notices.')
     expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated)
   })
 })
@@ -49,6 +51,27 @@ function workspace(entries: Record<string, Manifest>): { manifests: Map<string,
 }
 
 describe('tierExternalDeps', () => {
+  it('limits the LibreOffice exception to its reviewed package identity and MPL terms', () => {
+    for (const name of [
+      '@deepseek-ai/libreoffice-kit', '@deepseek-ai/libreoffice-kit-wasm',
+      '@deepseek-ai/libreoffice-kit-darwin-arm64', '@deepseek-ai/libreoffice-kit-darwin-x64',
+      '@deepseek-ai/libreoffice-kit-win32-arm64', '@deepseek-ai/libreoffice-kit-win32-x64',
+    ]) {
+      expect(() => { assertRuntimeLicenses([{ name, license: 'MPL-2.0' }]) }).not.toThrow()
+      expect(() => { assertRuntimeLicenses([{ name, license: 'GPL-3.0-only' }]) }).toThrow(name)
+    }
+    for (const dependency of [
+      { name: 'unrelated-library', license: 'MPL-2.0' },
+      { name: '@deepseek-ai/dsh-libreoffice-kit', license: 'MPL-2.0' },
+      { name: '@deepseek-ai/libreoffice-kit-unreviewed', license: 'MPL-2.0' },
+      { name: '@deepseek-ai/libreoffice-kit', license: 'GPL-3.0-only' },
+      { name: '@deepseek-ai/libreoffice-kit', license: 'UNKNOWN' },
+    ]) {
+      expect(() => { assertRuntimeLicenses([dependency]) }).toThrow(`${dependency.name} (${dependency.license})`)
+    }
+    expect(isPermissive('MPL-2.0')).toBe(false)
+  })
+
   it('keeps license rejection active when a browser library is declared for development', () => {
     const { manifests, names } = workspace({
       'packages/client/ui/package.json': { devDependencies: { 'browser-lib': '^1', 'test-tool': '^1' } },

Vissa filer visades inte eftersom för många filer har ändrats