Forráskód Böngészése

Merge remote-tracking branch 'origin/master' into feat/remove-str-replace-editor-from-minimal

fz 3 hete
szülő
commit
452fe6de55
100 módosított fájl, 2571 hozzáadás és 146 törlés
  1. 6 0
      .agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml
  2. 38 0
      .agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.md
  3. 38 0
      .agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.zh.md
  4. 3 0
      .agents/notes/archived/manifest.json
  5. 6 0
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml
  6. 37 0
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
  7. 37 0
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md
  8. 2 2
      .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml
  9. 4 0
      .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md
  10. 4 0
      .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md
  11. 1 0
      apps/cli/package.json
  12. 3 1
      apps/cli/tests/web-agent-presets.e2e.ts
  13. 2 0
      apps/web/tests/live-interactions.e2e.ts
  14. 186 0
      apps/web/tests/present.e2e.ts
  15. 1 0
      apps/web/tests/shipped-composition.e2e.ts
  16. 3 2
      apps/web/tests/turn-tail-actions.e2e.ts
  17. 1 0
      apps/web/tsconfig.json
  18. 2 2
      docs/config-catalog.i18n.yaml
  19. 17 1
      docs/config-catalog.md
  20. 17 1
      docs/config-catalog.zh.md
  21. 2 2
      docs/event-producer-consumer.i18n.yaml
  22. 1 1
      docs/event-producer-consumer.md
  23. 1 1
      docs/event-producer-consumer.zh.md
  24. 2 2
      docs/module-graph.i18n.yaml
  25. 8 0
      docs/module-graph.md
  26. 8 0
      docs/module-graph.zh.md
  27. 2 2
      docs/persistence-catalog.i18n.yaml
  28. 15 0
      docs/persistence-catalog.md
  29. 15 0
      docs/persistence-catalog.zh.md
  30. 2 2
      docs/tool-catalog.i18n.yaml
  31. 44 0
      docs/tool-catalog.md
  32. 44 0
      docs/tool-catalog.zh.md
  33. 55 27
      packages/acp/acp/tests/dispose.spec.ts
  34. 1 0
      packages/bundle/base/package.json
  35. 2 2
      packages/client/ui-chat/README.i18n.yaml
  36. 2 0
      packages/client/ui-chat/README.md
  37. 2 0
      packages/client/ui-chat/README.zh.md
  38. 1 1
      packages/client/ui-chat/src/client/apply.ts
  39. 3 2
      packages/client/ui-chat/src/client/contract/slots.ts
  40. 1 1
      packages/client/ui-chat/tests/apply-inject.client.spec.tsx
  41. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  42. 14 4
      packages/client/ui-deliverables/README.md
  43. 15 5
      packages/client/ui-deliverables/README.zh.md
  44. 8 2
      packages/client/ui-deliverables/package.json
  45. 14 0
      packages/client/ui-deliverables/src/client/Deliverables.module.css
  46. 64 0
      packages/client/ui-deliverables/src/client/Deliverables.tsx
  47. 6 0
      packages/client/ui-deliverables/src/client/PresentRow.module.css
  48. 45 0
      packages/client/ui-deliverables/src/client/PresentRow.tsx
  49. 24 7
      packages/client/ui-deliverables/src/client/index.ts
  50. 26 0
      packages/client/ui-deliverables/src/client/locales.ts
  51. 54 0
      packages/client/ui-deliverables/src/client/present-open.ts
  52. 35 8
      packages/client/ui-deliverables/src/client/turn-deliverables.ts
  53. 6 3
      packages/client/ui-deliverables/src/index.ts
  54. 71 0
      packages/client/ui-deliverables/src/present-open.ts
  55. 52 0
      packages/client/ui-deliverables/src/presented.ts
  56. 63 0
      packages/client/ui-deliverables/tests/present-open.client.spec.ts
  57. 178 0
      packages/client/ui-deliverables/tests/present-open.host.spec.ts
  58. 58 0
      packages/client/ui-deliverables/tests/present-row.client.spec.tsx
  59. 112 7
      packages/client/ui-deliverables/tests/produced-files.client.spec.tsx
  60. 3 0
      packages/client/ui-deliverables/tests/prompt.host.spec.ts
  61. 57 0
      packages/client/ui-deliverables/tsconfig.client.json
  62. 39 0
      packages/client/ui-deliverables/tsconfig.host.json
  63. 3 40
      packages/client/ui-deliverables/tsconfig.json
  64. 1 0
      packages/core/session/src/known-event-types.ts
  65. 1 1
      packages/core/tools/tests/gen-tool-catalog.spec.ts
  66. 8 7
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  67. 2 2
      packages/fs/README.i18n.yaml
  68. 2 1
      packages/fs/README.md
  69. 2 1
      packages/fs/README.zh.md
  70. 6 0
      packages/fs/tool-present/README.i18n.yaml
  71. 102 0
      packages/fs/tool-present/README.md
  72. 102 0
      packages/fs/tool-present/README.zh.md
  73. 60 0
      packages/fs/tool-present/package.json
  74. 105 0
      packages/fs/tool-present/src/index.ts
  75. 17 0
      packages/fs/tool-present/src/types.ts
  76. 58 0
      packages/fs/tool-present/tests/built-errors.e2e.ts
  77. 167 0
      packages/fs/tool-present/tests/present.spec.ts
  78. 36 0
      packages/fs/tool-present/tsconfig.json
  79. 2 2
      packages/preset/agent-presets/README.i18n.yaml
  80. 2 0
      packages/preset/agent-presets/README.md
  81. 2 0
      packages/preset/agent-presets/README.zh.md
  82. 3 0
      packages/preset/agent-presets/presets/cordis/agent.cordis.yml
  83. 3 0
      packages/preset/agent-presets/presets/ptc/agent.cordis.yml
  84. 3 0
      packages/preset/agent-presets/presets/standard/agent.cordis.yml
  85. 4 2
      packages/spill/spill-local/tests/spill-local.spec.ts
  86. 5 0
      packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts
  87. 67 0
      pnpm-lock.yaml
  88. 1 0
      python/sdk-runtime/package.json
  89. 1 0
      scripts/client-build-environment.client.spec.ts
  90. 13 0
      scripts/gen-tool-catalog.ts
  91. 1 0
      scripts/run-gates.ts
  92. 32 0
      snapshots/web/cordis-tool-round/tool-schemas.expected.json
  93. 32 0
      snapshots/web/fresh-round-trip/tool-schemas.expected.json
  94. 13 0
      snapshots/web/present/session.v2.jsonl
  95. 14 0
      snapshots/web/present/session.v3.jsonl
  96. 9 0
      snapshots/web/present/snapshot.yml
  97. 109 0
      snapshots/web/present/ui.expected.md
  98. 1 0
      snapshots/web/present/workspace.expected/report.txt
  99. 1 0
      snapshots/web/present/workspace.expected/说明.txt
  100. 16 0
      snapshots/web/ptc-round/system-prompt.expected.md

+ 6 - 0
.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.md
+2026-09-08-web-explicit-file-delivery.md: ff2ddeb59ded05b70006dce217df2966eab9d4f2
+2026-09-08-web-explicit-file-delivery.zh.md: 85b09ac82c83365b1c198168b78aa7ac84ef216f

+ 38 - 0
.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.md

@@ -0,0 +1,38 @@
+# Agent Note: Web delivers explicit file snapshots
+
+Status: implemented
+Archived: 2026-09-08
+
+English | [中文](2026-09-08-web-explicit-file-delivery.zh.md)
+
+## Problem
+
+Workspace links read live paths, so edits or deletion can invalidate a final deliverable. Files created through shell commands also lack first-party editor mutation records. Delivery needs an explicit operation and saved bytes without expanding Session ZIP exports.
+
+## Decision
+
+The [present tool](../../../../packages/fs/tool-present/README.md) owns execution, immutable snapshots, delivery types, and the durable event. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) owns authenticated snapshot actions and browser rendering, with type-only imports from the tool’s `./types` entry. The `standard`, `ptc`, and `cordis` presets mount the tool package; `minimal` retains its two-tool training configuration. The existing attachment service saves immutable bytes; successful final `tools/result` notifications append `deliverables/presented` to the calling Session. Native and nested calls use the same recorder. A later enclosing program failure does not undo a completed nested delivery. Blocked tool results publish none.
+
+Download and native-open requests authorize a reference by the viewed Session, event sequence, and file index. The event stores no Session ID, so forked history uses the child's own log. The existing produced-file row keeps its names and behavior. Session ZIP retains delivery events but does not collect their attachment bytes.
+
+Card and closing-mention gestures open a verified private copy with the existing native-command utility. A POST expresses the desktop side effect; GET remains a byte read. Each gesture receives a new copy so application edits cannot corrupt the immutable attachment or alter later opens. Successful copies survive until plugin disposal for applications that read lazily; failed copies are removed immediately, and disposal awaits cancelled work before cleanup.
+
+## Alternatives considered
+
+**A Host tool subpath in the UI package** couples preset installation to browser packaging and requires extra published entries. An ordinary tool package preserves shared filesystem and tool error classes through the repository’s peer dependency rules.
+
+**Live workspace links** cannot preserve a delivered version after edits or deletion. Opening the attachment store’s own path instead would expose immutable saved bytes to application writes.
+
+**Generic artifact fields throughout tools, dispatch, and Session** would broaden unrelated APIs for one Web feature. A plugin-owned event uses existing extension points and avoids parent-result forwarding.
+
+**Tool text as the durable index** is unreliable because post-processing and spill can replace ordinary or nested result text. Each plugin instance retains its own completed snapshots by execution identity and publishes them only on a successful final result. Same-name scoped replacements cannot create or duplicate another instance’s delivery records.
+
+**Descriptor-bound filesystem extensions** would change multiple capability providers. This feature uses existing bounded reads with containment and before/after version checks. Those checks reject ordinary concurrent changes but do not guarantee atomic confinement against swap-and-restore; stronger filesystem guarantees belong to the filesystem provider.
+
+## Consequences
+
+The implementation adds no artifact service or attachment format. Unreferenced snapshots can remain after partial failure; attachment retention remains service-owned. A downstream build must understand the new required event to read the log. The generated Session event inventory records that requirement without changing released format generations.
+
+The delivery event is required-on-read because it is the authorization index for saved bytes, not only display metadata. Skipping it would allow an older reader to reconstruct or fork a Session without its completed deliveries. Unsupported readers refuse that loss instead of silently dropping the references.
+
+Focused tests cover snapshot bytes, invalid inputs, blocked results, HTTP integrity, native-open copy isolation, retry and disposal, turn isolation, and fork-addressed actions. The recorded Web scenario covers nested completion followed by an enclosing failure, source deletion, reload, native-open gestures without browser downloads, and ZIP exclusion.

+ 38 - 0
.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.zh.md

@@ -0,0 +1,38 @@
+# Agent Note: Web 显式交付文件快照
+
+Status: implemented
+Archived: 2026-09-08
+
+[English](2026-09-08-web-explicit-file-delivery.md) | 中文
+
+## 问题
+
+工作区链接读取当前路径,因此编辑或删除会使最终交付文件失效。通过 shell 命令创建的文件也没有第一方编辑器修改记录。交付需要显式操作和保存的字节,同时不扩大 Session ZIP 导出内容。
+
+## 决策
+
+[present 工具](../../../../packages/fs/tool-present/README.zh.md)拥有执行、不可变快照、交付类型和持久事件。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)拥有认证快照操作和浏览器渲染,仅从工具的 `./types` 入口导入类型。`standard`、`ptc` 与 `cordis` preset 挂载工具包;`minimal` 保留双工具训练配置。现有 attachment 服务保存不可变字节;成功的最终 `tools/result` 通知将 `deliverables/presented` 追加到调用方 Session。原生与嵌套调用使用同一个记录器。外层程序随后失败不会撤销已完成的嵌套交付。被阻止的工具结果不发布交付。
+
+下载与原生打开请求通过当前查看的 Session、事件序号与文件索引授权引用。事件不保存 Session ID,因此 fork 历史使用子 Session 自己的日志。现有产出文件行保留其名称和行为。Session ZIP 保留交付事件,但不收集其中引用的 attachment 字节。
+
+卡片和收尾引用操作通过现有 native-command 工具,在默认应用中打开经过校验的私有副本。POST 表达桌面副作用;GET 仍仅读取字节。每次操作创建新副本,避免应用内编辑损坏不可变 attachment 或改变后续打开的内容。成功副本保留到插件释放,以支持延迟读取的应用;失败副本立即删除,释放时先等待取消的操作结束再清理。
+
+## 已考虑的替代方案
+
+**在 UI 包中提供 Host 工具子路径**会将 preset 安装与浏览器打包耦合,并要求额外发布入口。普通工具包通过仓库 peer dependency 规则保留共享的文件系统和工具错误类。
+
+**实时工作区链接**无法在编辑或删除后保留已交付版本。直接打开 attachment 存储路径则会使不可变保存字节暴露于应用写入。
+
+**在工具、dispatch 和 Session 中增加通用 artifact 字段**会为单个 Web 功能扩大无关 API。插件拥有的事件使用现有扩展点,并省去父调用结果转发。
+
+**将工具文本作为持久索引**并不可靠,因为后处理与 spill 可以替换普通或嵌套结果文本。每个插件实例按执行对象保留自身已完成的快照,仅在最终结果成功时发布。同名作用域替代工具不能创建或重复其他实例的交付记录。
+
+**基于文件描述符的文件系统扩展**会修改多个能力提供方。本功能使用现有有界读取,并检查路径包含关系及读取前后的版本。这些校验会拒绝普通并发变化,但不保证对替换后复原提供原子路径限制;更强的文件系统保证属于文件系统提供方。
+
+## 影响
+
+实现不增加 artifact 服务或 attachment 格式。部分失败后可能留下无引用快照;attachment 保留策略仍由服务拥有。下游构建必须理解新必需事件才能读取日志。生成的 Session 事件清单记录该要求,不修改已发布的格式代际。
+
+交付事件要求读取端识别,因为它是保存字节的授权索引,不只是显示元数据。跳过事件会让旧读取端在重建或分叉 Session 时丢失已完成的交付。不支持该事件的读取端拒绝读取,避免静默丢弃引用。
+
+定向测试覆盖快照字节、无效输入、被阻止的结果、HTTP 完整性、原生打开的副本隔离、重试与释放、turn 隔离及使用 fork 地址的操作。录制 Web 场景覆盖嵌套调用完成后外层失败、源文件删除、重新加载、不触发浏览器下载的原生打开操作和 ZIP 排除。

+ 3 - 0
.agents/notes/archived/manifest.json

@@ -1381,6 +1381,9 @@
     "feature/2026-09-01-web-superellipse-corner-smoothing.i18n.yaml": "sha256:50afdbe5b5e19889918af6d86ab3218c05205be35938b6d33d158c60777e3b58",
     "feature/2026-09-01-web-superellipse-corner-smoothing.md": "sha256:b1445101c49e74bbcb4f607af850cd6df105d4034828d0dd47081e8079148f15",
     "feature/2026-09-01-web-superellipse-corner-smoothing.zh.md": "sha256:1a278c417c0d7de3b4c3c35061b419303b4a1a0707831c283d8f862ab9b6fd23",
+    "feature/2026-09-08-web-explicit-file-delivery.i18n.yaml": "sha256:99daae539cc8fd7376ce0265538bee21e1e33f3c0d77c8cc4e011f94b4e9568a",
+    "feature/2026-09-08-web-explicit-file-delivery.md": "sha256:bb416b1e8be081e6cb6af17792eb1a442172ff114c3a3577e5ef06a77eb57093",
+    "feature/2026-09-08-web-explicit-file-delivery.zh.md": "sha256:00a642380e1f6ac9d5cd840e021f4e3e4ae68a289cb6344eb3dcd73a6fd81f4d",
     "process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d",
     "process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e",
     "process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b",

+ 6 - 0
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
+2026-09-08-present-workspace-source-files.md: 6239c9bd920849f3c8cf4fdee2e1ded4b758b01d
+2026-09-08-present-workspace-source-files.zh.md: 7aa5bebc97558b9b0cb74406303d038483c39d94

+ 37 - 0
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md

@@ -0,0 +1,37 @@
+# Agent Note: Present declares workspace source files
+
+Status: implemented
+
+English | [中文](2026-09-08-present-workspace-source-files.zh.md)
+
+## Problem
+
+Users need to open and edit the files produced in their workspace, including shell-created files that have no editor mutation records. Preserving an independent delivered version adds content storage, copy verification, temporary-file retention, and a second editing destination to this workflow.
+
+## Decision
+
+The [present tool](../../../../packages/fs/tool-present/README.md) declares existing regular files inside the calling Session's workspace. It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them.
+
+The tool remains an ordinary package with shared filesystem and tool error classes. Its pure type entry owns the delivery event without importing Host code into the browser. The `standard`, `ptc`, and `cordis` presets mount it; `minimal` retains its two tools. Each plugin instance correlates its executions with successful final `tools/result` notifications before appending `deliverables/presented`. Native and nested calls share this rule. A later enclosing program failure does not revoke a completed nested declaration; blocked results publish none, and same-name scoped replacements cannot publish another instance's results.
+
+An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The existing produced-file row retains its separate text-preview behavior.
+
+## Alternatives considered
+
+**Immutable attachment snapshots and editable temporary copies** preserve delivered versions after source edits or deletion, but make desktop edits diverge from workspace files and introduce retention work without a current product requirement. This decision supersedes the [snapshot-delivery design](../../archived/feature/2026-09-08-web-explicit-file-delivery.md). Neither a download endpoint nor a fallback copy remains; both require an explicit future product decision.
+
+**Opening attachment-store files directly** lets editors mutate immutable objects. A future persistent delivery system needs an owned editing and retention policy, such as copy-on-write, before exposing saved versions to applications.
+
+**Generic artifact fields or a Host tool subpath inside the UI package** broaden unrelated APIs or couple preset installation to browser packaging. A tool-owned event and ordinary package preserve existing extension points and publication rules.
+
+**Tool text as the durable index** cannot survive post-processing or result spill reliably. Execution identity and final successful results retain declaration ownership independently of displayed tool text.
+
+**Descriptor-bound filesystem extensions** would change every provider without making an external desktop application's later path lookup atomic. Current checks reject ordinary escapes; concurrent swap-and-restore remains outside the path API's guarantees.
+
+## Consequences
+
+The Session log persists declarations but no attachment references or file contents from `present`. Session ZIP exports contain these declarations; transferring the log does not transfer workspace files. The event remains required-on-read because silently losing delivery declarations would alter reconstructed or forked history. Released Session format generations remain unchanged.
+
+The removed file-size cap has no role in a metadata-only declaration; the configurable file-count limit still bounds result size. Cards show file names, types, and descriptions without stale byte-size metadata. No artifact service or speculative storage fallback is introduced.
+
+Focused tests cover content-free declarations, invalid inputs, blocked results, source-path identity, current bytes after edits, missing files, workspace escapes, fork-relative paths, retry, cancellation, and disposal. The recorded Web scenario covers nested completion followed by enclosing failure, source edits, reload, deletion errors, card and prose opens without browser downloads, and content-free Session export.

+ 37 - 0
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md

@@ -0,0 +1,37 @@
+# Agent Note:Present 声明交付工作区源文件
+
+Status: implemented
+
+[English](2026-09-08-present-workspace-source-files.md) | 中文
+
+## 问题
+
+用户需要打开并编辑工作区中产出的文件,包括没有编辑器修改记录的 shell 产出文件。保存独立交付版本会为这一流程增加内容存储、副本校验、临时文件保留,以及第二个编辑目标。
+
+## 决策
+
+[present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付调用方 Session 工作区中已存在的普通文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。
+
+工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。
+
+经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。原有产出文件行保留独立的文本预览行为。
+
+## 考虑过的替代方案
+
+**不可变附件快照和可编辑临时副本**可在源文件编辑或删除后保留交付版本,但会使桌面编辑与工作区文件分离,并在缺少当前产品需求时引入保留工作。本决策取代[快照交付设计](../../archived/feature/2026-09-08-web-explicit-file-delivery.md)。不保留下载端点或回退副本;两者都需要未来明确的产品决策。
+
+**直接打开附件存储文件**会让编辑器修改不可变对象。未来持久化交付系统需要先明确编辑和保留策略,例如写时复制,再将保存版本暴露给应用。
+
+**通用 artifact 字段或 UI 包内的 Host 工具子路径**会扩展无关 API,或将 preset 安装与浏览器打包耦合。工具拥有的事件与普通包保留现有扩展点和发布规则。
+
+**以工具文本作为持久索引**无法可靠应对后处理或结果溢出。执行身份与最终成功结果使声明归属独立于展示的工具文本。
+
+**绑定文件描述符的文件系统扩展**会改动所有提供方,却无法使外部桌面应用随后按路径打开的动作原子化。当前检查拒绝普通越界;并发替换后复原仍不在路径 API 的保证范围内。
+
+## 影响
+
+Session 日志持久化声明,不保存来自 `present` 的附件引用或文件内容。Session ZIP 导出包含这些声明;转移日志不会转移工作区文件。该事件仍要求读取端识别,因为静默丢失交付声明会改变重建或 fork 的历史。已发布 Session 格式代际保持不变。
+
+仅声明元数据不需要文件大小上限,因此删除该限制;可配置的文件数量上限仍限制结果大小。卡片展示文件名称、类型和说明,不展示可能过时的字节大小。不引入 artifact 服务或推测性的存储回退。
+
+定向测试覆盖不读取内容的声明、无效输入、被阻止的结果、源路径身份、编辑后的当前字节、缺失文件、工作区越界、fork 相对路径、重试、取消与释放。录制的 Web 场景覆盖嵌套成功后外层失败、源文件编辑、重新加载、删除错误、卡片与正文打开且无浏览器下载,以及不包含交付内容的 Session 导出。

+ 2 - 2
.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md
-2026-09-08-ci-readiness-and-completion.md: 8ea0a5892d78eda16e657334dba2af58b6648d09
-2026-09-08-ci-readiness-and-completion.zh.md: 62a4c64081369a20a576805fb8a465bffff2922d
+2026-09-08-ci-readiness-and-completion.md: 01d2d7f91a0ef10e161772d3c398261acc472238
+2026-09-08-ci-readiness-and-completion.zh.md: 584d8ea00c012e197cb75d9fbce03eb2e1fb03a1

+ 4 - 0
.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md

@@ -10,6 +10,8 @@ The [empty master PR run](https://github.com/deepseek-harness/deepseek-harness/a
 
 Another [Windows coverage run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34224004885/job/102053583437) reports a null publint child status and an LSP initialization-marker timeout. Their helpers impose five- and three-second limits inside the lane's 90-second test budget. These cases verify publication contents and cancellation behavior rather than cold-start latency.
 
+The [ACP coverage run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34242280527/job/102115221228) exhausts a one-second registry poll after transport failure. Disconnect cleanup includes cancellation, output draining, persistence, and owner disposal; registry removal alone does not establish complete teardown.
+
 ## Decision
 
 The [webhook browser test](../../../../apps/web/tests/github-ready-review.e2e.ts) observes the model request caused by delivery before checking Session registration. The [feedback test](../../../../apps/web/tests/feedback-command.e2e.ts) waits for the empty composer and enabled attachment control before comparing ARIA output. Matching consecutive snapshots cannot prove that the command RPC has settled: its event stream can publish the acknowledgement first.
@@ -18,6 +20,8 @@ The [desktop transaction test](../../../../apps/desktop/tests/project-manager.sp
 
 The [publint runner tests](../../../../scripts/publint-all.spec.ts) pass the active test budget to their child and check launch errors and termination signals before interpreting its exit code. The [LSP instance test](../../../../packages/lsp/lsp-stdio/tests/instance.spec.ts) uses the same budget for its fixture marker, observes the actual pending `didOpen` write before aborting, and captures the query's rejection before waiting for readiness. Its [server fixture](../../../../packages/lsp/lsp-stdio/tests/fixture-server.ts) publishes the marker after pausing stdin. Teardown captures the instance list, Context, and directory before its first await.
 
+The [ACP disconnect tests](../../../../packages/acp/acp/tests/dispose.spec.ts) await the real session handle disposer for both EOF and transport failure. A barrier holds disposal pending while the test checks ownership, then releases it before awaiting completion and checking both registries. Neither case invokes plugin disposal to trigger the behavior under test. The independent teardown hook releases the barrier before disposing the captured Context, including when the test body times out.
+
 The [subagent teardown decision](2026-09-07-subagent-teardown-test-budgets.md) owns lifecycle cleanup budgets. The [persistent PowerShell decision](2026-09-07-pwsh-ci-observable-completion.md) owns exact versus inferred terminal readiness; a one-shot process's completion promise has different semantics.
 
 ## Alternatives considered

+ 4 - 0
.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md

@@ -10,6 +10,8 @@ Status: implemented
 
 另一次 [Windows coverage 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34224004885/job/102053583437)报告了 publint 子进程退出状态为 null,以及 LSP 初始化标记等待超时。对应 helper 在通道的 90 秒测试预算内另设五秒和三秒限制。这些用例验证发布内容与取消行为,不衡量冷启动延迟。
 
+[ACP coverage 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34242280527/job/102115221228)在传输失败后耗尽一秒的注册表轮询期限。断连清理包含取消、输出排空、持久化和 owner 处置;仅从注册表移除不能证明完整拆卸已经结束。
+
 ## 决策
 
 [Webhook 浏览器测试](../../../../apps/web/tests/github-ready-review.e2e.ts)观察投递触发的模型请求后再检查 Session 注册。[反馈测试](../../../../apps/web/tests/feedback-command.e2e.ts)在比较 ARIA 输出前等待输入框清空且附件按钮启用。连续两次快照相同不能证明命令 RPC 已完成:事件流可能先发布确认消息。
@@ -18,6 +20,8 @@ Status: implemented
 
 [publint runner 测试](../../../../scripts/publint-all.spec.ts)将当前测试预算传给子进程,并在解释退出码前检查启动错误和终止信号。[LSP 实例测试](../../../../packages/lsp/lsp-stdio/tests/instance.spec.ts)用同一预算等待 fixture 标记,在取消前观察实际尚未完成的 `didOpen` 写入,并在等待就绪前接住查询的 rejection。[服务器 fixture](../../../../packages/lsp/lsp-stdio/tests/fixture-server.ts)在暂停 stdin 后发布标记。Teardown 在首次 await 前捕获实例列表、Context 和目录。
 
+[ACP 断连测试](../../../../packages/acp/acp/tests/dispose.spec.ts)在 EOF 和传输失败两种情况下等待真实 Session handle 的 disposer。屏障阻塞处置,供测试检查所有权,然后释放屏障,等待完成并检查两个注册表。两个用例都不调用插件处置来触发待验证行为。独立的 teardown hook 在处置捕获的 Context 前释放屏障,包括测试体超时的情况。
+
 [子 Agent 拆卸决策](2026-09-07-subagent-teardown-test-budgets.zh.md)负责生命周期清理预算。[持久 PowerShell 决策](2026-09-07-pwsh-ci-observable-completion.zh.md)负责精确与推断的终端就绪状态;一次性进程的完成 Promise 具有不同语义。
 
 ## 考虑过的替代方案

+ 1 - 0
apps/cli/package.json

@@ -76,6 +76,7 @@
     "@deepseek-ai/dsh-tool-bash": "workspace:^",
     "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
     "@deepseek-ai/dsh-tool-cordis": "workspace:^",
+    "@deepseek-ai/dsh-tool-present": "workspace:^",
     "@deepseek-ai/dsh-tool-fs": "workspace:^",
     "@deepseek-ai/dsh-tool-fs-search": "workspace:^",
     "@deepseek-ai/dsh-tool-goal": "workspace:^",

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

@@ -62,6 +62,8 @@ async function bootWeb(
     // back on the next run, so a stored document from any other build decides
     // this test's boot. Same reason the settings row above is pinned.
     { id: 'storage-json', config: { root: storageRoot } },
+    // Fixed Session IDs must stay inside this boot's temporary profile root.
+    { id: 'session-persistence-jsonl', config: { root: join(dirname(settingsFile), 'sessions') } },
     // Host rows with side effects outside this process: a bound port, a served
     // asset tree, a telemetry exporter. `api-gateway` and `directory-picker`
     // stay ENABLED on purpose — the api-proxy is the host row that injects
@@ -240,7 +242,7 @@ describe('the shipped Web composition', () => {
       // depend on ripgrep being present on the machine.
       expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
         'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
-        'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill',
+        'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'present', 'ralph', 'read', 'read_image', 'send_message', 'skill',
         'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_fetch', 'web_search',
         'workflow', 'write',
       ])

+ 2 - 0
apps/web/tests/live-interactions.e2e.ts

@@ -99,6 +99,8 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
     }
     scaffold = await launchWebScaffold({
       replayFixture: FIXTURE,
+      // Throughput snapshots need a nonzero interval between replayed chunks.
+      paceMs: 1,
       ...(overridePath === undefined ? {} : { replayOverride: overridePath }),
       ...(overridePath === undefined ? {} : { compareReplaySession: false }),
       ...(retryPolicy === undefined ? {} : { replayRetryPolicy: retryPolicy }),

+ 186 - 0
apps/web/tests/present.e2e.ts

@@ -0,0 +1,186 @@
+/** Recorded source-file delivery, edits, reload, deletion, and Session ZIP behavior. */
+import { readFile, unlink, mkdir, mkdtemp, writeFile, rm, realpath } from 'node:fs/promises'
+import { join, delimiter } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import { unzipSync, strFromU8 } from 'fflate'
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+import { tmpdir, release } from 'node:os'
+import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-tool-present/types'
+import {
+  acknowledgeReloadConnectionLoss, assertFinalWorkspaceSnapshot, captureExpandedTurnProcessAria,
+  compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture,
+  watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage } from './support.ts'
+
+const DIR = fileURLToPath(new URL('../../../snapshots/web/present', import.meta.url))
+const FIXTURE = join(DIR, 'session.v3.jsonl')
+const MODE = webSnapshotMode()
+const PROMPT = 'Use one run_code program to do the following in order. Call present for missing.txt and catch its error without creating that file. '
+  + 'Use bash to run exactly `printf "DELIVERED_REPORT\\n" > report.txt; printf "DELIVERED_NOTE\\n" > 说明.txt`. '
+  + 'Call present for report.txt and 说明.txt. After present succeeds, deliberately throw the string "AFTER_PRESENT" (not an Error object) from that same run_code program. '
+  + 'Do not retry the program or create any other files. Finish by mentioning `report.txt` and `说明.txt` in inline code, and put PRESENT_DONE in a separate paragraph.'
+
+// The recorded Bash scenario and executable opener fixture require a POSIX host outside WSL.
+describe.skipIf(process.platform === 'win32' || release().toLowerCase().includes('microsoft'))('web e2e: explicit file delivery', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+  let sessionId: SessionId
+  let cwd: string
+  let disposeApproval: (() => void) | undefined
+  const events: SessionEvent[] = []
+  let nativeRoot: string | undefined
+  let openLog: string
+  const opened = async (): Promise<Array<{ path: string; content: string }>> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; content: string })
+  const downloads: string[] = []
+
+  beforeAll(async () => {
+    nativeRoot = await mkdtemp(join(tmpdir(), 'dsh-present-native-'))
+    openLog = join(nativeRoot, 'opened.jsonl')
+    await writeFile(openLog, '')
+    // Exercise the built Host through its actual OS command, replacing only the desktop application.
+    const command = process.platform === 'darwin' ? 'open' : 'xdg-open'
+    await writeFile(join(nativeRoot, command), `#!${process.execPath}
+const fs = require('node:fs');
+fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.argv[2], content: fs.readFileSync(process.argv[2], 'utf8') }) + '\\n');
+`, { mode: 0o700 })
+    vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`)
+    await mkdir(DIR, { recursive: true })
+    scaffold = await launchWebScaffold({
+      agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true,
+      ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }),
+    })
+    disposeApproval = scaffold.ctx.on('approval/request', () => Promise.resolve('allowed-once'), { prepend: true })
+    scaffold.ctx.on('session/event', (_session, event) => { events.push(event) })
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+    page.on('download', (download) => { downloads.push(download.suggestedFilename()) })
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
+  }, 120_000)
+
+  afterAll(async () => {
+    try {
+      await browser?.close()
+    } finally {
+      disposeApproval?.()
+      try {
+        await scaffold?.close()
+      } finally {
+        vi.unstubAllEnvs()
+        if (nativeRoot !== undefined) await rm(nativeRoot, { recursive: true, force: true })
+      }
+    }
+  })
+
+  it('declares nested deliveries even when the enclosing program subsequently fails', async () => {
+    if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
+    const settled = scaffold.whenTurnSettled()
+    const input = page.locator('[data-composer-input]').first()
+    await input.fill(PROMPT)
+    await input.press('Enter')
+    sessionId = await settled
+    const workspace = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
+    if (workspace === undefined) throw new Error('present Session has no workspace')
+    cwd = workspace
+    if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
+    await page.getByText(/^PRESENT_DONE\.?$/).waitFor({ timeout: 30_000 })
+    await assertFinalWorkspaceSnapshot(DIR, cwd)
+    expect(events.filter(event => event.type === 'deliverables/presented').flatMap(event => event.data.files.map(file => file.path)))
+      .toEqual(['report.txt', '说明.txt'])
+    for (const event of events) {
+      if (event.type === 'deliverables/presented') {
+        expect(event.data.files).toEqual([
+          { path: 'report.txt', description: 'delivered report' },
+          { path: '说明.txt', description: 'delivered note' },
+        ])
+      }
+    }
+    expect(events.some(event => event.type === 'tool/ptc-dispatch' && event.data.name === 'present' && event.data.isError)).toBe(true)
+    expect(events.some(event => event.type === 'tool/result' && event.data.message.content[0].isError)).toBe(true)
+  }, 200_000)
+
+  it('opens current source files after edits and reload, and reports deletion without downloading', async () => {
+    await writeFile(join(cwd, 'report.txt'), 'EDITED_REPORT\n')
+    await writeFile(join(cwd, '说明.txt'), 'EDITED_NOTE\n')
+    for (const reload of [false, true]) {
+      if (reload) {
+        const warningStart = tripwire.warnings.length
+        await page.reload({ waitUntil: 'load' })
+        acknowledgeReloadConnectionLoss(tripwire, warningStart)
+        await page.getByText(/^PRESENT_DONE\.?$/).waitFor({ timeout: 30_000 })
+      }
+      const row = page.locator('[data-presented-files-row]')
+      await row.waitFor()
+      expect(await row.getByRole('button').count()).toBe(2)
+      for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) {
+        const count = (await opened()).length
+        const response = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST')
+        await row.getByRole('button', { name: `Open ${name} in default app`, exact: true }).click()
+        expect((await response).status()).toBe(204)
+        await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null)
+        expect(await opened()).toHaveLength(count + 1)
+        expect((await opened()).at(-1)).toEqual({ path: await realpath(join(cwd, name)), content: bytes })
+      }
+    }
+    const count = (await opened()).length
+    const openedResponse = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST')
+    await page.locator('code').getByRole('button', { name: 'Open report.txt in default app', exact: true }).click()
+    await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null)
+    expect((await openedResponse).status()).toBe(204)
+    expect(await opened()).toHaveLength(count + 1)
+    expect((await opened()).at(-1)).toEqual({ path: await realpath(join(cwd, 'report.txt')), content: 'EDITED_REPORT\n' })
+    expect(downloads).toEqual([])
+    const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href)
+    expect(response.status()).toBe(200)
+    const entries = unzipSync(await response.body())
+    expect(Object.keys(entries)).toHaveLength(1)
+    const exported = strFromU8(Object.values(entries)[0]!)
+    expect(exported).toContain('deliverables/presented')
+    const declarations = exported.trim().split('\n').map(line => JSON.parse(line) as SessionEvent)
+      .filter(event => event.type === 'deliverables/presented')
+    expect(declarations).toHaveLength(1)
+    expect(declarations[0]!.data.files).toEqual([
+      { path: 'report.txt', description: 'delivered report' },
+      { path: '说明.txt', description: 'delivered note' },
+    ])
+    expect(exported).not.toContain('EDITED_REPORT')
+    if (MODE !== 'record') {
+      const aria = await captureExpandedTurnProcessAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+      await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE)
+      await page.locator('[data-turn-process]').click()
+      const failed = page.locator('[data-tool="present"][data-state="error"]')
+      const delivered = page.locator('[data-tool="present"][data-state="ok"]')
+      expect(await failed.count()).toBe(1)
+      expect(await delivered.count()).toBe(1)
+      expect(await failed.innerText()).toContain('Delivery failed')
+      expect(await delivered.innerText()).toContain('Delivered')
+      await page.locator('[data-turn-process]').click()
+      await page.setViewportSize({ width: 480, height: 900 })
+      const row = page.locator('[data-presented-files-row]')
+      await row.scrollIntoViewIfNeeded()
+      for (const card of await row.getByRole('button').all()) {
+        const bounds = await card.boundingBox()
+        expect(bounds).not.toBeNull()
+        expect(bounds!.x).toBeGreaterThanOrEqual(0)
+        expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(480)
+      }
+    }
+    const beforeDelete = (await opened()).length
+    await unlink(join(cwd, 'report.txt'))
+    const missing = page.waitForResponse(response => response.url().includes('/api/present.open?'))
+    await page.locator('[data-presented-files-row]').getByRole('button', { name: 'Open report.txt in default app', exact: true }).click()
+    expect((await missing).status()).toBe(404)
+    await page.getByText('Could not open. Click to retry.', { exact: true }).waitFor()
+    expect(await opened()).toHaveLength(beforeDelete)
+    expect(downloads).toEqual([])
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  })
+})

+ 1 - 0
apps/web/tests/shipped-composition.e2e.ts

@@ -45,6 +45,7 @@ const EXPECTED_TOOLS = [
   'job_list',
   'job_output',
   'list_agents',
+  'present',
   'ralph',
   'read',
   'read_image',

+ 3 - 2
apps/web/tests/turn-tail-actions.e2e.ts

@@ -64,7 +64,8 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
   /** Boot scaffold + page, materializing the sidecar before the replay row installs. */
   async function launch(
     buildOverride?: (sidecarHome: string) => ReplayOverrideDoc,
-    paceMs?: number,
+    // Throughput snapshots require a nonzero interval between replayed chunks.
+    paceMs = 1,
   ): Promise<void> {
     sessionEvents = []
     let overridePath: string | undefined
@@ -80,7 +81,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
           replayFixture: FIXTURE,
           ...(overridePath === undefined ? {} : { replayOverride: overridePath }),
           compareReplaySession: overridePath === undefined,
-          ...(paceMs === undefined ? {} : { paceMs }),
+          paceMs,
         },
     )
     scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })

+ 1 - 0
apps/web/tsconfig.json

@@ -61,6 +61,7 @@
     "tests/rail-search-expand.e2e.ts",
     "tests/conversation-column-overflow.e2e.ts",
     "tests/ptc-round.e2e.ts",
+    "tests/present.e2e.ts",
     "tests/composer-draft-scroll.e2e.ts",
     "tests/cordis-tool-round.e2e.ts",
     "tests/web-search-round.e2e.ts",

+ 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: 4078317ede9d5938dcf680747006409197a94869
-config-catalog.zh.md: 0f94c711b3cd5f06d6969eef8153df9ca0f1adba
+config-catalog.md: 81281af98001149f88896b490bc261f1c1fc0d26
+config-catalog.zh.md: e0b5156b52f67a0cf99688fe1f61feff5419b2f0

+ 17 - 1
docs/config-catalog.md

@@ -2869,6 +2869,22 @@ export interface Config {
 
 Source: [`packages/lsp/tool-lsp/src/index.ts:57`](../packages/lsp/tool-lsp/src/index.ts)
 
+<a id="deepseek-aidsh-tool-present"></a>
+
+## `@deepseek-ai/dsh-tool-present`
+
+Requires: `tools` · `fs` · `sessionProjections`
+
+```ts config-catalog
+/** Per-call delivery limit. */
+export interface Config {
+  /** Maximum number of files in one call. */
+  maxFiles: number
+}
+```
+
+Source: [`packages/fs/tool-present/src/index.ts:15`](../packages/fs/tool-present/src/index.ts)
+
 <a id="deepseek-aidsh-tool-pwsh"></a>
 
 ## `@deepseek-ai/dsh-tool-pwsh`
@@ -3441,7 +3457,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-client-ui-commands` ([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-cordis` ([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts))
-- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-directory-picker-browse` ([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts))

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

@@ -2871,6 +2871,22 @@ export interface Config {
 
 来源:[`packages/lsp/tool-lsp/src/index.ts:57`](../packages/lsp/tool-lsp/src/index.ts)
 
+<a id="deepseek-aidsh-tool-present"></a>
+
+## `@deepseek-ai/dsh-tool-present`
+
+依赖: `tools` · `fs` · `sessionProjections`
+
+```ts config-catalog
+/** Per-call delivery limit. */
+export interface Config {
+  /** Maximum number of files in one call. */
+  maxFiles: number
+}
+```
+
+来源: [`packages/fs/tool-present/src/index.ts:15`](../packages/fs/tool-present/src/index.ts)
+
 <a id="deepseek-aidsh-tool-pwsh"></a>
 
 ## `@deepseek-ai/dsh-tool-pwsh`
@@ -3443,7 +3459,7 @@ export interface Config {
 - `@deepseek-ai/dsh-client-ui-commands`([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-conversation`([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-cordis`([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts))
-- `@deepseek-ai/dsh-client-ui-deliverables` — 需要 `systemPrompt`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-deliverables` — 需要 `systemPrompt` · `connection` · `sessionQuery` · `sessionController`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-directory-picker-browse`([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-directory-picker-native`([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-goal`([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts))

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: d5ee3453e8694de70cfd64e44b3ce724aa76cd86
-event-producer-consumer.zh.md: d857561a7d9dbdf4c4d971a1854d43d3ecc4fd2a
+event-producer-consumer.md: 449b7d8f0fb55515e7f1f028e151ce1b26862e92
+event-producer-consumer.zh.md: 8120df6de6776bce8841f75311294583417532c2

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

@@ -66,7 +66,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:167`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
 | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:144`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
 | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
-| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
+| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/fs/tool-present) |
 | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
 | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |

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

@@ -68,7 +68,7 @@
 | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:167`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
 | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:144`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
 | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
-| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
+| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/fs/tool-present) |
 | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
 | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: 9d229ba5d25fbf9f4763a96667150dd2103d0231
-module-graph.zh.md: 830f777dde991098ad2f4279a879ecf9cb144900
+module-graph.md: 45e3490e95c86b025fde3f89ef3243b98625d012
+module-graph.zh.md: aacce9fb513e243a1ec27955d34aa82a2a6749be

+ 8 - 0
docs/module-graph.md

@@ -56,6 +56,7 @@ flowchart TD
     pkg_fs_sandbox["fs-sandbox"]
     pkg_tool_fs["tool-fs"]
     pkg_tool_fs_search["tool-fs-search"]
+    pkg_tool_present["tool-present"]
     pkg_tool_str_replace_editor["tool-str-replace-editor"]
   end
   subgraph group_skill["packages/skill"]
@@ -739,6 +740,12 @@ flowchart TD
   pkg_tool_fs_search --> pkg_system_prompt
   pkg_tool_fs_search --> pkg_timeout
   pkg_tool_fs_search --> pkg_tools
+  pkg_tool_present --> pkg_agent
+  pkg_tool_present --> pkg_fs
+  pkg_tool_present --> pkg_llm
+  pkg_tool_present --> pkg_session
+  pkg_tool_present --> pkg_session_projection
+  pkg_tool_present --> pkg_tools
   pkg_tool_str_replace_editor --> pkg_fs
   pkg_tool_str_replace_editor --> pkg_sandbox
   pkg_tool_str_replace_editor --> pkg_sandbox_policy
@@ -1370,6 +1377,7 @@ flowchart TD
 | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
+| [`tool-present`](../packages/fs/tool-present) | `fs` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
 | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
 | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |

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

@@ -58,6 +58,7 @@ flowchart TD
     pkg_fs_sandbox["fs-sandbox"]
     pkg_tool_fs["tool-fs"]
     pkg_tool_fs_search["tool-fs-search"]
+    pkg_tool_present["tool-present"]
     pkg_tool_str_replace_editor["tool-str-replace-editor"]
   end
   subgraph group_skill["packages/skill"]
@@ -741,6 +742,12 @@ flowchart TD
   pkg_tool_fs_search --> pkg_system_prompt
   pkg_tool_fs_search --> pkg_timeout
   pkg_tool_fs_search --> pkg_tools
+  pkg_tool_present --> pkg_agent
+  pkg_tool_present --> pkg_fs
+  pkg_tool_present --> pkg_llm
+  pkg_tool_present --> pkg_session
+  pkg_tool_present --> pkg_session_projection
+  pkg_tool_present --> pkg_tools
   pkg_tool_str_replace_editor --> pkg_fs
   pkg_tool_str_replace_editor --> pkg_sandbox
   pkg_tool_str_replace_editor --> pkg_sandbox_policy
@@ -1372,6 +1379,7 @@ flowchart TD
 | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
+| [`tool-present`](../packages/fs/tool-present) | `fs` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
 | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
 | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |

+ 2 - 2
docs/persistence-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/persistence-catalog.md
-persistence-catalog.md: 06242185a988d6908be0c66e13a45af4e4974e1c
-persistence-catalog.zh.md: bc6f4f623e60962df7adfd3771d4bf95b1c1ff3b
+persistence-catalog.md: a9e1221a564a4ad1af1353278f1215bb33fb9c4f
+persistence-catalog.zh.md: 2b74b03b4b783f848385e66e15c40d73f50a788b

+ 15 - 0
docs/persistence-catalog.md

@@ -396,6 +396,21 @@ Types: [ContentBlock](subsystems/core.md) · [TokenUsage](subsystems/llm-streami
 
 Source: [`packages/compaction/compaction/src/types.ts:34`](../packages/compaction/compaction/src/types.ts)
 
+### `deliverables/*`
+
+<a id="deliverablespresented--log-only"></a>
+
+#### `deliverables/presented` — log-only
+
+```ts persistence-catalog
+/** Declared workspace files from a successful final present result, including nested calls. */
+'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] }
+```
+
+Types: [ToolCallId](subsystems/core.md)
+
+Source: [`packages/fs/tool-present/src/types.ts:15`](../packages/fs/tool-present/src/types.ts)
+
 ### `feedback/*`
 
 <a id="feedbackmessage-delete--log-only"></a>

+ 15 - 0
docs/persistence-catalog.zh.md

@@ -398,6 +398,21 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 来源:[`packages/compaction/compaction/src/types.ts:34`](../packages/compaction/compaction/src/types.ts)
 
+### `deliverables/*`
+
+<a id="deliverablespresented--log-only"></a>
+
+#### `deliverables/presented` — 仅日志
+
+```ts persistence-catalog
+/** Declared workspace files from a successful final present result, including nested calls. */
+'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] }
+```
+
+类型: [ToolCallId](subsystems/core.zh.md)
+
+来源: [`packages/fs/tool-present/src/types.ts:15`](../packages/fs/tool-present/src/types.ts)
+
 ### `feedback/*`
 
 <a id="feedbackmessage-delete--log-only"></a>

+ 2 - 2
docs/tool-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/tool-catalog.md
-tool-catalog.md: c85201523e66408e6aa536de1f141df650055265
-tool-catalog.zh.md: ce5f5b57f6ac6f6a3db6a1503967fc7ceb7c48fd
+tool-catalog.md: c2ea95ff460b65fbba789738b16b0c67a7c91948
+tool-catalog.zh.md: 41fc1eaa3c6a0acf17bde4b69c97413b64872930

+ 44 - 0
docs/tool-catalog.md

@@ -19,6 +19,7 @@ This table connects model-visible tool names to the plugin package and service s
 | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` / `mode: both` (see the PTC mode Agent Note). Under `ptc` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. |
 | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userQuestions (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-questions seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. |
 | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.jobs` runtime and is collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. |
+| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented after a successful final result`, `tool/result` | - | Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards. |
 | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.shell`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.jobs` runtime and are collected/stopped through the `job_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-shell-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. |
 | `@deepseek-ai/dsh-tool-cordis` | `cordis_define`, `cordis_inspect_list`, `cordis_inspect_query`, `cordis_inspect_self`, `cordis_run`, `cordis_stop`, `cordis_undefine` | `ctx.tools`, `ctx.dynamicCordisRunner` | `tool/call`, `tool/result`, `process-local dynamic package lifecycle` | - | Not in any shipped tree (a deliberate opt-in — dynamic package code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). The toolset injects `ctx.dynamicCordisRunner` from `@deepseek-ai/dsh-cordis-host-runner`, which owns the definition registry and the vm sandbox; a composition missing it never activates the tools. A running package may register ADDITIONAL model-visible tools until it is stopped, undefined, or DSH restarts; a full changed request header logs those tool-set changes. |
 | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. |
@@ -218,6 +219,49 @@ Source: [`packages/shell/tool-bash/src/index.ts`](../packages/shell/tool-bash/sr
 
 The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.jobs` runtime and is collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.
 
+<a id="deepseek-aidsh-tool-present"></a>
+
+## `@deepseek-ai/dsh-tool-present`
+
+### `present`
+
+Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.
+
+```json
+{
+  "type": "object",
+  "properties": {
+    "files": {
+      "type": "array",
+      "items": {
+        "type": "object",
+        "additionalProperties": false,
+        "properties": {
+          "path": {
+            "type": "string",
+            "description": "Path of an existing file inside the workspace."
+          },
+          "description": {
+            "type": "string",
+            "description": "Brief description for the user."
+          }
+        },
+        "required": [
+          "path"
+        ]
+      }
+    }
+  },
+  "required": [
+    "files"
+  ]
+}
+```
+
+Source: [`packages/fs/tool-present/src/index.ts`](../packages/fs/tool-present/src/index.ts)
+
+Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards.
+
 <a id="deepseek-aidsh-tool-pwsh"></a>
 
 ## `@deepseek-ai/dsh-tool-pwsh`

+ 44 - 0
docs/tool-catalog.zh.md

@@ -23,6 +23,7 @@
 | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`、`ctx.codeRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: ptc`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `ptc` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 |
 | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 |
 | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;禁用 `enableRunInBackground` 配置(默认为 true)后,该参数会被完全移除。 |
+| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented 在成功的最终结果之后`, `tool/result` | - | 交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。 |
 | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 |
 | `@deepseek-ai/dsh-tool-cordis` | `cordis_define`、`cordis_inspect_list`、`cordis_inspect_query`、`cordis_inspect_self`、`cordis_run`、`cordis_stop`、`cordis_undefine` | `ctx.tools`、`ctx.dynamicCordisRunner` | `tool/call`、`tool/result`、`process-local dynamic package lifecycle` | - | 不在任何随产品发布的树中,需要显式选择启用;动态 Package 代码可以访问真实运行时,见 .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md。该工具集注入 `@deepseek-ai/dsh-cordis-host-runner` 提供的 `ctx.dynamicCordisRunner`,后者拥有定义注册表和 vm 沙箱;组合缺少它时这些工具不会激活。运行中的 Package 在停止、undefine 或 DSH 重启前可以注册**额外的**模型可见工具;发生这类工具集变化时,系统会记录完整且有变动的请求头。 |
 | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 |
@@ -222,6 +223,49 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类
 
 bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;禁用 `enableRunInBackground` 配置(默认为 true)后,该参数会被完全移除。
 
+<a id="deepseek-aidsh-tool-present"></a>
+
+## `@deepseek-ai/dsh-tool-present`
+
+### `present`
+
+声明交付已有的工作区文件。用户打开当前源文件;不复制或保存其内容。调用工具前先创建文件。
+
+```json
+{
+  "type": "object",
+  "properties": {
+    "files": {
+      "type": "array",
+      "items": {
+        "type": "object",
+        "additionalProperties": false,
+        "properties": {
+          "path": {
+            "type": "string",
+            "description": "Path of an existing file inside the workspace."
+          },
+          "description": {
+            "type": "string",
+            "description": "Brief description for the user."
+          }
+        },
+        "required": [
+          "path"
+        ]
+      }
+    }
+  },
+  "required": [
+    "files"
+  ]
+}
+```
+
+来源: [`packages/fs/tool-present/src/index.ts`](../packages/fs/tool-present/src/index.ts)
+
+交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。
+
 <a id="deepseek-aidsh-tool-pwsh"></a>
 
 ## `@deepseek-ai/dsh-tool-pwsh`

+ 55 - 27
packages/acp/acp/tests/dispose.spec.ts

@@ -7,10 +7,14 @@ import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
 
 describe('ACP connection ownership', () => {
   let harness: BridgeHarness | undefined
+  let releaseBlockedDisposal: (() => void) | undefined
 
   afterEach(async () => {
-    await harness?.dispose()
+    const bridge = harness
     harness = undefined
+    releaseBlockedDisposal?.()
+    releaseBlockedDisposal = undefined
+    await bridge?.dispose()
   })
 
   it('disposal cancels a running prompt and awaits agent teardown', async () => {
@@ -198,34 +202,58 @@ describe('ACP connection ownership', () => {
     expect(harness.ctx.agents.list()).toHaveLength(0)
   })
 
-  it('a client disconnect disposes every owned session without root-context disposal', async () => {
+  it.each([
+    ['a client disconnect', 'closeClientTransport'],
+    ['a failed client transport', 'abortClientTransport'],
+  ] as const)('%s disposes its session without plugin disposal', async (_name, disconnect) => {
     harness = await makeBridgeHarness({ script: ['hang'] })
-    await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
-    const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
-    const agent = harness.ctx.agents.get(SessionId(sessionId))!
-    void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
-    await vi.waitFor(() => { expect(agent.status).toBe('running') })
-
-    await harness.closeClientTransport()
-    await harness.acpFiber.dispose()
-    expect(agent.status).toBe('idle')
-    expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
-    expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
-  })
-
-  it('a failed client transport still disposes every owned session', async () => {
-    harness = await makeBridgeHarness({ script: ['hang'] })
-    await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
-    const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
-    const agent = harness.ctx.agents.get(SessionId(sessionId))!
-    void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
-    await vi.waitFor(() => { expect(agent.status).toBe('running') })
-
-    await harness.abortClientTransport()
-    await vi.waitFor(() => {
-      expect(harness!.ctx.agents.get(SessionId(sessionId)) === undefined).toBe(true)
+    const bridge = harness
+    const create = bridge.ctx.agents.create.bind(bridge.ctx.agents)
+    const disposalStarted = Promise.withResolvers<undefined>()
+    const releaseDisposal = Promise.withResolvers<undefined>()
+    releaseBlockedDisposal = () => { releaseDisposal.resolve(undefined) }
+    const disposalCompleted = Promise.withResolvers<undefined>()
+    const createSpy = vi.spyOn(bridge.ctx.agents, 'create').mockImplementation(async (options) => {
+      const handle = await create(options)
+      const dispose = handle.dispose.bind(handle)
+      handle.dispose = () => {
+        const completion = (async () => {
+          disposalStarted.resolve(undefined)
+          await releaseDisposal.promise
+          await dispose()
+          return undefined
+        })()
+        disposalCompleted.resolve(completion)
+        return completion
+      }
+      return handle
     })
-    expect(agent.status).toBe('idle')
+    const running = Promise.withResolvers<undefined>()
+    let stopListening: (() => void) | undefined
+    try {
+      await bridge.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+      const { sessionId } = await bridge.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+      const agent = bridge.ctx.agents.get(SessionId(sessionId))!
+      stopListening = bridge.ctx.on('agent/status', ({ agent: changed, status }) => {
+        if (changed === agent && status === 'running') running.resolve(undefined)
+      })
+      void bridge.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
+      await running.promise
+
+      await bridge[disconnect]()
+      await disposalStarted.promise
+      expect(bridge.ctx.agents.get(SessionId(sessionId))).toBe(agent)
+      expect(bridge.ctx.sessions.get(SessionId(sessionId))).toBe(agent.session)
+      releaseDisposal.resolve(undefined)
+      await disposalCompleted.promise
+      expect(agent.status).toBe('idle')
+      expect(bridge.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
+      expect(bridge.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
+    } finally {
+      releaseDisposal.resolve(undefined)
+      stopListening?.()
+      createSpy.mockRestore()
+    }
   })
 
   it('disconnect and plugin disposal share one quiescence boundary', async () => {

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

@@ -95,6 +95,7 @@
     "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-tool-bash": "workspace:^",
+    "@deepseek-ai/dsh-tool-present": "workspace:^",
     "@deepseek-ai/dsh-tool-fs": "workspace:^",
     "@deepseek-ai/dsh-tool-fs-search": "workspace:^",
     "@deepseek-ai/dsh-tool-goal": "workspace:^",

+ 2 - 2
packages/client/ui-chat/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
-README.md: b5a837f927b483a1f72e8282032fce29683bc237
-README.zh.md: f6b8018434251e46875bfd5cc0b0a242cdf2a439
+README.md: 7c9b38a1abc6289c9c0f7b8335d2c4a796e32840
+README.zh.md: 80198a89fea2586d350dfc8bbeaf8a122739122c

+ 2 - 0
packages/client/ui-chat/README.md

@@ -10,6 +10,8 @@ English | [中文](README.zh.md)
 
 Use this package to render a browser chat from recorded Session conversations, including historical images, localized actions, and restored scroll position. Compact display folds completed-turn process rows while keeping the final answer and independently useful context visible; packed historical Assistant runs remain collapsed. Local transcript and steering submissions appear immediately, remain in their original surface, and disappear atomically when authoritative Session records arrive, while queued submissions stay outside Chat. The package does not assemble or modify model requests.
 
+File-mention providers receive the viewed Session ID with the closing-turn owner, so links into inherited history can address the fork itself.
+
 ## Table of Contents
 
 - [System prompt row](#system-prompt-row)

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

@@ -10,6 +10,8 @@ kind: "package-reference"
 
 使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。紧凑显示会收起已完成轮次的过程行,同时保持最终答案和独立有用的上下文可见;已打包的历史 Assistant 连续消息保持收起。本地 transcript 与 steering 提交会立即显示并保留在原区域,在权威 Session 记录到达时原子地消失,而 queued 提交始终不进入 Chat。本包不组装或修改模型请求。
 
+文件引用提供方同时接收当前查看的 Session ID 与收尾 turn 的属主信息,因此继承历史中的链接可以指向 fork 自身。
+
 ## 目录
 
 - [系统提示词行](#system-prompt-row)

+ 1 - 1
packages/client/ui-chat/src/client/apply.ts

@@ -116,7 +116,7 @@ export function apply(ctx: Context): void {
             chatNode: key => chat.getSnapshot().nodes.source(key),
             chatNodeProcess: key => chat.getSnapshot().nodes.processSource(key),
           },
-          fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner),
+          fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId),
           // Files open in the right Sidebar, not in a desktop application: the
           // content stays in the product, beside the conversation that produced
           // it. A relative path, or an absolute one inside the session's

+ 3 - 2
packages/client/ui-chat/src/client/contract/slots.ts

@@ -1,6 +1,6 @@
 /** Chat-owned Slot declarations and composed component props. */
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
-import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
+import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types'
 import type {
   CommandNode, CompactionSummaryNode, ConversationLocationDataStore, ConversationTurnDataMap,
   MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation,
@@ -53,9 +53,10 @@ export interface ChatFileMentions {
   /**
    * Resolve prose links for one closing Turn.
    * @param owner - closing-Turn identity and file opener.
+   * @param sessionId - viewed Session, including when history is inherited from a fork.
    * @returns link resolver when available.
    */
-  forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined
+  forClosing(owner: TurnTailOwnerProps, sessionId: SessionId): MarkdownFileMentions | undefined
 }
 
 declare module '@deepseek-ai/cordis' {

+ 1 - 1
packages/client/ui-chat/tests/apply-inject.client.spec.tsx

@@ -182,7 +182,7 @@ describe('Chat inject API', () => {
     const forClosing = vi.fn(() => mentions)
     b.runtime.ctx.provide('chatFileMentions', { forClosing } as never)
     expect(injected.fileMentions(owner)).toBe(mentions)
-    expect(forClosing).toHaveBeenCalledWith(owner)
+    expect(forClosing).toHaveBeenCalledWith(owner, ROOT)
 
     expect(injected.chatScroll.read()).toBeNull()
     const position = { anchorKey: 'node-1', anchorTop: 4, scrollTop: 12 }

+ 2 - 2
packages/client/ui-deliverables/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md
-README.md: bfd4c40a952122db25172bebb077d12800b49308
-README.zh.md: 23c983f938d2d77fa5f33e8dcd2526282268b835
+README.md: ae42abb5a69d29332bd7cfd6d2d2a1191381e79a
+README.zh.md: 664dfa3cd2e0d526b43464a80290604a8ecf958c

+ 14 - 4
packages/client/ui-deliverables/README.md

@@ -27,6 +27,13 @@ This package renders the deliverables row a finished turn ends with — the file
 
 Mount this plugin alongside `ui-conversation`; a finished turn then ends with the produced-files row between the closing message's body and its action footer. Each chip opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar as a text-preview tab, with relative paths resolved against the session cwd. The row offers no folder action: the Sidebar has no directory form, so an omitted-file remainder is a label only.
 
+<a id="explicit-deliveries"></a>
+### Explicit deliveries
+
+The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows responsive cards with file names, types, descriptions, and buttons that open the source in the Host’s default application. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply.
+
+The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. File cards include every delivered file. Opening shows progress, confirmation, or a retryable error on the card. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device.
+
 ### The row
 
 The row uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling.
@@ -43,7 +50,9 @@ The closing prose carries the same vocabulary: an inline-code token resolves by
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers `ProducedFiles` into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost.
+The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around `ProducedFiles` and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost.
+
+Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host resolves the declaration against that Session’s workspace and checks the current file exists within it before launching the default application. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests.
 
 </details>
 
@@ -72,7 +81,7 @@ One fixed paragraph instructs the model to name primary files from successful cr
 
 #### Token effect
 
-One fixed prompt paragraph whenever this package is loaded; no tool schema, tool result, or per-Turn context is added.
+One fixed prompt paragraph whenever this package is loaded. The [present tool](../../fs/tool-present/README.md#model-experience) owns the delivery schema and result text.
 
 #### KV Cache effect
 
@@ -86,7 +95,8 @@ The section is static at first-party order 9000 for the lifetime of the package
 These limits define the current deliverables vocabulary. They are current package constraints, not a general file-linking comparison or a task backlog.
 
 - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it.
-- **Files created indirectly by terminal commands remain outside the matching vocabulary** — naming such a file in inline code does not make it clickable unless a successful mutation location also records that path.
+- **Terminal-created files require explicit delivery** — call `present` to declare them for native opening.
+- **Declarations do not preserve file contents** — reopening or transferring a Session requires the source files in the viewed Session’s workspace. Missing files return 404; paths resolving outside the workspace return 403.
 - **Directories have no destination** — chips open files in the right Sidebar's text preview, which shows files only; the former native folder handoff is gone rather than replaced.
 
 <a id="dev-note"></a>
@@ -99,4 +109,4 @@ None.
 
 </details>
 
-**Runtime invariant:** No companion is published. The prompt section, slot, dictionary, event definition, and optional service registrations are effect-owned with disposal proven by their plugin specs; this package owns no mutable state.
+**Runtime invariant:** No companion is published. Prompt, slot, dictionary, file-action route, and optional service registrations are effect-owned; the Session log owns declarations and the workspace owns file contents.

+ 15 - 5
packages/client/ui-deliverables/README.zh.md

@@ -27,6 +27,13 @@ kind: "package-reference"
 
 与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经属主的 `openFile` 打开文件——chat 视图把它路由到右侧 Sidebar 作为一个文本预览 tab——相对路径按会话 cwd 解析。该行不提供文件夹动作:Sidebar 没有目录形态,因此省略文件的余数只是一个标签才会打开会话工作区。
 
+<a id="explicit-deliveries"></a>
+### 显式交付
+
+Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 显示响应式卡片,包含文件名称、类型、说明和在 Host 默认应用中打开源文件的按钮。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。
+
+`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。文件卡片展示全部交付文件。打开时,卡片显示进度、成功确认或可重试的错误。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。
+
 ### 该行
 
 该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用 ellipsis 省略,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。
@@ -43,7 +50,9 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把 `ProducedFiles` 注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。
+Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。
+
+原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 按该 Session 的工作区解析路径,检查当前文件存在且位于工作区内,再启动默认应用。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
 
 </details>
 
@@ -72,7 +81,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 
 #### Token 影响
 
-加载本包时增加一段固定提示词;不增加工具 schema、工具结果或按轮次变化的上下文。
+加载本包时增加一段固定提示词。[present 工具](../../fs/tool-present/README.zh.md#model-experience)拥有交付 schema 和结果文本。
 
 #### KV Cache 影响
 
@@ -86,8 +95,9 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 这些限制界定了当前产出物词表。它们是当前包约束,不是通用文件链接对比或任务积压。
 
 - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。
-- **终端命令间接创建的文件仍不在匹配词表内**——除非某个成功修改位置也记录了该路径,否则在行内代码中点名这类文件不会使其可点击。
-- **原生文件夹交接以 Host 桌面为目标**——经非 loopback authority 访问的浏览器会省略该动作,报告没有原生打开器的部署也一样;若 SSH 转发让远端 Host 看似 loopback 本地,部署必须为 Session Controller 设置 `nativeOpen: false`。
+- **终端创建的文件需要显式交付**——调用 `present` 声明文件,以便原生打开。
+- **声明不保存文件内容**——重新打开或转移 Session 后,需要当前查看的 Session 工作区中仍有源文件。文件缺失返回 404;解析到工作区外的路径返回 403。
+- **目录没有打开目标**——标签项在右侧 Sidebar 的文本预览中打开文件,该预览仅支持文件,不提供原生文件夹打开动作。
 
 <a id="dev-note"></a>
 ### 开发备注
@@ -99,4 +109,4 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 
 </details>
 
-**运行时不变式:** 不发布伴生入口。prompt section、slot、dictionary、event definition 与可选 service 注册都归 effect 所有,释放由插件测试证明;本包不持有可变状态。
+**运行时不变式:** 不发布伴生入口。提示词、slot、dictionary、文件操作路由与可选 service 注册归 effect 所有;Session 日志拥有声明,工作区拥有文件内容。

+ 8 - 2
packages/client/ui-deliverables/package.json

@@ -33,7 +33,8 @@
         "@deepseek-ai/dsh-client-locale",
         "@deepseek-ai/dsh-client-ui-chat",
         "@deepseek-ai/dsh-client-ui-conversation",
-        "@deepseek-ai/dsh-client-ui-renderer"
+        "@deepseek-ai/dsh-client-ui-renderer",
+        "@deepseek-ai/dsh-client-ui-tool"
       ],
       "platform": "web"
     }
@@ -61,7 +62,12 @@
     "react": "^18.2.0",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
-    "@deepseek-ai/dsh-system-prompt": "workspace:^"
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-session-query": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-tool": "workspace:^",
+    "@deepseek-ai/dsh-tool-present": "workspace:^",
+    "@deepseek-ai/dsh-api-session-controller": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 14 - 0
packages/client/ui-deliverables/src/client/Deliverables.module.css

@@ -0,0 +1,14 @@
+/** Immutable file deliveries at the end of a turn. */
+.root { display: flex; flex-direction: column; gap: 8px; min-width: 0; margin-top: 12px; }
+.label { font-size: 12px; color: var(--dsw-alias-label-secondary); }
+.presented { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 8px; }
+.file { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 14px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); text-decoration: none; font: inherit; text-align: left; cursor: pointer; }
+.file:hover { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-border-l3); }
+.file:focus-visible { outline: 2px solid var(--dsw-alias-link); outline-offset: 2px; }
+.fileIcon { flex: 0 0 auto; width: 24px; height: 24px; }
+.details { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }
+.fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; }
+.metadata { color: var(--dsw-alias-label-tertiary); font-size: 11px; }
+.description { color: var(--dsw-alias-label-secondary); font-size: 12px; overflow-wrap: anywhere; }
+.open { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 0 0 auto; color: var(--dsw-alias-link); font-size: 11px; }
+.file:disabled { cursor: wait; opacity: 0.65; }

+ 64 - 0
packages/client/ui-deliverables/src/client/Deliverables.tsx

@@ -0,0 +1,64 @@
+/** Existing changed-file chips and explicitly declared files for a closing turn. */
+import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
+import { LinkIcon, classifyLinkPath, IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import type { PresentedOpenController } from './present-open.ts'
+import { ProducedFiles } from './ProducedFiles.tsx'
+import { basename, presentedForClosing, selectProducedFiles, type PresentedPath } from './turn-deliverables.ts'
+import type { NS } from './locales.ts'
+import { presentedFileUrl } from '../presented.ts'
+import css from './Deliverables.module.css'
+
+interface DeliverablesMatch { produced: readonly string[]; presented: readonly PresentedPath[] }
+
+/** Native-open callbacks and shared gesture status supplied by the plugin. */
+export interface DeliverablesInjected {
+  hooks: { presentedOpen: ObservableSnapshot<ReturnType<PresentedOpenController['state']['getSnapshot']>> }
+  openPresented: PresentedOpenController['open']
+}
+
+/**
+ * Claim turns containing modified paths or declared files.
+ * @param owner - closing turn.
+ * @returns matched files, or null for an empty turn.
+ */
+export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch | null {
+  const produced = selectProducedFiles(owner) ?? []
+  const presented = presentedForClosing(owner)
+  return produced.length + presented.length === 0 ? null : { produced, presented }
+}
+
+/**
+ * Render workspace file actions and default-application buttons for declared files.
+ * @param props - matched files, workspace opener, and localized copy.
+ * @returns the closing turn's file rows.
+ */
+export function Deliverables({ matched, openFile, t, sessionId, openPresented, usePresentedOpen }: Pick<TurnTailOwnerProps, 'openFile'> & {
+  matched: DeliverablesMatch
+} & PropsLocale<typeof NS> & Pick<SessionStandardProps, 'sessionId'> & InjectFace<DeliverablesInjected>) {
+  const states = usePresentedOpen(value => value)
+  return <>
+    {matched.produced.length > 0 && <ProducedFiles matched={matched.produced} openFile={openFile} t={t} />}
+    {matched.presented.length > 0 && <div className={css.root}>
+      <span className={css.label}>{t('presented.label')}</span>
+      <div className={css.presented} data-presented-files-row>
+        {matched.presented.map((file) => {
+          const phase = states[presentedFileUrl(sessionId, file.seq, file.index)]
+          return <button key={file.path} type="button" className={css.file}
+            disabled={phase === 'opening'}
+            onClick={() => { void openPresented(sessionId, file.seq, file.index) }}
+            title={t('presented.open', { name: file.path })} aria-label={t('presented.open', { name: file.path })}>
+            <LinkIcon kind={classifyLinkPath(file.path)} className={css.fileIcon} />
+            <span className={css.details}>
+              <span className={css.fileName}>{basename(file.path)}</span>
+              <span className={css.metadata}>{basename(file.path).match(/\.([^.]+)$/)?.[1]?.toUpperCase() ?? t('presented.file')}</span>
+              {file.description && <span className={css.description}>{file.description}</span>}
+              {phase !== undefined && <span className={css.description} role="status">{t(`presented.${phase}`)}</span>}
+            </span>
+            <span className={css.open}><IconRightUpOutline16 /><span>{t('presented.action')}</span></span>
+          </button>})}
+      </div>
+    </div>}
+  </>
+}

+ 6 - 0
packages/client/ui-deliverables/src/client/PresentRow.module.css

@@ -0,0 +1,6 @@
+/** Present status remains readable beside long workspace paths. */
+.summary { margin-left: 8px; display: flex; align-items: center; gap: 8px; min-width: 0; color: var(--dsw-alias-label-secondary); font-size: 12px; }
+.summary > :first-child { flex-shrink: 0; }
+.paths { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.output { margin: 8px 0; padding: 12px; border-radius: 8px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; }
+.inspect { align-self: flex-start; border: none; padding: 4px 0; background: transparent; color: var(--dsw-alias-link); font: inherit; font-size: 12px; cursor: pointer; }

+ 45 - 0
packages/client/ui-deliverables/src/client/PresentRow.tsx

@@ -0,0 +1,45 @@
+/** Present call status and expandable durable result text. */
+import { useState } from 'react'
+import { DisclosureRow, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
+import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
+import type { NS } from './locales.ts'
+import css from './PresentRow.module.css'
+
+type PresentRowProps = ToolCallViewProps & PropsLocale<typeof NS>
+
+/** Raw arguments can be partial while a call is streaming. */
+function fileNames(raw: string): string {
+  let args: unknown
+  try { args = JSON.parse(raw) }
+  catch { return raw } // Truncated tool JSON remains visible until the call completes.
+  if (typeof args !== 'object' || args === null || !('files' in args) || !Array.isArray(args.files)) return raw
+  return args.files.flatMap((file: unknown) =>
+    typeof file === 'object' && file !== null && 'path' in file && typeof file.path === 'string'
+      ? [file.path] : [],
+  ).join(', ')
+}
+
+/**
+ * Render a present call using its recorded arguments and result.
+ * @param props - tool call and localized status copy.
+ * @returns a status row with a result disclosure.
+ */
+export function PresentRow({ block, inspect, t }: PresentRowProps) {
+  const settled = 'kind' in block
+  const state = !settled ? 'running' : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok'
+  const args = (settled ? block.call?.argsRaw : block.argsRaw) ?? ''
+  const output = settled ? block.content.map(item => item.type === 'text' ? item.text : JSON.stringify(item)).join('\n') : ''
+  const details = output || (settled && block.error ? `${block.error.name}: ${block.error.code}` : '')
+  const [expanded, setExpanded] = useState(false)
+  return <div data-tool="present" data-state={state}>
+    <DisclosureRow title={t('row.title')}
+      icon={<StateDot state={state === 'running' ? 'ongoing' : state === 'ok' ? 'done' : state === 'stopped' ? 'warning' : 'error'} />}
+      open={expanded && details !== ''} expandable={details !== ''} expandOnRowClick keepContentWhenOpen
+      onToggle={() => { setExpanded(value => !value) }}
+      collapsedContent={<span className={css.summary}><span>{t(`row.${state}`)}</span><span className={css.paths}>{fileNames(args)}</span></span>}>
+      <pre className={css.output}>{details}</pre>
+      {inspect && <button type="button" className={css.inspect} onClick={inspect}>{t('row.inspect')}</button>}
+    </DisclosureRow>
+  </div>
+}

+ 24 - 7
packages/client/ui-deliverables/src/client/index.ts

@@ -13,10 +13,12 @@ import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
-import { ProducedFiles } from './ProducedFiles.tsx'
+import { PresentedOpenController } from './present-open.ts'
+import { PresentRow } from './PresentRow.tsx'
+import { Deliverables, selectDeliverables, type DeliverablesInjected } from './Deliverables.tsx'
 import { en, NS, zh, type DeliverablesKey } from './locales.ts'
 import {
-  deliverablesDefinition, producedFileMentions, selectProducedFiles,
+  deliverablesDefinition, presentedForClosing, producedFileMentions, selectProducedFiles,
 } from './turn-deliverables.ts'
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -37,26 +39,41 @@ export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.se
  * @param ctx - client root context.
  */
 export function apply(ctx: ClientContext): void {
+  const opener = new PresentedOpenController()
+  ctx.effect(() => () => opener.dispose())
   ctx.uiConversation.events.register(deliverablesDefinition)
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries')
   ctx.slots.inject(
     'conversation.chat.turnTail',
     () => ctx.slots.register({
       name: 'conversation.chat.turnTail',
-      select: selectProducedFiles,
+      select: selectDeliverables,
       locale: NS,
-    }, ProducedFiles),
+      inject: (): DeliverablesInjected => ({
+        hooks: { presentedOpen: opener.state },
+        openPresented: (sessionId, seq, index) => opener.open(sessionId, seq, index),
+      }),
+    }, Deliverables),
   )
+  ctx.slots.inject('tool.call.toolview', () => ctx.slots.register(
+    { name: 'tool.call.toolview', key: 'present', locale: NS }, PresentRow,
+  ))
   // The prose side of the same vocabulary: the chat view reaches this face
   // via ctx.get, so its absence — this plugin composed out — is the off state.
   const t = ctx.locale.bind(NS)
   const mentions: ChatFileMentions = {
-    forClosing(owner) {
+    forClosing(owner, sessionId) {
       // Same claim test the turn-tail chain entry runs: no produced files,
       // no vocabulary — the two surfaces agree by construction.
       const paths = selectProducedFiles(owner)
-      if (paths === null) return undefined
-      return producedFileMentions(paths, owner.openFile, path => t('produced.open', { name: path }))
+      const presented = presentedForClosing(owner)
+      if (paths === null && presented.length === 0) return undefined
+      const deliveries = new Map(presented.map(file => [file.path, file]))
+      return producedFileMentions([...new Set([...paths ?? [], ...deliveries.keys()])], (path) => {
+        const file = deliveries.get(path)
+        if (file === undefined) owner.openFile(path)
+        else void opener.open(sessionId, file.seq, file.index)
+      }, path => t(deliveries.has(path) ? 'presented.open' : 'produced.open', { name: path }))
     },
   }
   ctx.provide('chatFileMentions', mentions)

+ 26 - 0
packages/client/ui-deliverables/src/client/locales.ts

@@ -5,6 +5,19 @@ export const NS = 'deliverables'
 
 /** Simplified Chinese dictionary (the key-set source of truth). */
 export const zh = {
+  'presented.label': '交付文件',
+  'presented.action': '打开',
+  'presented.opening': '正在打开…',
+  'presented.opened': '已在默认程序中打开',
+  'presented.error': '打开失败,点击重试',
+  'presented.file': '文件',
+  'row.title': '交付文件',
+  'row.running': '正在交付',
+  'row.ok': '已交付',
+  'row.error': '交付失败',
+  'row.stopped': '已中断',
+  'row.inspect': '查看调用',
+  'presented.open': '在默认程序中打开 {name}',
   'produced.label': '产物',
   'produced.moreOne': '+ 1 个文件',
   'produced.more': '+ {count} 个文件',
@@ -13,6 +26,19 @@ export const zh = {
 
 /** English dictionary (same key set). */
 export const en: Record<DeliverablesKey, string> = {
+  'presented.label': 'Deliverables',
+  'presented.action': 'Open',
+  'presented.opening': 'Opening…',
+  'presented.opened': 'Opened in default app',
+  'presented.error': 'Could not open. Click to retry.',
+  'presented.file': 'File',
+  'row.title': 'Present files',
+  'row.running': 'Delivering',
+  'row.ok': 'Delivered',
+  'row.error': 'Delivery failed',
+  'row.stopped': 'Interrupted',
+  'row.inspect': 'Inspect call',
+  'presented.open': 'Open {name} in default app',
   'produced.label': 'Produced',
   'produced.moreOne': '+ 1 file',
   'produced.more': '+ {count} files',

+ 54 - 0
packages/client/ui-deliverables/src/client/present-open.ts

@@ -0,0 +1,54 @@
+/** Shared native-open status for delivery cards and closing-message file mentions. */
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { presentedFileUrl } from '../presented.ts'
+
+/** State of the latest explicit open gesture for one saved file. */
+export type PresentedOpenPhase = 'opening' | 'opened' | 'error'
+
+/** One browser plugin's file-open requests, cancelled when that plugin is disposed. */
+export class PresentedOpenController {
+  /** File action URLs key the state across Sessions, turns, and both clickable surfaces. */
+  readonly state = createSnapshotStore<Record<string, PresentedOpenPhase | undefined>>({})
+  private readonly lifetime = new AbortController()
+  private readonly pending = new Set<Promise<void>>()
+
+  /**
+   * Open a declared workspace file once while a request for the same coordinates is pending.
+   * Failures remain visible on the card and a later gesture retries them.
+   * @param sessionId - viewed Session, including a fork's own identity.
+   * @param seq - durable delivery event sequence.
+   * @param index - original file index within that event.
+   * @returns after the Host acknowledges opening or the error state is published.
+   */
+  async open(sessionId: SessionId, seq: number, index: number): Promise<void> {
+    const url = presentedFileUrl(sessionId, seq, index)
+    if (this.lifetime.signal.aborted || this.state.getSnapshot()[url] === 'opening') return
+    this.state.update((state) => { state[url] = 'opening' })
+    const task = this.request(url)
+    this.pending.add(task)
+    try {
+      await task
+    } finally {
+      this.pending.delete(task)
+    }
+  }
+
+  /** Cancel outstanding requests and wait until no request can publish state. */
+  async dispose(): Promise<void> {
+    this.lifetime.abort()
+    await Promise.all(this.pending)
+  }
+
+  private async request(url: string): Promise<void> {
+    let phase: PresentedOpenPhase = 'opened'
+    try {
+      const response = await fetch(url, { method: 'POST', signal: this.lifetime.signal })
+      if (!response.ok) phase = 'error'
+    } catch {
+      // Transport failures share the retryable card state with Host open failures.
+      phase = 'error'
+    }
+    if (!this.lifetime.signal.aborted) this.state.update((state) => { state[url] = phase })
+  }
+}

+ 35 - 8
packages/client/ui-deliverables/src/client/turn-deliverables.ts

@@ -7,6 +7,14 @@ import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
 import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { PresentedFile } from '@deepseek-ai/dsh-tool-present/types'
+import { basename, isPresentedData, isPresentedFile } from '../presented.ts'
+
+/** A declared file with its authorized open coordinates. */
+export interface PresentedPath extends PresentedFile {
+  readonly seq: number
+  readonly index: number
+}
 
 interface ProducedPath {
   readonly seq: number
@@ -16,6 +24,7 @@ interface ProducedPath {
 /** Immutable produced-file facts published against one Turn. */
 export interface DeliverablesTurnData {
   readonly produced: readonly ProducedPath[]
+  readonly presented?: readonly PresentedPath[]
 }
 
 declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
@@ -150,6 +159,7 @@ export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesStat
   match: (event) => {
     if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
     if (event.type === 'tool/call') return { id: String(event.data.turn), role: 'update' }
+    if (event.type === 'deliverables/presented') return isPresentedData(event.data) ? { id: String(event.data.turn), role: 'update' } : null
     if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) {
       return { id: String(event.data.turn), role: 'update' }
     }
@@ -160,6 +170,17 @@ export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesStat
     return { turn: match.event.data.turn, calls: new Map(), produced: [] }
   },
   update: (context, match) => {
+    if (match.event.type === 'deliverables/presented') {
+      const { files } = match.event.data
+      const seq = match.event.seq
+      const presented: PresentedPath[] = []
+      for (let index = 0; index < files.length; index += 1) {
+        const file = files[index]
+        if (isPresentedFile(file)) presented.push({ ...file, seq, index })
+      }
+      if (presented.length === 0) return context.state
+      return { ...context.state, presented: [...context.state.presented ?? [], ...presented] }
+    }
     if (match.event.type === 'tool/call') {
       const calls = new Map(context.state.calls)
       calls.set(
@@ -182,26 +203,32 @@ export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesStat
     if (previous?.kind === 'turn'
       && previous.turn === context.state.turn
       && previous.key === 'deliverables'
-      && previous.value.produced === context.state.produced) return previous
+      && previous.value.produced === context.state.produced
+      && previous.value.presented === context.state.presented) return previous
     return {
       kind: 'turn',
       turn: context.state.turn,
       key: 'deliverables',
-      value: { produced: context.state.produced },
+      value: { produced: context.state.produced, ...context.state.presented === undefined ? {} : { presented: context.state.presented } },
     }
   },
 }
 
 /**
- * Trailing path segment, the part that identifies the file at a glance.
- * @param path - Slash- or backslash-separated path.
- * @returns The final segment, or the whole string when separator-free.
+ * Select the latest declaration of each path before the closing reply.
+ * @param owner - closing turn and sequence.
+ * @returns replayable deliveries in first-seen path order.
  */
-export function basename(path: string): string {
-  const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
-  return at === -1 ? path : path.slice(at + 1)
+export function presentedForClosing(owner: TurnTailOwnerProps): PresentedPath[] {
+  const files = new Map<string, PresentedPath>()
+  for (const file of owner.turn.data.get('deliverables')?.presented ?? []) {
+    if (file.seq < owner.seq) files.set(file.path, file)
+  }
+  return [...files.values()]
 }
 
+export { basename } from '../presented.ts'
+
 /**
  * File-mention vocabulary over one turn's produced paths, for the closing
  * message's prose: an inline-code token opens the file it names. A token

+ 6 - 3
packages/client/ui-deliverables/src/index.ts

@@ -1,15 +1,17 @@
 /**
  * Deliverables plugin, node half. Registers the response-format guidance that
- * lets the browser half recognize final-response file references. The browser
+ * lets the browser half recognize final-response file references and serves
+ * authenticated native opens of workspace files. The browser
  * half ships via exports["./client"], discovered through the package.json
  * dsh.client declaration.
  */
 
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-system-prompt'
+import { registerPresentOpen } from './present-open.ts'
 
-/** Services required for the model guidance paired with the browser renderer. */
-export const inject = ['systemPrompt']
+/** Services required for file-reference guidance and authenticated native opens of workspace files. */
+export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController']
 
 /** Stable final-response guidance owned by the matching renderer. */
 const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, mention the primary outputs in your final response. '
@@ -20,6 +22,7 @@ const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, men
  * @param ctx - host context carrying the system-prompt registry.
  */
 export function apply(ctx: Context): void {
+  registerPresentOpen(ctx)
   ctx.systemPrompt.section({
     name: 'ui:deliverable-file-references',
     order: ctx.systemPrompt.getSectionOrder('DELIVERABLE_FILE_REFERENCES'),

+ 71 - 0
packages/client/ui-deliverables/src/present-open.ts

@@ -0,0 +1,71 @@
+/** Open declared source files inside the viewed Session's workspace. */
+import { realpath, stat } from 'node:fs/promises'
+import { isAbsolute, relative, resolve, sep } from 'node:path'
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-api-session-controller'
+import type {} from '@deepseek-ai/dsh-client-connection'
+import type {} from '@deepseek-ai/dsh-session-query'
+import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
+import { isPresentedData, isPresentedFile, PRESENT_OPEN_PATH } from './presented.ts'
+
+/**
+ * Register native opening inside Connection's authentication fence.
+ * @param ctx - Session lookup, native opener, and route lifetime.
+ */
+export function registerPresentOpen(ctx: Context): void {
+  const lifetime = new AbortController()
+  const pending = new Set<Promise<Response>>()
+  ctx.effect(() => async () => {
+    lifetime.abort()
+    await Promise.allSettled(pending)
+  })
+  ctx.connection.fetch.register({
+    path: PRESENT_OPEN_PATH,
+    methods: ['POST'],
+    requestBody: 'buffered',
+    fetch: (request) => {
+      const task = handlePresentOpen(ctx, new Request(request, {
+        signal: AbortSignal.any([request.signal, lifetime.signal]),
+      }))
+      pending.add(task)
+      void task.then(() => { pending.delete(task) }, () => { pending.delete(task) })
+      return task
+    },
+  })
+}
+
+async function handlePresentOpen(ctx: Context, request: Request): Promise<Response> {
+  const query = new URL(request.url).searchParams
+  const id = query.get('sessionId')
+  const seq = query.get('seq')
+  const index = query.get('index')
+  if (!id || seq === null || index === null || !/^\d+$/.test(seq) || !/^\d+$/.test(index)
+    || !Number.isSafeInteger(Number(seq)) || !Number.isSafeInteger(Number(index))) {
+    return new Response('Invalid Presented file coordinates.', { status: 400 })
+  }
+  try {
+    request.signal.throwIfAborted()
+    const { session, target } = await ctx.sessionQuery.readEvent({
+      sessionId: id as SessionId, seq: Number(seq) as SessionSeq, before: 0, after: 0,
+    }, request.signal)
+    const file = target.type === 'deliverables/presented' && isPresentedData(target.data) ? target.data.files[Number(index)] : undefined
+    if (!isPresentedFile(file)) return new Response('Presented file not found in this Session result.', { status: 404 })
+    if (session.cwd === undefined) return new Response('Session workspace unavailable.', { status: 404 })
+    const root = await realpath(session.cwd)
+    const path = await realpath(resolve(root, file.path))
+    const within = relative(root, path)
+    if (isAbsolute(within) || within === '..' || within.startsWith(`..${sep}`)) {
+      return new Response('Presented file is outside the workspace.', { status: 403 })
+    }
+    if (!(await stat(path)).isFile()) return new Response('Presented path is not a file.', { status: 404 })
+    request.signal.throwIfAborted()
+    await ctx.sessionController.openWorkspacePath({ path }, request.signal)
+    return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } })
+  } catch (error: unknown) {
+    request.signal.throwIfAborted()
+    const missing = error instanceof Error && 'code' in error
+      && (error.code === 'SESSION_QUERY_SESSION_NOT_FOUND' || error.code === 'SESSION_QUERY_EVENT_NOT_FOUND'
+        || error.code === 'ENOENT' || error.code === 'ENOTDIR')
+    return new Response('Presented workspace file unavailable.', { status: missing ? 404 : 500 })
+  }
+}

+ 52 - 0
packages/client/ui-deliverables/src/presented.ts

@@ -0,0 +1,52 @@
+/** Validate declared workspace paths and address their native-open actions. */
+import type { PresentedFile } from '@deepseek-ai/dsh-tool-present/types'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
+
+/** Authenticated POST route for opening a workspace file on the Host desktop. */
+export const PRESENT_OPEN_PATH = '/api/present.open'
+
+/**
+ * Validate a file declaration read from a Session log.
+ * @param value - decoded durable data.
+ * @returns whether the declaration contains a path and optional description.
+ */
+export function isPresentedFile(value: unknown): value is PresentedFile {
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
+  const { path, description } = value as Record<string, unknown>
+  return typeof path === 'string' && path.trim().length > 0
+    && (description === undefined || typeof description === 'string')
+}
+
+/**
+ * Build authenticated coordinates for a declared file.
+ * @param sessionId - owning Session.
+ * @param seq - deliverables/presented event sequence.
+ * @param index - original index in the event's files array.
+ * @returns same-origin file action URL.
+ */
+export function presentedFileUrl(sessionId: SessionId, seq: number, index: number): string {
+  return `${PRESENT_OPEN_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}`
+}
+
+/**
+ * Validate a delivery event before reading its turn or file declarations.
+ * @param value - decoded durable event data.
+ * @returns whether the event identifies a turn, call, and file list.
+ */
+export function isPresentedData(value: unknown): value is { turn: number; callId: ToolCallId; files: unknown[] } {
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
+  const { turn, callId, files } = value as Record<string, unknown>
+  return typeof turn === 'number' && Number.isSafeInteger(turn) && turn >= 1
+    && typeof callId === 'string' && callId.length > 0 && Array.isArray(files)
+}
+
+/**
+ * Trailing path segment, the part that identifies the file at a glance.
+ * @param path - Slash- or backslash-separated path.
+ * @returns The final segment, or the whole string when separator-free.
+ */
+export function basename(path: string): string {
+  const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
+  return at === -1 ? path : path.slice(at + 1)
+}

+ 63 - 0
packages/client/ui-deliverables/tests/present-open.client.spec.ts

@@ -0,0 +1,63 @@
+/** Delivery gestures share pending state, report failures, and cancel with the plugin. */
+import { afterEach, expect, it, vi } from 'vitest'
+import { SessionId } from '@deepseek-ai/dsh-session/types'
+import { PresentedOpenController } from '../src/client/present-open.ts'
+
+afterEach(() => { vi.unstubAllGlobals() })
+
+const id = SessionId('fork')
+const url = '/api/present.open?sessionId=fork&seq=2&index=1'
+
+it('coalesces concurrent card and mention gestures, then allows another open', async () => {
+  const reply = Promise.withResolvers<Response>()
+  const fetcher = vi.fn().mockReturnValue(reply.promise)
+  vi.stubGlobal('fetch', fetcher)
+  const controller = new PresentedOpenController()
+  const first = controller.open(id, 2, 1)
+  await controller.open(id, 2, 1)
+  expect(fetcher).toHaveBeenCalledTimes(1)
+  expect(fetcher).toHaveBeenCalledWith(url, { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal })
+  expect(controller.state.getSnapshot()[url]).toBe('opening')
+  reply.resolve(new Response(null, { status: 204 }))
+  await first
+  expect(controller.state.getSnapshot()[url]).toBe('opened')
+  await controller.open(id, 2, 1)
+  expect(fetcher).toHaveBeenCalledTimes(2)
+  await controller.dispose()
+})
+
+it.each(['http', 'network'])('publishes retryable %s failures', async (failure) => {
+  const fetcher = vi.fn()
+  if (failure === 'http') fetcher.mockResolvedValueOnce(new Response(null, { status: 500 }))
+  else fetcher.mockRejectedValueOnce(new Error('offline'))
+  fetcher.mockResolvedValue(new Response(null, { status: 204 }))
+  vi.stubGlobal('fetch', fetcher)
+  const controller = new PresentedOpenController()
+  await controller.open(id, 2, 1)
+  expect(controller.state.getSnapshot()[url]).toBe('error')
+  await controller.open(id, 2, 1)
+  expect(controller.state.getSnapshot()[url]).toBe('opened')
+  await controller.dispose()
+})
+
+it('awaits cancellation and prevents late state publication or new requests after disposal', async () => {
+  const aborted = Promise.withResolvers<undefined>()
+  const release = Promise.withResolvers<Response>()
+  const fetcher = vi.fn((_url: string, { signal }: RequestInit) => {
+    signal!.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true })
+    return release.promise
+  })
+  vi.stubGlobal('fetch', fetcher)
+  const controller = new PresentedOpenController()
+  const open = controller.open(id, 2, 1)
+  const state = controller.state.getSnapshot()
+  let disposed = false
+  const disposal = controller.dispose().then(() => { disposed = true })
+  await aborted.promise
+  expect(disposed).toBe(false)
+  release.resolve(new Response(null, { status: 204 }))
+  await Promise.all([open, disposal])
+  expect(controller.state.getSnapshot()).toBe(state)
+  await controller.open(id, 2, 1)
+  expect(fetcher).toHaveBeenCalledOnce()
+})

+ 178 - 0
packages/client/ui-deliverables/tests/present-open.host.spec.ts

@@ -0,0 +1,178 @@
+/** Native delivery actions resolve the viewed Session's current workspace files. */
+import { mkdtemp, rm, readFile, writeFile, mkdir, realpath, symlink, unlink } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Context } from '@deepseek-ai/cordis'
+import { HostConnectionService } from '@deepseek-ai/dsh-client-connection'
+import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
+import type { SessionEventReadRequest } from '@deepseek-ai/dsh-session-query'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { registerPresentOpen } from '../src/present-open.ts'
+import { presentedFileUrl, PRESENT_OPEN_PATH } from '../src/presented.ts'
+
+const cleanups: Array<() => Promise<unknown>> = []
+afterEach(async () => {
+  for (const cleanup of cleanups.reverse()) await cleanup()
+  cleanups.length = 0
+  vi.restoreAllMocks()
+})
+
+async function fixture() {
+  const root = await mkdtemp(join(tmpdir(), 'dsh-present-open-'))
+  cleanups.push(() => rm(root, { recursive: true, force: true }))
+  const cwd = join(root, 'workspace')
+  await mkdir(cwd)
+  const file = { path: '日记模板.docx' }
+  await writeFile(join(cwd, file.path), Uint8Array.of(80, 75, 0, 255))
+  const ctx = new Context()
+  cleanups.push(() => ctx.fiber.dispose())
+  const session: { cwd?: string } = { cwd }
+  const readEvent = vi.fn(async (request: SessionEventReadRequest) => {
+    if (request.sessionId !== 'owner') throw new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND')
+    if (request.seq !== 7) throw new SessionQueryError('missing', 'SESSION_QUERY_EVENT_NOT_FOUND')
+    return { session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'present-call', files: [file] } } as SessionEvent }
+  })
+  ctx.provide('sessionQuery', { readEvent } as never)
+  const opener = vi.fn(async (_request: { path: string }, _signal: AbortSignal) => ({ opened: true as const }))
+  ctx.provide('sessionController', { openWorkspacePath: opener } as never)
+  const connection = new HostConnectionService(ctx, [], {} as BrowserAuth)
+  const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController'], apply: registerPresentOpen })
+  await fiber
+  const handler = connection.createSharedFetchHandler('/api')
+  const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => handler.fetch(new Request(
+    `http://localhost${PRESENT_OPEN_PATH}${query}`, { method: 'POST', signal: signal ?? null },
+  ))
+  return { root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler }
+}
+
+describe('Presented workspace file native open route', () => {
+  it('opens the source itself with current bytes and leaves it intact at disposal', async () => {
+    const { cwd, open, file, fiber, opener, handler, ctx } = await fixture()
+    const source = await realpath(join(cwd, file.path))
+    expect(presentedFileUrl(SessionId('owner'), 7, 0)).toBe(`${PRESENT_OPEN_PATH}?sessionId=owner&seq=7&index=0`)
+    expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`))).status).toBe(404)
+    expect((await handler.fetch(new Request('http://localhost/api/present.download?sessionId=owner&seq=7&index=0'))).status).toBe(404)
+    for (const contents of ['current source', 'edited source']) {
+      await writeFile(source, contents)
+      const response = await open()
+      expect(response.status).toBe(204)
+      expect(response.headers.get('content-disposition')).toBeNull()
+      expect(response.headers.get('cache-control')).toBe('no-store')
+      expect(opener.mock.lastCall?.[0].path).toBe(source)
+      expect(await readFile(opener.mock.lastCall![0].path, 'utf8')).toBe(contents)
+    }
+    expect(ctx.get('attachments')).toBeUndefined()
+    await fiber.dispose()
+    expect(await readFile(source, 'utf8')).toBe('edited source')
+    expect((await open()).status).toBe(404)
+  })
+
+  it('resolves inherited declarations in the viewed fork workspace', async () => {
+    const { root, file, readEvent, session, open, opener } = await fixture()
+    const fork = join(root, 'fork')
+    await mkdir(fork)
+    await writeFile(join(fork, file.path), 'child source')
+    readEvent.mockResolvedValueOnce({ session: { ...session, cwd: fork }, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'inherited', files: [file] } } as SessionEvent })
+    expect((await open('?sessionId=fork&seq=7&index=0')).status).toBe(204)
+    expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(fork, file.path)))
+  })
+
+  it.each(['', '?seq=7&index=0', '?sessionId=owner&index=0', '?sessionId=owner&seq=7',
+    '?sessionId=owner&seq=-1&index=0', '?sessionId=owner&seq=7&index=0.1',
+    '?sessionId=owner&seq=9007199254740992&index=0', '?sessionId=owner&seq=7&index=9007199254740992',
+  ])('rejects invalid coordinates before reading: %s', async (query) => {
+    const { open, readEvent } = await fixture()
+    expect((await open(query)).status).toBe(400)
+    expect(readEvent).not.toHaveBeenCalled()
+  })
+
+  it('refuses unrelated Sessions, absent events, and undeclared file indices', async () => {
+    const { open, readEvent, session, opener } = await fixture()
+    expect((await open('?sessionId=other&seq=7&index=0')).status).toBe(404)
+    expect((await open('?sessionId=owner&seq=8&index=0')).status).toBe(404)
+    expect((await open('?sessionId=owner&seq=7&index=1')).status).toBe(404)
+    readEvent.mockResolvedValueOnce({ session, target: { type: 'turn/start' } as SessionEvent })
+    expect((await open()).status).toBe(404)
+    expect(opener).not.toHaveBeenCalled()
+  })
+
+  it.each([null, [], 'invalid', {}, { turn: 1, callId: 'call', files: null },
+    { turn: 1, callId: 'call', files: [null] }, { turn: 1, callId: 'call', files: [{ path: '' }] },
+    { turn: 1, callId: 'call', files: [{ path: 'a', description: 1 }] },
+  ])('refuses malformed recorded delivery data: %j', async (data) => {
+    const { open, readEvent, session, opener } = await fixture()
+    readEvent.mockResolvedValueOnce({ session, target: { type: 'deliverables/presented', data } as unknown as SessionEvent })
+    expect((await open()).status).toBe(404)
+    expect(opener).not.toHaveBeenCalled()
+  })
+
+  it('reports removed files, directories, and absent workspaces without launching', async () => {
+    const { cwd, file, session, open, opener } = await fixture()
+    await unlink(join(cwd, file.path))
+    expect((await open()).status).toBe(404)
+    file.path = '.'
+    expect((await open()).status).toBe(404)
+    delete session.cwd
+    expect((await open()).status).toBe(404)
+    expect(opener).not.toHaveBeenCalled()
+  })
+
+  it('refuses traversal and a source replaced by a symlink outside the workspace', async () => {
+    const { root, cwd, file, open, opener } = await fixture()
+    const outside = join(root, 'outside.txt')
+    await writeFile(outside, 'outside')
+    const source = join(cwd, file.path)
+    await unlink(source)
+    await symlink(outside, source)
+    expect((await open()).status).toBe(403)
+    file.path = '../outside.txt'
+    expect((await open()).status).toBe(403)
+    file.path = outside
+    expect((await open()).status).toBe(403)
+    expect(opener).not.toHaveBeenCalled()
+  })
+
+  it('reports query and launcher failures without leaking Host paths and allows retry', async () => {
+    const { open, readEvent, opener } = await fixture()
+    readEvent.mockRejectedValueOnce(new SessionQueryError('corrupt', 'SESSION_QUERY_CORRUPT_SESSION'))
+    expect((await open()).status).toBe(500)
+    opener.mockRejectedValueOnce(new Error('/private/host/path'))
+    const response = await open()
+    expect(response.status).toBe(500)
+    expect(await response.text()).not.toContain('/private/host/path')
+    expect((await open()).status).toBe(204)
+  })
+
+  it('honors cancellation before lookup', async () => {
+    const { open, readEvent } = await fixture()
+    const controller = new AbortController()
+    controller.abort(new Error('cancelled'))
+    await expect(open(undefined, controller.signal)).rejects.toThrow('cancelled')
+    expect(readEvent).not.toHaveBeenCalled()
+  })
+
+  it('disposal aborts and awaits a pending native launch', async () => {
+    const entered = Promise.withResolvers<undefined>()
+    const aborted = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    const { open, fiber, opener } = await fixture()
+    opener.mockImplementation(async (_request, signal) => {
+      signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true })
+      entered.resolve(undefined)
+      await release.promise
+      signal.throwIfAborted()
+      return { opened: true }
+    })
+    const request = open().catch((error: unknown) => error)
+    await entered.promise
+    let disposed = false
+    const disposal = fiber.dispose().then(() => { disposed = true })
+    await aborted.promise
+    expect(disposed).toBe(false)
+    release.resolve(undefined)
+    await Promise.all([request, disposal])
+  })
+})

+ 58 - 0
packages/client/ui-deliverables/tests/present-row.client.spec.tsx

@@ -0,0 +1,58 @@
+// @vitest-environment jsdom
+/** Present UI derives statuses and details from durable tool records. */
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { PresentRow } from '../src/client/PresentRow.tsx'
+import { en } from '../src/client/locales.ts'
+
+afterEach(cleanup)
+type Props = Parameters<typeof PresentRow>[0]
+const running: RunningToolCall = { callId: 'p', name: 'present', argsRaw: '{"files":[{"path":"report.txt"}]}', turn: 1, step: 1, time: 1, subCalls: [] }
+const settled: ToolResultNode = { kind: 'tool-result', seq: 2, time: 2, callId: 'p', call: { name: 'present', argsRaw: running.argsRaw }, callTime: 1, content: [{ type: 'text', text: 'Presented report.txt (4 bytes)' }], isError: false, subCalls: [] }
+function props(block: Props['block'], inspect?: () => void): Props {
+  return { block, callId: 'p', toolName: 'present', openFile: vi.fn(), inspect, t: makeTranslate(en) } as Props
+}
+
+it('discloses the saved result and offers call inspection', () => {
+  const inspect = vi.fn()
+  const view = render(<PresentRow {...props(settled, inspect)} />)
+  expect(view.getByText('Delivered')).toBeTruthy()
+  expect(view.getByText('report.txt')).toBeTruthy()
+  expect(view.queryByText('Presented report.txt (4 bytes)')).toBeNull()
+  const row = view.getByRole('button')
+  fireEvent.keyDown(row, { key: 'Enter' })
+  expect(view.getByText('Presented report.txt (4 bytes)')).toBeTruthy()
+  fireEvent.click(view.getByRole('button', { name: 'Inspect call' }))
+  expect(inspect).toHaveBeenCalledOnce()
+  fireEvent.click(row)
+  expect(row.getAttribute('aria-expanded')).toBe('false')
+})
+
+it.each([
+  [running, 'running', 'Delivering'],
+  [{ ...settled, isError: true }, 'error', 'Delivery failed'],
+  [{ ...settled, error: { name: 'Interrupted', code: 'interrupted' } }, 'stopped', 'Interrupted'],
+] as const)('renders call lifecycle without claiming failed delivery', (block, state, label) => {
+  const view = render(<PresentRow {...props(block)} />)
+  expect(view.container.querySelector('[data-tool="present"]')?.getAttribute('data-state')).toBe(state)
+  expect(view.getByText(label)).toBeTruthy()
+  expect(view.queryByText('Delivered')).toBeNull()
+})
+
+it.each(['', '{', 'null', '[]', '{"files":null}', '{"files":[null,{},1,{"path":false},{"path":"good.txt"}]}'])('tolerates partial arguments %s', (argsRaw) => {
+  const view = render(<PresentRow {...props({ ...running, argsRaw })} />)
+  expect(view.getByText('Delivering')).toBeTruthy()
+  expect(view.queryByRole('button')).toBeNull()
+})
+
+it('shows orphaned error details and non-text results', () => {
+  const view = render(<PresentRow {...props({ ...settled, call: null, content: [], isError: true, error: { name: 'Missing', code: 'missing' } })} />)
+  fireEvent.click(view.getByRole('button'))
+  expect(view.getByText('Missing: missing')).toBeTruthy()
+  view.rerender(<PresentRow {...props({ ...settled, content: [{ type: 'reasoning', text: 'Recorded detail' }] })} />)
+  expect(view.container.textContent).toContain('Recorded detail')
+  view.rerender(<PresentRow {...props({ ...settled, content: [] })} />)
+  expect(view.queryByRole('button')).toBeNull()
+})

+ 112 - 7
packages/client/ui-deliverables/tests/produced-files.client.spec.tsx

@@ -21,15 +21,26 @@ import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
 import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
 import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
+import { Deliverables, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx'
+import { PresentedOpenController } from '../src/client/present-open.ts'
 import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
 import {
-  basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles,
+  basename, deliverablesDefinition, presentedForClosing, producedFileMentions, producedForClosing, selectProducedFiles,
   type DeliverablesTurnData,
 } from '../src/client/turn-deliverables.ts'
 import { apply, inject } from '../src/client/index.ts'
 import { en, zh } from '../src/client/locales.ts'
+import { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 
+function openProps(controller = new PresentedOpenController()) {
+  return {
+    openPresented: vi.fn((...args: Parameters<PresentedOpenController['open']>) => controller.open(...args)),
+    usePresentedOpen: <T,>(select: (state: ReturnType<typeof controller.state.getSnapshot>) => T): T =>
+      select(controller.state.getSnapshot()),
+  }
+}
+
 afterEach(() => {
   cleanup()
   vi.restoreAllMocks()
@@ -368,6 +379,10 @@ describe('produced-file Turn data', () => {
     ))
       .toThrow('deliverables start requires turn/start')
     expect(deliverablesDefinition.update(context, unrelated)).toBe(state)
+    for (const files of [[], [null, { path: '' }]]) {
+      const declaration = matched(at(3, 'deliverables/presented', { turn: 1, callId: 'present', files }), 'update')
+      expect(deliverablesDefinition.update(context, declaration)).toBe(state)
+    }
   })
 
   it('replays a tail page once prepend supplies its missing Turn start', () => {
@@ -494,7 +509,7 @@ describe('plugin registration', () => {
     // The owning view's child declaration, stood up by a bench root entry.
     ctx.slots.register({
       name: 'root',
-      children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
+      children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' }, 'tool.call.toolview': { kind: 'keyed', scope: 'session' } },
     } as never, () => null)
     // ui-theme's Appearance row binds a durable scope through these two.
     const session = {
@@ -513,9 +528,8 @@ describe('plugin registration', () => {
     await fiber.await()
     const [entry] = ctx.slots.entries('conversation.chat.turnTail')
     expect(entry).toBeDefined()
-    // The row needs no injected Host capability: it hands a path to its owner
-    // and nothing in it reaches the local machine.
-    expect(entry?.inject).toBeUndefined()
+    expect(ctx.slots.entries('tool.call.toolview')).toHaveLength(1)
+    expect(entry?.inject).toBeDefined()
 
     // The prose face is live while the plugin is: a produced turn yields a
     // resolver whose matches open through the owner-supplied opener.
@@ -526,15 +540,106 @@ describe('plugin registration', () => {
       (path) => { opened.push(path) },
     )
     const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions')
-    const mentions = service?.forClosing(owner)
+    const mentions = service?.forClosing(owner, SessionId('viewed-session'))
     mentions?.resolve('report.html')?.open()
     expect(opened).toEqual(['site/report.html'])
+    const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
+    vi.stubGlobal('fetch', fetcher)
+    const delivered = tailOwner({ produced: [], presented: [{ path: 'report.docx', seq: 2, index: 0 }] }, 3)
+    service?.forClosing(delivered, SessionId('child-session'))?.resolve('report.docx')?.open()
+    expect(fetcher).toHaveBeenCalledWith('/api/present.open?sessionId=child-session&seq=2&index=0', { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal })
+    const face = entry!.inject!(SessionId('child-session') as never) as unknown as DeliverablesInjected
+    await face.openPresented(SessionId('child-session'), 2, 0)
+    expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened')
     // A turn that produced nothing yields no vocabulary at all.
-    expect(service?.forClosing(tailOwner(undefined, 2))).toBeUndefined()
+    expect(service?.forClosing(tailOwner(undefined, 2), SessionId('viewed-session'))).toBeUndefined()
 
     await fiber.dispose()
     expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0)
+    expect(ctx.slots.entries('tool.call.toolview')).toHaveLength(0)
     // Fiber teardown retracts the service: the consumer's ctx.get sees the off state.
     expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined()
   })
 })
+
+
+describe('presented files', () => {
+  const file = (path = 'report.docx') => ({ path })
+
+  it('replays deliveries without mutation calls, preserves indices, and isolates turns', () => {
+    const value = assembler([
+      at(1, 'turn/start', { turn: 1 }),
+      at(2, 'deliverables/presented', { turn: 1, callId: 'nested', files: [null, { ...file(), description: 'Final report' }] }),
+      at(3, 'deliverables/presented', { turn: 1, callId: 'again', files: [{ ...file(), description: 'Updated report' }] }),
+      at(4, 'turn/end', { turn: 1 }),
+      at(5, 'turn/start', { turn: 2 }),
+    ])
+    const first = presentedForClosing(tailOwner(deliverablesOf(value), 3))
+    expect(first).toMatchObject([{ path: 'report.docx', seq: 2, index: 1, description: 'Final report' }])
+    expect(presentedForClosing(tailOwner(deliverablesOf(value), 4)))
+      .toMatchObject([{ path: 'report.docx', seq: 3, description: 'Updated report' }])
+    expect(selectDeliverables(tailOwner(deliverablesOf(value, 2), 9))).toBeNull()
+  })
+
+  it('uses the viewed fork Session in every open action and retains all delivered files', () => {
+    const value = assembler([
+      at(1, 'turn/start', { turn: 1 }),
+      at(2, 'deliverables/presented', { turn: 1, callId: 'nested', files: Array.from({ length: 8 }, (_, i) => file(`report-${i}.docx`)) }),
+    ])
+    const owner = tailOwner(deliverablesOf(value), 3)
+    const matched = selectDeliverables(owner)!
+    const props = openProps()
+    props.openPresented.mockResolvedValue(undefined)
+    const view = render(<Deliverables {...props} matched={matched} openFile={owner.openFile} sessionId={SessionId('child-session')} t={makeTranslate(en)} />)
+    expect(view.getAllByRole('button')).toHaveLength(8)
+    expect(view.queryByRole('link')).toBeNull()
+    fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in default app' }))
+    expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0)
+    expect(view.queryByText('Produced')).toBeNull()
+  })
+})
+
+
+it.each([null, [], 'invalid'])('declines non-object delivery data: %j', (data) => {
+  expect(deliverablesDefinition.match(at(1, 'deliverables/presented', data).event)).toBeNull()
+})
+
+it.each([{}, { turn: '1', callId: 'bad', files: [] },
+  { turn: 1.5, callId: 'bad', files: [] }, { turn: 0, callId: 'bad', files: [] },
+  { turn: 1, files: [] }, { turn: 1, callId: '', files: [] }, { turn: 1, callId: 'bad', files: null },
+])('ignores malformed delivery data and keeps the existing produced row: %j', (data) => {
+  const value = assembler([
+    at(1, 'turn/start', { turn: 1 }),
+    call(2, 'write-a', 'write', { file_path: 'a.txt', content: 'a' }),
+    result(3, 'write-a'),
+    at(4, 'deliverables/presented', data),
+  ])
+  const owner = tailOwner(deliverablesOf(value), 5)
+  const matched = selectDeliverables(owner)!
+  const view = render(<Deliverables {...openProps()} matched={matched} openFile={owner.openFile} sessionId={SessionId('session')} t={makeTranslate(en)} />)
+  expect(view.getByText('Produced')).toBeTruthy()
+  expect(view.queryByText('Deliverables')).toBeNull()
+})
+
+it('shows file metadata and descriptions without hiding extensionless deliveries', () => {
+  const view = render(<Deliverables {...openProps()} matched={{ produced: [], presented: [
+    { path: 'out/report.txt', description: 'Quarterly summary', seq: 2, index: 0 },
+    { path: 'LICENSE', seq: 2, index: 1 },
+  ] }} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />)
+  expect(view.getByText('Quarterly summary')).toBeTruthy()
+  expect(view.getByText('TXT')).toBeTruthy()
+  expect(view.getByText('File')).toBeTruthy()
+  expect(view.getByRole('button', { name: 'Open out/report.txt in default app' }).getAttribute('title')).toBe('Open out/report.txt in default app')
+})
+
+
+it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits retries after failure', (phase) => {
+  const controller = new PresentedOpenController()
+  controller.state.set({ '/api/present.open?sessionId=session&seq=2&index=0': phase })
+  const props = openProps(controller)
+  const view = render(<Deliverables {...props} matched={{ produced: [], presented: [
+    { path: 'report.txt', seq: 2, index: 0 },
+  ] }} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />)
+  expect(view.getByRole('status').textContent).toBe(en[`presented.${phase}`])
+  expect((view.getByRole('button') as HTMLButtonElement).disabled).toBe(phase === 'opening')
+})

+ 3 - 0
packages/client/ui-deliverables/tests/prompt.client.spec.ts → packages/client/ui-deliverables/tests/prompt.host.spec.ts

@@ -16,6 +16,9 @@ describe('ui-deliverables node plugin', () => {
   it('registers final-response file-reference guidance only while mounted', async () => {
     ctx = new Context()
     await ctx.plugin(SystemPrompt, { personaPrefix: '' })
+    ctx.provide('connection', { fetch: { register: () => () => {} } } as never)
+    ctx.provide('sessionQuery', {} as never)
+    ctx.provide('sessionController', {} as never)
     const mounted = ctx.plugin({ apply, inject })
     await mounted.await()
 

+ 57 - 0
packages/client/ui-deliverables/tsconfig.client.json

@@ -0,0 +1,57 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types",
+    "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
+  },
+  "include": [
+    "src/client",
+    "src/presented.ts",
+    "src/css-modules.d.ts"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../api/remotes/tsconfig.client.json"
+    },
+    {
+      "path": "../connection/tsconfig.client.json"
+    },
+    {
+      "path": "../locale"
+    },
+    {
+      "path": "../store"
+    },
+    {
+      "path": "../ui-conversation"
+    },
+    {
+      "path": "../ui-chat"
+    },
+    {
+      "path": "../ui-renderer"
+    },
+    {
+      "path": "../ui-primitives"
+    },
+    {
+      "path": "../ui-slots"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../ui-tool"
+    },
+    {
+      "path": "../../fs/tool-present"
+    }
+  ]
+}

+ 39 - 0
packages/client/ui-deliverables/tsconfig.host.json

@@ -0,0 +1,39 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types",
+    "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
+  },
+  "files": [
+    "src/index.ts",
+    "src/presented.ts",
+    "src/present-open.ts"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../connection/tsconfig.host.json"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../../core/system-prompt"
+    },
+    {
+      "path": "../../session-query/session-query"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../../fs/tool-present"
+    },
+    {
+      "path": "../../api/session-controller/tsconfig.host.json"
+    }
+  ]
+}

+ 3 - 40
packages/client/ui-deliverables/tsconfig.json

@@ -1,48 +1,11 @@
 {
-  "extends": "../../../tsconfig.base.client.json",
-  "compilerOptions": {
-    "rootDir": "src",
-    "outDir": "lib/types"
-  },
-  "include": [
-    "src"
-  ],
+  "files": [],
   "references": [
     {
-      "path": "../../api/remotes/tsconfig.client.json"
+      "path": "./tsconfig.host.json"
     },
     {
-      "path": "../../../vendor/cordis"
-    },
-    {
-      "path": "../connection/tsconfig.client.json"
-    },
-    {
-      "path": "../locale"
-    },
-    {
-      "path": "../store"
-    },
-    {
-      "path": "../ui-conversation"
-    },
-    {
-      "path": "../ui-chat"
-    },
-    {
-      "path": "../ui-renderer"
-    },
-    {
-      "path": "../ui-primitives"
-    },
-    {
-      "path": "../ui-slots"
-    },
-    {
-      "path": "../../core/system-prompt"
-    },
-    {
-      "path": "../../core/session"
+      "path": "./tsconfig.client.json"
     }
   ]
 }

+ 1 - 0
packages/core/session/src/known-event-types.ts

@@ -33,6 +33,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
   'compaction/prune',
   'compaction/start',
   'compaction/summary',
+  'deliverables/presented',
   'feedback/message-delete',
   'feedback/message-put',
   'feedback/record',

+ 1 - 1
packages/core/tools/tests/gen-tool-catalog.spec.ts

@@ -30,7 +30,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
       'cordis_inspect_query', 'cordis_inspect_self', 'cordis_run', 'cordis_stop',
       'cordis_undefine', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep',
       'interrupt_agent', 'interrupt_agent', 'job_kill', 'job_list', 'job_output',
-      'list_agents', 'list_agents', 'list_subagent_models', 'lsp', 'pwsh', 'pwsh', 'ralph',
+      'list_agents', 'list_agents', 'list_subagent_models', 'lsp', 'present', 'pwsh', 'pwsh', 'ralph',
       'read', 'read_image', 'run_code', 'schedule_create', 'schedule_delete',
       'schedule_list', 'send_message', 'send_message', 'session_event_read', 'session_event_search',
       'session_event_trace', 'session_search', 'session_trace', 'skill', 'spawn_teammate',

+ 8 - 7
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -174,7 +174,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n      { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:212',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:213',
   },
   {
     key: 'conversation.chat.commandview',
@@ -221,7 +221,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n      { name: \'conversation.chat.commandview\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:200',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:201',
   },
   {
     key: 'conversation.chat.node',
@@ -289,7 +289,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n      { name: \'conversation.chat.node\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:181',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:182',
   },
   {
     key: 'conversation.chat.turnTail',
@@ -332,11 +332,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     slotInject: '',
     declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-chat), so it exists while that entry is mounted',
     occupants: [
-      'client-ui-deliverables ProducedFiles',
+      'client-ui-deliverables Deliverables',
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n      { name: \'conversation.chat.turnTail\', select: owner => null },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:206',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:207',
   },
   {
     key: 'conversation.composer',
@@ -983,7 +983,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n      { name: \'conversation.message.images\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:194',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:195',
   },
   {
     key: 'conversation.session',
@@ -2576,11 +2576,12 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       'useProjection: UseProjection',
       'useTrajectory: UseTrajectory',
     ],
-    keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: ask_user_question, bash, cordis_define, cordis_run, cordis_stop, cordis_undefine, edit, glob, grep, read, read_image, skill, todo_write, web_fetch, web_search, write',
+    keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: ask_user_question, bash, cordis_define, cordis_run, cordis_stop, cordis_undefine, edit, glob, grep, present, read, read_image, skill, todo_write, web_fetch, web_search, write',
     hookContext: '',
     slotInject: '',
     declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-tool), so it exists while that entry is mounted',
     occupants: [
+      'client-ui-deliverables PresentRow key \'present\'',
       'client-ui-skill SkillRow key \'skill\'',
       'client-ui-tool AskQuestionRow key \'ask_user_question\'',
       'client-ui-tool BashRow key \'bash\'',

+ 2 - 2
packages/fs/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/fs/README.md
-README.md: c8cfc5f8d5dd5d225149abbf192d23df1b51cf71
-README.zh.md: 9596f72e76241de43072ee590c141b043af6a861
+README.md: dd3e6f1fab3a8439dd8e2ff15f854e2872549eef
+README.zh.md: 6e0b34b2b9ca7a8076f47c4c3302c67b91e200da

+ 2 - 1
packages/fs/README.md

@@ -22,7 +22,7 @@ The `fs/` group gives agents durable, policy-governed access to files: the `ctx.
 <a id="packages"></a>
 ## Packages
 
-Seven packages plus the remote sibling `fs-e2b` play the filesystem roles; the subsystem reference owns the exhaustive contracts and the error taxonomy.
+Eight packages plus the remote sibling `fs-e2b` play the filesystem roles; the subsystem reference owns the exhaustive contracts and the error taxonomy.
 
 | Package | Role | ctx key |
 |---|---|---|
@@ -34,6 +34,7 @@ Seven packages plus the remote sibling `fs-e2b` play the filesystem roles; the s
 | [`tool-fs/`](tool-fs/README.md) | Model-facing `read`, `read_image`, `write`, and `edit` tools plus their executor | registers on `ctx.tools` |
 | [`tool-fs-search/`](tool-fs-search/README.md) | Model-facing `glob` and `grep` discovery tools backed by the packaged ripgrep binary | registers on `ctx.tools` |
 | [`tool-str-replace-editor/`](tool-str-replace-editor/README.md) | Standalone `str_replace_editor` tool: `view`, `create`, `str_replace`, and `insert` over `ctx.fs` | registers on `ctx.tools` |
+| [`tool-present/`](tool-present/README.md) | Explicit immutable snapshots of delivered files | registers on `ctx.tools` |
 
 The policy is a plugin, not a service the tools inject: removing it leaves the bare provider's unconditional mutation behavior instead of breaking the tools. The mode fence in `fs-sandbox` and the read-before-edit gate compose. `tool-fs-search` deliberately does not extend the provider contract — search is a process-backed ripgrep workflow, so filesystem backends stay free of a universal search API.
 

+ 2 - 1
packages/fs/README.zh.md

@@ -22,7 +22,7 @@ kind: "package-group"
 <a id="packages"></a>
 ## 包
 
-七个包加上远程同级 `fs-e2b` 承担文件系统角色;子系统参考文档拥有穷尽式约定与错误分类体系。
+八个包加上远程同级 `fs-e2b` 承担文件系统角色;子系统参考文档拥有穷尽式约定与错误分类体系。
 
 | 包 | 职责 | ctx 键 |
 |---|---|---|
@@ -34,6 +34,7 @@ kind: "package-group"
 | [`tool-fs/`](tool-fs/README.zh.md) | 面向模型的 `read`、`read_image`、`write` 与 `edit` 工具及其执行器 | 注册到 `ctx.tools` |
 | [`tool-fs-search/`](tool-fs-search/README.zh.md) | 由打包 ripgrep 二进制支持的面向模型 `glob` 与 `grep` 发现工具 | 注册到 `ctx.tools` |
 | [`tool-str-replace-editor/`](tool-str-replace-editor/README.zh.md) | 独立的 `str_replace_editor` 工具:基于 `ctx.fs` 的 `view`、`create`、`str_replace` 与 `insert` | 注册到 `ctx.tools` |
+| [`tool-present/`](tool-present/README.zh.md) | 显式保存交付文件的不可变快照 | 注册到 `ctx.tools` |
 
 策略是插件,不是工具注入的服务:移除它只会让工具回到裸提供方的无条件变更行为,而不会破坏工具。`fs-sandbox` 的模式围栏与编辑前读取门禁可以组合。`tool-fs-search` 有意不扩展提供方约定——搜索是由进程支持的 ripgrep 工作流,因此文件系统后端无需承担通用搜索 API。
 

+ 6 - 0
packages/fs/tool-present/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/fs/tool-present/README.md
+README.md: 6767bb3b8d8839ae776fdf441ef853192c5117a2
+README.zh.md: 8255e7be42de273d58d01ca9b4e108d4c65a585d

+ 102 - 0
packages/fs/tool-present/README.md

@@ -0,0 +1,102 @@
+---
+description: "Declare workspace files as deliverables with present; configuration, Session ownership, and source-file opening."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-tool-present
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Use `present` to declare final workspace files, including files created through shell commands. Users open the current source files in their default application. The tool records paths and optional descriptions without copying file contents.
+
+## 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 `standard`, `ptc`, and `cordis` agent presets mount this plugin. Call `present` with `files: [{ path, description? }]` after creating the files. Files must exist inside the Session workspace and be regular files. Missing files, directories, and paths outside the workspace fail the call.
+
+Mount it in an agent's Cordis composition with `tools`, `fs`, and the `turnBoundary` Session projection available:
+
+```yaml
+- name: '@deepseek-ai/dsh-tool-present'
+  config:
+    maxFiles: 8
+```
+
+| Field | Default | Meaning |
+|---|---|---|
+| `maxFiles` | `8` | Positive maximum file count per call |
+
+The file-count limit is validated at mount. The tool requires an agent Session with a workspace and an open turn. Delivery belongs to the calling Session; a parent must call `present` itself to declare files created by a subagent.
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+The tool resolves paths through the configured filesystem provider and checks workspace containment and regular-file metadata without reading contents. Successful final `tools/result` notifications append `deliverables/presented`, including nested calls. A later enclosing program failure does not revoke an already completed declaration. Blocked results publish none. Each plugin instance records only calls it executed; scoped tools with the same name cannot publish through another instance.
+
+The pure `./types` entry declares `PresentedFile` and the Session event without importing Host runtime code. The Web consumer validates persisted declarations before displaying or opening them. The event stores no Session ID, so forked history resolves relative paths against the viewed Session's workspace.
+
+**Runtime invariant:** No companion is published. Tool and event registrations are effect-owned, and the Session log owns file declarations; the plugin maintains no independent file-content store.
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+- [Filesystem subsystem](../../../docs/subsystems/filesystem.md) — provider paths and errors.
+- [Web deliverables](../../client/ui-deliverables/README.md) — source-file opening and cards.
+- [Delivery decision](../../../.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md) — Session ownership and required-on-read events.
+
+<a id="model-experience"></a>
+## Model Experience
+
+### present
+
+#### What the model sees
+
+The [present schema](../../../docs/tool-catalog.md#present) asks for existing workspace files: “Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.” Results report `Presented <path>` for each file; the program result and durable event contain paths and optional descriptions.
+
+#### Token effect
+
+One tool schema per mounted agent and one result line per delivered file. File bytes do not enter model messages.
+
+#### KV Cache effect
+
+The tool schema is static for the mount lifetime. Delivery result text extends the conversation without rewriting its prompt prefix.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- Path containment checks are best effort; they cannot atomically defend against a concurrent symlink replacement before the desktop application opens the file.
+- Edits change what opens. Deleted or moved source files cannot be opened from their declarations.
+- Session ZIP exports contain declarations, not file contents. Persistent delivery versions and copy-on-write storage are deferred.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>

+ 102 - 0
packages/fs/tool-present/README.zh.md

@@ -0,0 +1,102 @@
+---
+description: "通过 present 声明交付工作区文件;配置、Session 归属与源文件打开。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-tool-present
+
+[English](README.md) | 中文
+
+## 概述
+
+使用 `present` 声明交付最终工作区文件,包括通过 shell 命令创建的文件。用户使用默认应用打开当前源文件。工具记录路径和可选说明,不复制文件内容。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [进一步探索](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用本包
+
+`standard`、`ptc` 与 `cordis` Agent preset 挂载本插件。创建文件后,以 `files: [{ path, description? }]` 调用 `present`。文件必须存在于 Session 工作区内,且为普通文件。文件缺失、为目录或路径位于工作区外时,调用失败。
+
+在 Agent 的 Cordis 组合中挂载,并提供 `tools`、`fs` 和 `turnBoundary` Session 投影:
+
+```yaml
+- name: '@deepseek-ai/dsh-tool-present'
+  config:
+    maxFiles: 8
+```
+
+| 字段 | 默认值 | 含义 |
+|---|---|---|
+| `maxFiles` | `8` | 每次调用的最大文件数,为正整数 |
+
+挂载时校验文件数量上限。工具要求 Agent Session 具有工作区和已开始的 turn。交付归调用方 Session 所有;父 Session 如需声明交付子 Agent 创建的文件,必须自行调用 `present`。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 理解实现
+
+<details>
+<summary>实现细节——点击展开</summary>
+
+工具通过配置的文件系统提供方解析路径,检查工作区包含关系和普通文件元数据,不读取内容。成功的最终 `tools/result` 通知追加 `deliverables/presented`,嵌套调用也适用。外层程序随后失败不会撤销已完成的声明。被阻止的结果不发布声明。每个插件实例只记录其实际执行的调用;同名作用域工具不能通过其他实例发布交付。
+
+纯 `./types` 入口声明 `PresentedFile` 与 Session 事件,不导入 Host 运行时代码。Web 消费方在展示或打开文件前校验持久声明。事件不保存 Session ID,因此 fork 历史中的相对路径按当前查看的 Session 工作区解析。
+
+**运行时不变式:** 不发布伴生入口。工具与事件注册归 effect 所有,Session 日志拥有文件声明;插件不维护独立的文件内容存储。
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## 进一步探索
+
+- [文件系统子系统](../../../docs/subsystems/filesystem.zh.md)——提供方路径与错误。
+- [Web 交付](../../client/ui-deliverables/README.zh.md)——源文件打开与卡片。
+- [交付决策](../../../.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md)——Session 归属与读取端必须识别的事件。
+
+<a id="model-experience"></a>
+## 模型体验
+
+### present
+
+#### 模型看到的内容
+
+[present schema](../../../docs/tool-catalog.zh.md#present)要求已有的工作区文件:“Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.” 每个文件的结果为 `Presented <path>`;程序结果和持久事件包含路径及可选说明。
+
+#### Token 影响
+
+每个挂载的 Agent 增加一个工具 schema,每个交付文件增加一行结果。文件字节不进入模型消息。
+
+#### KV Cache 影响
+
+工具 schema 在挂载期间保持静态。交付结果文本扩展对话,不重写提示词前缀。
+
+## 已知限制与延期工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- 路径包含关系检查是尽力而为的;无法原子防御桌面应用打开文件前发生的并发符号链接替换。
+- 编辑会改变打开的内容。源文件删除或移动后,无法通过原声明打开。
+- Session ZIP 导出包含声明,不包含文件内容。交付版本持久化和写时复制存储延期实现。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者的工作上下文——点击展开</summary>
+
+无。
+
+</details>

+ 60 - 0
packages/fs/tool-present/package.json

@@ -0,0 +1,60 @@
+{
+  "name": "@deepseek-ai/dsh-tool-present",
+  "description": "Explicit workspace file delivery declarations for the DeepSeek Harness",
+  "version": "0.1.5-alpha.1",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/fs/tool-present"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "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"
+  },
+  "license": "MIT",
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.js",
+    "lib/types/**/*.d.ts"
+  ],
+  "dependencies": {
+    "@deepseek-ai/schemastery": "workspace:^"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-fs": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-projection": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-fs": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-projection": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-agent-loop": "workspace:^",
+    "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
+    "@deepseek-ai/dsh-fs-local": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-scope": "workspace:^"
+  }
+}

+ 105 - 0
packages/fs/tool-present/src/index.ts

@@ -0,0 +1,105 @@
+/** Scoped tool that declares workspace file deliveries in their owning Session. */
+import type { Context } from '@deepseek-ai/cordis'
+import z from '@deepseek-ai/schemastery'
+import { FsError } from '@deepseek-ai/dsh-fs'
+import { defineTool, type ToolExecution } from '@deepseek-ai/dsh-tools'
+import type {} from '@deepseek-ai/dsh-agent'
+import type {} from '@deepseek-ai/dsh-session-projection'
+import type { Session } from '@deepseek-ai/dsh-session'
+import type { PresentedFile } from './types.ts'
+
+/** Stable Loader identity. */
+export const name = 'tool-present'
+
+/** Per-call delivery limit. */
+export interface Config {
+  /** Maximum number of files in one call. */
+  maxFiles: number
+}
+
+/** Validated delivery limit. */
+export const Config: z<Config> = z.object({
+  maxFiles: z.number().default(8),
+})
+
+/** Services used by the scoped delivery tool. */
+export const inject = ['tools', 'fs', 'sessionProjections']
+
+/**
+ * Register present with durable file references in its tool result.
+ * @param ctx - agent-scoped services.
+ * @param config - maximum files per call.
+ */
+export function apply(ctx: Context, config: Config): void {
+  if (!Number.isSafeInteger(config.maxFiles) || config.maxFiles < 1) {
+    throw new Error('present requires a positive integer maxFiles')
+  }
+  const pending = new WeakMap<ToolExecution, { session: Session; turn: number; files: PresentedFile[] }>()
+  ctx.tools.register(defineTool({
+    name: 'present',
+    description: 'Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.',
+    parameters: {
+      files: {
+        type: 'array', required: true,
+        items: {
+          type: 'object', additionalProperties: false,
+          properties: {
+            path: { type: 'string', required: true, description: 'Path of an existing file inside the workspace.' },
+            description: { type: 'string', description: 'Brief description for the user.' },
+          },
+        },
+      },
+    },
+    output: {
+      schema: {
+        type: 'object', additionalProperties: false,
+        properties: {
+          turn: { type: 'integer', required: true },
+          files: {
+            type: 'array', required: true,
+            items: {
+              type: 'object', additionalProperties: false,
+              properties: {
+                path: { type: 'string', required: true },
+                description: { type: 'string' },
+              },
+            },
+          },
+        },
+      },
+      render: (_args, value) => [{ type: 'text', text: value.files.map(file => `Presented ${file.path}`).join('\n') }],
+    },
+    async execute(args, exec) {
+      if (exec.agent === undefined) throw new Error('present requires an agent Session')
+      const boundary = ctx.sessionProjections.stateOf(exec.agent.session, 'turnBoundary')
+      if (boundary === undefined || boundary.openTurnStartSeq === null) throw new Error('present requires an open turn')
+      if (args.files.length === 0 || args.files.length > config.maxFiles) throw new Error(`present accepts 1 to ${config.maxFiles} files`)
+      const cwd = exec.agent.session.header.cwd
+      if (cwd === undefined) throw new Error('present requires a workspace')
+      const options = { cwd, signal: exec.signal }
+      const root = await ctx.fs.resolve('.', options)
+      const files: PresentedFile[] = []
+      for (const file of args.files) {
+        if (file.path.trim().length === 0) throw new Error('present requires a non-empty file path')
+        const target = await ctx.fs.resolve(file.path, options)
+        if (!ctx.fs.contains(root, target)) throw new Error(`Cannot present ${file.path}: outside the workspace`)
+        const info = await ctx.fs.stat(target, exec.signal)
+        if (info === undefined) throw new FsError(`Cannot present ${file.path}: file not found. Check the path, create the file if needed, and retry.`, 'FS_NOT_FOUND')
+        if (info.type !== 'file') throw new Error(`Cannot present ${file.path}: not a regular file`)
+        files.push({ ...file })
+      }
+      exec.signal.throwIfAborted()
+      pending.set(exec, { session: exec.agent.session, turn: boundary.lastTurn, files })
+      return { turn: boundary.lastTurn, files }
+    },
+  }))
+  ctx.on('tools/result', (exec, result) => {
+    const delivery = pending.get(exec)
+    pending.delete(exec)
+    if (delivery === undefined || result.isError) return
+    const { session, turn, files } = delivery
+    session.append('deliverables/presented', {
+      turn, callId: exec.callId, files,
+    })
+  })
+}

+ 17 - 0
packages/fs/tool-present/src/types.ts

@@ -0,0 +1,17 @@
+/** Durable file deliveries produced by the present tool. */
+import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
+
+/** A declared workspace file whose current contents remain at its source path. */
+export interface PresentedFile {
+  /** Original workspace path. */
+  path: string
+  /** Optional description supplied by the model. */
+  description?: string
+}
+
+declare module '@deepseek-ai/dsh-session/types' {
+  interface SessionEventMap {
+    /** Declared workspace files from a successful final present result, including nested calls. */
+    'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] }
+  }
+}

+ 58 - 0
packages/fs/tool-present/tests/built-errors.e2e.ts

@@ -0,0 +1,58 @@
+/** Built tool and runtime bundles must preserve their shared structured error classes. */
+import { execFile } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { promisify } from 'node:util'
+import { expect, it } from 'vitest'
+
+const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
+const bundle = fileURLToPath(new URL('../lib/index.js', import.meta.url))
+const execFileAsync = promisify(execFile)
+const probe = String.raw`
+import assert from 'node:assert/strict'
+import { Context } from './vendor/cordis/lib/index.js'
+import AgentRegistry from './packages/core/agent/lib/index.js'
+import LocalFileSystem from './packages/fs/fs-local/lib/index.js'
+import SystemPrompt from './packages/core/system-prompt/lib/index.js'
+import ToolRuntime from './packages/core/tools/lib/index.js'
+import { Session, SESSION_FORMAT_VERSION } from './packages/core/session/lib/index.js'
+import * as Present from './packages/fs/tool-present/lib/index.js'
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+const root = await mkdtemp(join(tmpdir(), 'present-built-'))
+const ctx = new Context()
+try {
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRuntime)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(LocalFileSystem, { cwd: root })
+  ctx.provide('sessionProjections', { stateOf() { return { openTurnStartSeq: 1, lastTurn: 1 } } })
+  await ctx.plugin(Present, { maxFiles: 2 })
+  const scope = ctx.plugin(() => {})
+  const session = Session.create('built-present', [], { version: SESSION_FORMAT_VERSION, id: 'built-present', createdAt: 0, cwd: root, isSeeded: false })
+  const owner = { id: 'built-present', session, ctx: scope.ctx, options: {}, status: 'idle' }
+  ctx.agents.register(owner)
+  const events = []
+  ctx.on('tools/result', (_exec, result) => events.push(result))
+  let n = 0
+  for (const [files, code] of [[[{ path: 'missing' }], 'FS_NOT_FOUND'], [[{ path: 42 }], 'INVALID_ARGS']]) {
+    const result = await ctx.tools.execute({ signal: new AbortController().signal, name: 'present', callId: 'call-' + (++n), arguments: { files }, agent: owner })
+    assert.equal(result.isError, true)
+    assert.equal(result.error.info.code, code)
+    assert.equal(events.at(-1).error.info.code, code)
+    console.log('built ToolRuntime result preserved ' + code)
+  }
+} finally {
+  try { await ctx.fiber.dispose() }
+  finally { await rm(root, { recursive: true, force: true }) }
+}
+`
+
+it.skipIf(!existsSync(bundle))('preserves present error codes through built ToolRuntime results', { retry: 0 }, async ({ signal }) => {
+  const { stdout } = await execFileAsync(process.execPath, ['--input-type=module', '-e', probe], { cwd: repoRoot, signal })
+  expect(stdout.trim().split('\n')).toEqual([
+    'built ToolRuntime result preserved FS_NOT_FOUND',
+    'built ToolRuntime result preserved INVALID_ARGS',
+  ])
+})

+ 167 - 0
packages/fs/tool-present/tests/present.spec.ts

@@ -0,0 +1,167 @@
+/** Explicit deliveries commit only after a successful final tool result. */
+import { mkdtemp, rm, writeFile, symlink } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
+import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
+import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
+import { createScope, type Scope } from '@deepseek-ai/dsh-scope'
+import { ToolCallId } from '@deepseek-ai/dsh-llm'
+import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRuntime, { defineTool } from '@deepseek-ai/dsh-tools'
+import type { PresentedFile } from '../src/types.ts'
+import * as Present from '../src/index.ts'
+
+const cleanups: Array<() => Promise<unknown>> = []
+let callNumber = 0
+afterEach(async () => {
+  for (const cleanup of cleanups.reverse()) await cleanup()
+  cleanups.length = 0
+  vi.restoreAllMocks()
+})
+async function agent(ctx: Context, cwd: string | undefined): Promise<Agent> {
+  const id = SessionId(`present-owner-${++callNumber}`)
+  let scope: Scope
+  const session = Session.create(id, [], {
+    version: SESSION_FORMAT_VERSION, id, createdAt: 0, ...cwd === undefined ? {} : { cwd }, isSeeded: false,
+  })
+  const value: Agent = {
+    id,
+    options: {},
+    session,
+    inbox: unsupportedInbox(),
+    status: 'idle',
+    get ctx() { return scope.ctx },
+    send: () => {},
+    followup: () => {},
+    steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
+    inject: () => {},
+    cancel() {},
+    runMaintenance: task => task(new AbortController().signal),
+    whenIdle: () => Promise.resolve(),
+  }
+  await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, value) }, { inject: ['tools'] }))
+  ctx.agents.register(value)
+  return value
+}
+
+
+async function setup() {
+  const root = await mkdtemp(join(tmpdir(), 'dsh-present-minimal-'))
+  cleanups.push(() => rm(root, { recursive: true, force: true }))
+  const ctx = new Context()
+  cleanups.push(() => ctx.fiber.dispose())
+  await ctx.plugin(SystemPrompt)
+  await ctx.plugin(ToolRuntime)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(LocalFileSystem, { cwd: root })
+  await ctx.plugin(SessionProjectionRegistry)
+  ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+  const fiber = ctx.plugin(Present, { maxFiles: 2 })
+  await fiber
+  const owner = await agent(ctx, root)
+  owner.session.append('turn/start', { turn: 1 })
+  const execute = (files: unknown) => ctx.tools.execute({
+    signal: new AbortController().signal, callId: ToolCallId(`call-${++callNumber}`),
+    name: 'present', arguments: { files }, agent: owner,
+  })
+  return { ctx, owner, root, fiber, execute }
+}
+
+describe('present', () => {
+  it('declares binary files without reading or copying contents, and records one delivery', async () => {
+    const { ctx, owner, root, execute, fiber } = await setup()
+    const data = Uint8Array.of(80, 75, 0, 255)
+    await writeFile(join(root, '报告.docx'), data)
+    const read = vi.spyOn(ctx.fs, 'readBytes')
+    const result = await execute([{ path: '报告.docx', description: 'Report' }])
+    expect(result.isError).toBe(false)
+    if (result.isError) throw new Error('present failed')
+    const files = (result.value as unknown as { files: PresentedFile[] }).files
+    expect(files).toHaveLength(1)
+    expect(owner.session.snapshotEvents().find(event => event.type === 'deliverables/presented')?.data.files).toEqual(files)
+    expect(files).toEqual([{ path: '报告.docx', description: 'Report' }])
+    expect(read).not.toHaveBeenCalled()
+    expect(ctx.get('attachments')).toBeUndefined()
+    await fiber.dispose()
+    expect(ctx.tools.get('present', owner)).toBeUndefined()
+  })
+
+  it('ignores a different present definition in the calling agent scope', async () => {
+    const { owner, execute } = await setup()
+    owner.ctx.tools.register(defineTool({
+      name: 'present', description: 'Scoped replacement.', parameters: {},
+      output: {
+        schema: {
+          type: 'object', additionalProperties: false,
+          properties: {
+            turn: { type: 'integer', required: true },
+            files: { type: 'array', required: true, items: { type: 'string' } },
+          },
+        },
+        render: () => [],
+      },
+      execute: async () => ({ turn: 1, files: [] }),
+    }))
+    expect((await execute([])).isError).toBe(false)
+    expect(owner.session.snapshotEvents().filter(event => event.type === 'deliverables/presented')).toEqual([])
+  })
+
+  it('records once when ancestor and agent scopes both mount present', async () => {
+    const { owner, root, execute } = await setup()
+    await owner.ctx.plugin(Present, { maxFiles: 2 })
+    await writeFile(join(root, 'a'), 'a')
+    expect((await execute([{ path: 'a' }])).isError).toBe(false)
+    const deliveries = owner.session.snapshotEvents().filter(event => event.type === 'deliverables/presented')
+    expect(deliveries).toHaveLength(1)
+    expect(deliveries[0]?.data.files[0]?.path).toBe('a')
+  })
+
+  it('does not publish deliveries after post-execute blocks a successful declaration', async () => {
+    const { ctx, root, owner, execute } = await setup()
+    await writeFile(join(root, 'a'), 'a')
+    ctx.on('tools/post-execute', async (_exec, _result, next) => {
+      await next()
+      return { kind: 'block', feedback: [{ type: 'text', text: 'blocked' }] }
+    })
+    expect((await execute([{ path: 'a' }])).isError).toBe(true)
+    expect(owner.session.snapshotEvents().some(event => event.type === 'deliverables/presented')).toBe(false)
+  })
+
+  it('rejects missing, non-file, outside-workspace, empty, and excessive inputs', async () => {
+    const { root, owner, execute } = await setup()
+    await writeFile(join(root, 'large'), 'four')
+    await symlink(tmpdir(), join(root, 'outside'))
+    for (const files of [[], [{ path: '' }], [{ path: 'missing' }], [{ path: '.' }], [{ path: 'outside' }], [{ path: 'large' }, { path: 'large' }, { path: 'large' }]]) {
+      const result = await execute(files)
+      expect(result.isError, JSON.stringify(files)).toBe(true)
+    }
+    expect(owner.session.snapshotEvents().some(event => event.type === 'deliverables/presented')).toBe(false)
+  })
+
+
+})
+
+
+it('validates deployment limits before registering the tool', () => {
+  for (const config of [{ maxFiles: 0 }, { maxFiles: 1.5 }, { maxFiles: Number.POSITIVE_INFINITY }]) {
+    expect(() => { Present.apply(new Context(), config) }).toThrow('positive integer maxFiles')
+  }
+})
+
+it('requires an agent, an open turn, and a workspace', async () => {
+  const { ctx, owner, execute } = await setup()
+  const detached = await ctx.tools.execute({ signal: new AbortController().signal, callId: ToolCallId('detached'), name: 'present', arguments: { files: [{ path: 'a' }] } })
+  expect(detached.isError).toBe(true)
+  owner.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+  expect((await execute([{ path: 'a' }])).isError).toBe(true)
+  const noWorkspace = await agent(ctx, undefined)
+  noWorkspace.session.append('turn/start', { turn: 1 })
+  const absent = await ctx.tools.execute({ signal: new AbortController().signal, callId: ToolCallId('no-workspace'), name: 'present', arguments: { files: [{ path: 'a' }] }, agent: noWorkspace })
+  expect(absent.isError).toBe(true)
+})

+ 36 - 0
packages/fs/tool-present/tsconfig.json

@@ -0,0 +1,36 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../core/agent"
+    },
+    {
+      "path": "../fs"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../../session/session-projection"
+    },
+    {
+      "path": "../../core/tools"
+    }
+  ]
+}

+ 2 - 2
packages/preset/agent-presets/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md
-README.md: bb9f59d11adc40ee7b428beb3d929829d88ff282
-README.zh.md: 13d30f9b7544f5d42523c1a8560cabf64165cabf
+README.md: 6ef3374a5f1ae6b83005073f80d63d156ee92f9c
+README.zh.md: a1be6d634e592126711befad4ffcb06ca0bdc26c

+ 2 - 0
packages/preset/agent-presets/README.md

@@ -27,6 +27,8 @@ Use `dsh-agent-presets` to give each session the tools, prompt sections, and ski
 
 Mount this package in a composition that should give each agent session its own tools, prompt sections, and skills from a preset file. Every session names a preset — explicitly or through the configured default — and is composed from it; without the package, sessions fall back to whatever the host composition mounts.
 
+The shipped Web `standard`, `ptc`, and `cordis` presets include [explicit file delivery](../../client/ui-deliverables/README.md#explicit-deliveries). The `minimal` preset keeps its fixed two-tool training configuration.
+
 ### What a preset gives a session
 
 A session composed from a preset runs the plugins that preset's `agent.cordis.yml` names: its tools, prompt sections, and skills. Sessions joined to the same preset share one installed composition, and each session's state stays separate. A child agent (subagent) joins its parent's composition, so it sees the same tools and prompt sections as the agent that spawned it.

+ 2 - 0
packages/preset/agent-presets/README.zh.md

@@ -27,6 +27,8 @@ kind: "package-reference"
 
 在需要让每个 agent 会话从 preset 文件获得自己的工具、提示词段落与 skill 的组装中挂载本包。每个会话都会命名一个 preset——显式指定或通过配置的默认值——并据此组装;没有本包时,会话只能回退到宿主组装挂载的内容。
 
+随附 Web 的 `standard`、`ptc` 与 `cordis` preset 包含[显式文件交付](../../client/ui-deliverables/README.zh.md#explicit-deliveries)。`minimal` preset 保留固定的双工具训练配置。
+
 ### preset 给会话带来什么
 
 从 preset 组装的会话会运行该 preset `agent.cordis.yml` 所列插件:它的工具、提示词段落与 skill。加入同一 preset 的会话共享一份已安装的组装,且各会话的状态彼此隔离。子 agent(subagent)会加入其父方的组装,因此它看到的工具与提示词段落和创建它的 agent 相同。

+ 3 - 0
packages/preset/agent-presets/presets/cordis/agent.cordis.yml

@@ -261,3 +261,6 @@
 
 - id: tool-skill
   name: '@deepseek-ai/dsh-tool-skill'
+
+- id: present
+  name: '@deepseek-ai/dsh-tool-present'

+ 3 - 0
packages/preset/agent-presets/presets/ptc/agent.cordis.yml

@@ -270,3 +270,6 @@
   name: '@deepseek-ai/dsh-agent-tool-presentation'
   config:
     mode: ptc
+
+- id: present
+  name: '@deepseek-ai/dsh-tool-present'

+ 3 - 0
packages/preset/agent-presets/presets/standard/agent.cordis.yml

@@ -250,3 +250,6 @@
   config:
     fetch: true
     searchTimeoutMs: 60000
+
+- id: present
+  name: '@deepseek-ai/dsh-tool-present'

+ 4 - 2
packages/spill/spill-local/tests/spill-local.spec.ts

@@ -267,10 +267,12 @@ describe('startup cleanup sweep', () => {
   it('keeps a file exactly at the boundary (only strictly-older expires)', async () => {
     const dir = sessionDir(root, 'sess-1')
     mkdirSync(dir, { recursive: true })
-    const cutoffMs = Date.now() - 30 * DAY_MS
+    const requestedMs = Date.now() - 30 * DAY_MS
     const boundary = join(dir, 'boundary.txt')
     writeFileSync(boundary, 'x')
-    utimesSync(boundary, cutoffMs / 1000, cutoffMs / 1000)
+    utimesSync(boundary, requestedMs / 1000, requestedMs / 1000)
+    // Filesystems can round timestamps written through utimes.
+    const cutoffMs = statSync(boundary).mtimeMs
     await sweepSpillRoots({ roots: [active(root)], cutoffMs, warn: () => {} })
     expect(existsSync(boundary)).toBe(true)
   })

+ 5 - 0
packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts

@@ -36,6 +36,11 @@ export const expectedUnsupported: Readonly<Partial<Record<string, { sourceVersio
     sourceVersion: 2,
     reason: 'session snapshot line 3: format v2 surface before first step cannot acquire a system head without changing chronology',
   },
+  // This recording contains an event absent from the released V2 event inventory.
+  'snapshots/web/present/session.v2.jsonl': {
+    sourceVersion: 2,
+    reason: 'session snapshot line 21: format v2 to v3 cannot safely transform unclassified event deliverables/presented',
+  },
   'snapshots/web/pwsh-terminal/session.v2.jsonl': {
     sourceVersion: 2,
     reason: 'session snapshot line 3: format v2 surface before first step cannot acquire a system head without changing chronology',

+ 67 - 0
pnpm-lock.yaml

@@ -292,6 +292,9 @@ importers:
       '@deepseek-ai/dsh-tool-jobs':
         specifier: workspace:^
         version: link:../../packages/jobs/tool-jobs
+      '@deepseek-ai/dsh-tool-present':
+        specifier: workspace:^
+        version: link:../../packages/fs/tool-present
       '@deepseek-ai/dsh-tool-pwsh':
         specifier: workspace:^
         version: link:../../packages/shell/tool-pwsh
@@ -1524,6 +1527,9 @@ importers:
       '@deepseek-ai/dsh-tool-jobs':
         specifier: workspace:^
         version: link:../../jobs/tool-jobs
+      '@deepseek-ai/dsh-tool-present':
+        specifier: workspace:^
+        version: link:../../fs/tool-present
       '@deepseek-ai/dsh-tool-pwsh':
         specifier: workspace:^
         version: link:../../shell/tool-pwsh
@@ -2681,6 +2687,9 @@ importers:
       '@deepseek-ai/dsh-api-remotes':
         specifier: workspace:^
         version: link:../../api/remotes
+      '@deepseek-ai/dsh-api-session-controller':
+        specifier: workspace:^
+        version: link:../../api/session-controller
       '@deepseek-ai/dsh-client-connection':
         specifier: workspace:^
         version: link:../connection
@@ -2708,12 +2717,24 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
+      '@deepseek-ai/dsh-client-ui-tool':
+        specifier: workspace:^
+        version: link:../ui-tool
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-session-query':
+        specifier: workspace:^
+        version: link:../../session-query/session-query
       '@deepseek-ai/dsh-system-prompt':
         specifier: workspace:^
         version: link:../../core/system-prompt
+      '@deepseek-ai/dsh-tool-present':
+        specifier: workspace:^
+        version: link:../../fs/tool-present
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
@@ -6058,6 +6079,49 @@ importers:
         specifier: workspace:^
         version: link:../../core/tools
 
+  packages/fs/tool-present:
+    dependencies:
+      '@deepseek-ai/schemastery':
+        specifier: link:../../../vendor/schemastery
+        version: link:../../../vendor/schemastery
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-agent-loop':
+        specifier: workspace:^
+        version: link:../../core/agent-loop
+      '@deepseek-ai/dsh-agent-loop-testkit':
+        specifier: workspace:^
+        version: link:../../test-support/agent-loop-testkit
+      '@deepseek-ai/dsh-fs':
+        specifier: workspace:^
+        version: link:../fs
+      '@deepseek-ai/dsh-fs-local':
+        specifier: workspace:^
+        version: link:../fs-local
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-scope':
+        specifier: workspace:^
+        version: link:../../core/scope
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-session-projection':
+        specifier: workspace:^
+        version: link:../../session/session-projection
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../../core/system-prompt
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
+
   packages/fs/tool-str-replace-editor:
     dependencies:
       '@deepseek-ai/schemastery':
@@ -11001,6 +11065,9 @@ importers:
       '@deepseek-ai/dsh-tool-jobs':
         specifier: workspace:^
         version: link:../../packages/jobs/tool-jobs
+      '@deepseek-ai/dsh-tool-present':
+        specifier: workspace:^
+        version: link:../../packages/fs/tool-present
       '@deepseek-ai/dsh-tool-pwsh':
         specifier: workspace:^
         version: link:../../packages/shell/tool-pwsh

+ 1 - 0
python/sdk-runtime/package.json

@@ -102,6 +102,7 @@
     "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
     "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^",
     "@deepseek-ai/dsh-tool-cordis": "workspace:^",
+    "@deepseek-ai/dsh-tool-present": "workspace:^",
     "@deepseek-ai/dsh-tool-fs": "workspace:^",
     "@deepseek-ai/dsh-tool-fs-search": "workspace:^",
     "@deepseek-ai/dsh-tool-goal": "workspace:^",

+ 1 - 0
scripts/client-build-environment.client.spec.ts

@@ -72,6 +72,7 @@ function repositoryFixture(version = '1.2.3-rc.4'): string {
   git(fixtureRoot, ['init'])
   git(fixtureRoot, ['config', 'user.name', 'DSH test'])
   git(fixtureRoot, ['config', 'user.email', 'dsh-test@example.invalid'])
+  git(fixtureRoot, ['config', 'commit.gpgsign', 'false'])
   git(fixtureRoot, ['add', 'package.json', 'tracked.txt'])
   git(fixtureRoot, ['commit', '-m', 'fixture'])
   return fixtureRoot

+ 13 - 0
scripts/gen-tool-catalog.ts

@@ -46,6 +46,7 @@ import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
 import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent'
 import CordisHostRunner from '@deepseek-ai/dsh-cordis-host-runner'
 import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
+import * as ToolPresent from '@deepseek-ai/dsh-tool-present'
 import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
 import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
 import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor'
@@ -241,6 +242,18 @@ const TOOL_PACKAGES: ToolPackage[] = [
     note:
       'The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.jobs` runtime and is collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.',
   },
+  {
+    pkg: '@deepseek-ai/dsh-tool-present',
+    dir: 'tool-present',
+    source: 'packages/fs/tool-present/src/index.ts',
+    requires: ['ctx.tools', 'ctx.fs', 'ctx.sessionProjections'],
+    writes: ['tool/call', 'deliverables/presented after a successful final result', 'tool/result'],
+    async mount(ctx) {
+      await ctx.plugin(LocalFileSystem)
+      await ctx.plugin(ToolPresent)
+    },
+    note: 'Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards.',
+  },
   {
     pkg: '@deepseek-ai/dsh-tool-pwsh',
     dir: 'tool-pwsh',

+ 1 - 0
scripts/run-gates.ts

@@ -787,6 +787,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate {
     'apps/cli/tests/built-bin.e2e.ts',
     'packages/host/directory-picker-native/tests/built-worker.e2e.ts',
     'packages/sdk/server/tests/built-scope-carrier.e2e.ts',
+    'packages/fs/tool-present/tests/built-errors.e2e.ts',
     'packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts',
     'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts',
     'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',

+ 32 - 0
snapshots/web/cordis-tool-round/tool-schemas.expected.json

@@ -520,6 +520,38 @@
         }
       }
     },
+    {
+      "name": "present",
+      "description": "Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "files": {
+            "type": "array",
+            "items": {
+              "type": "object",
+              "additionalProperties": false,
+              "properties": {
+                "path": {
+                  "type": "string",
+                  "description": "Path of an existing file inside the workspace."
+                },
+                "description": {
+                  "type": "string",
+                  "description": "Brief description for the user."
+                }
+              },
+              "required": [
+                "path"
+              ]
+            }
+          }
+        },
+        "required": [
+          "files"
+        ]
+      }
+    },
     {
       "name": "ralph",
       "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",

+ 32 - 0
snapshots/web/fresh-round-trip/tool-schemas.expected.json

@@ -323,6 +323,38 @@
         }
       }
     },
+    {
+      "name": "present",
+      "description": "Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "files": {
+            "type": "array",
+            "items": {
+              "type": "object",
+              "additionalProperties": false,
+              "properties": {
+                "path": {
+                  "type": "string",
+                  "description": "Path of an existing file inside the workspace."
+                },
+                "description": {
+                  "type": "string",
+                  "description": "Brief description for the user."
+                }
+              },
+              "required": [
+                "path"
+              ]
+            }
+          }
+        },
+        "required": [
+          "files"
+        ]
+      }
+    },
     {
       "name": "ralph",
       "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 13 - 0
snapshots/web/present/session.v2.jsonl


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 14 - 0
snapshots/web/present/session.v3.jsonl


+ 9 - 0
snapshots/web/present/snapshot.yml

@@ -0,0 +1,9 @@
+version: 1
+scenario: present
+profile: web
+composition: web-ptc
+recording: live
+header:
+  class: web-ptc
+workspace:
+  final: true

+ 109 - 0
snapshots/web/present/ui.expected.md

@@ -0,0 +1,109 @@
+- banner:
+  - navigation "Session hierarchy":
+    - button "Use one run_code program to" [disabled]
+  - img
+  - text: PTC mode
+  - button "Session log":
+    - text: Session log
+    - img
+  - button "Open the sidebar":
+    - img
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- button "System prompt":
+  - img
+  - img
+  - text: System prompt
+- text: "Use one run_code program to do the following in order. Call present for missing.txt and catch its error without creating that file. Use bash to run exactly `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`. Call present for report.txt and 说明.txt. After present succeeds, deliberately throw the string \"AFTER_PRESENT\" (not an Error object) from that same run_code program. Do not retry the program or create any other files. Finish by mentioning `report.txt` and `说明.txt` in inline code, and put PRESENT_DONE in a separate paragraph. {{clock}}"
+- button "Copy":
+  - img
+- button "1 tool call" [expanded]:
+  - text: 1 tool call
+  - img
+- button "Context injection @deepseek-ai/dsh-system-prompt":
+  - img
+  - img
+  - text: Context injection @deepseek-ai/dsh-system-prompt
+- button "Think The user wants one run_code program that:":
+  - img
+  - img
+  - text: "Think The user wants one run_code program that:"
+- text: Failed
+- 'button "Code Error: code run failed (exception): AFTER_PRESENT"':
+  - img
+  - text: "Code Error: code run failed (exception): AFTER_PRESENT"
+- button "Present files Delivery failed missing.txt":
+  - img
+  - text: Present files Delivery failed missing.txt
+- button "Bash Write DELIVERED_REPORT and DELIVERED_NOTE to files":
+  - img
+  - img
+  - text: Bash Write DELIVERED_REPORT and DELIVERED_NOTE to files
+- button "Present files Delivered report.txt, 说明.txt":
+  - img
+  - text: Present files Delivered report.txt, 说明.txt
+- button "Think The program ran as intended:":
+  - img
+  - img
+  - text: "Think The program ran as intended:"
+- paragraph:
+  - text: "The single program ran exactly as ordered:"
+  - code: present
+  - text: for
+  - code: missing.txt
+  - text: failed and was caught (no file created), bash ran the exact command successfully, then
+  - code: present
+  - text: succeeded for
+  - code:
+    - button "Open report.txt in default app": report.txt
+  - text: and
+  - code:
+    - button "Open 说明.txt in default app": 说明.txt
+  - text: ", after which the program deliberately threw the string"
+  - code: AFTER_PRESENT
+  - text: — no retries, no extra files.
+- paragraph: PRESENT_DONE
+- text: Deliverables
+- button "Open report.txt in default app":
+  - text: report.txt TXT delivered report
+  - status: Opened in default app
+  - img
+  - text: Open
+- button "Open 说明.txt in default app":
+  - text: 说明.txt TXT delivered note
+  - status: Opened in default app
+  - img
+  - text: Open
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- button "Usage 19K tok":
+  - img
+  - text: Usage 19K tok
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
+- textbox "Message or run a task, / commands, @ files or sessions"
+- button "Commands":
+  - img
+- button "Add attachment":
+  - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
+  - img
+- button "8% of context used"
+- button "Send message" [disabled]
+- button "1 turns 2 steps · {{throughput}} tok/s":
+  - img
+  - text: 1 turns 2 steps{{throughput}} tok/s
+- button "19K tok · Cache hit 94%":
+  - img
+  - text: 19K tokCache hit 94%

+ 1 - 0
snapshots/web/present/workspace.expected/report.txt

@@ -0,0 +1 @@
+DELIVERED_REPORT

+ 1 - 0
snapshots/web/present/workspace.expected/说明.txt

@@ -0,0 +1 @@
+DELIVERED_NOTE

+ 16 - 0
snapshots/web/ptc-round/system-prompt.expected.md

@@ -162,6 +162,15 @@ interface ToolArgsMap {
     /** children (default) lists direct children only; descendants walks the complete tree below you. */
     scope?: "children" | "descendants";
   } & Record<string, JsonValue>;
+  /** Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool. */
+  present: {
+    files: {
+      /** Path of an existing file inside the workspace. */
+      path: string;
+      /** Brief description for the user. */
+      description?: string;
+    }[];
+  } & Record<string, JsonValue>;
   /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
   ralph: {
     /** The immutable completion objective for every fresh Ralph round. */
@@ -399,6 +408,13 @@ interface ToolOutputMap {
     parent?: string;
     depth?: number;
   })[];
+  present: {
+    turn: number;
+    files: {
+      path: string;
+      description?: string;
+    }[];
+  };
   ralph: {
     runId: string;
     agentsStarted: number;

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott