diff --git a/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml
new file mode 100644
index 0000000000..71c2bd9db9
--- /dev/null
+++ b/.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
diff --git a/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.md b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.md
new file mode 100644
index 0000000000..ff2ddeb59d
--- /dev/null
+++ b/.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.
diff --git a/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.zh.md b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.zh.md
new file mode 100644
index 0000000000..85b09ac82c
--- /dev/null
+++ b/.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 排除。
diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json
index f540320056..cded9f6c7e 100644
--- a/.agents/notes/archived/manifest.json
+++ b/.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",
diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml
index 3c883f7d6c..d03d20b03b 100644
--- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
-2026-07-10-single-file-executable-sdk-runtime-distribution.md: 5533c38d635d04c8799658fc1f6bcec8543d1dab
-2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 05800082034f0d1bd5034fc5bb17f09356d84dd9
+2026-07-10-single-file-executable-sdk-runtime-distribution.md: 7792e24a5869be6b7bae7787a6a481f3075ba740
+2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 857bfaec80da962fac0403f2239e0a5e71954194
diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
index 5533c38d63..7792e24a58 100644
--- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
+++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
@@ -66,7 +66,7 @@ The Python client launches the packaged `dsh` command with the selected profile
## Testing
-The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build installs both wheels into a clean venv outside the checkout, proves matching versions and installed module/executable locations, then completes turns against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The spawn-node scenario drives the platform shell tool through a command starting with `node` and requires the machine's own Node version in the tool result with no `PKG_EXECPATH` in the child environment, pinning the packaged runtime against a pkg upgrade that re-records the child-process patch. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. The same installed run compares a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. Trusted pull requests add a real-provider two-turn file write/read whose external bytes, tool calls, completed reasons, and persisted log must agree. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ.
+The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build installs both wheels into a clean venv outside the checkout, proves matching versions and installed module/executable locations, then completes turns against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and single shell-tool catalog and retains shell state across calls. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The spawn-node scenario drives the platform shell tool through a command starting with `node` and requires the machine's own Node version in the tool result with no `PKG_EXECPATH` in the child environment, pinning the packaged runtime against a pkg upgrade that re-records the child-process patch. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. The same installed run compares a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. Trusted pull requests add a real-provider two-turn file write/read whose external bytes, tool calls, completed reasons, and persisted log must agree. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ.
Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends.
diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md
index 0580008203..857bfaec80 100644
--- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md
@@ -66,7 +66,7 @@ exe 内支持 `dsh-workflow-worker-thread` 与 `dsh-code-runtime-worker-thread`
## 测试
-验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都会把两个 wheel 包安装进 checkout 外的干净 venv,证明版本相同以及已安装模块/可执行文件的位置,再通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob` 与 `grep`。spawn-node 场景驱动平台 shell 工具执行以 `node` 开头的命令,要求工具结果给出机器自身的 Node 版本且子进程环境中无 `PKG_EXECPATH`,把打包运行时钉死在「pkg 升级重录 child_process 补丁也不得回归」的行为上。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一项安装后运行还会经 Python SDK 比较一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。可信拉取请求会增加真实提供方双轮文件写入/读取,并要求外部字节、工具调用、已完成原因与持久化日志一致。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。
+验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都会把两个 wheel 包安装进 checkout 外的干净 venv,证明版本相同以及已安装模块/可执行文件的位置,再通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与单个 shell 工具目录,并跨调用保留 shell 状态。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob` 与 `grep`。spawn-node 场景驱动平台 shell 工具执行以 `node` 开头的命令,要求工具结果给出机器自身的 Node 版本且子进程环境中无 `PKG_EXECPATH`,把打包运行时钉死在「pkg 升级重录 child_process 补丁也不得回归」的行为上。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一项安装后运行还会经 Python SDK 比较一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。可信拉取请求会增加真实提供方双轮文件写入/读取,并要求外部字节、工具调用、已完成原因与持久化日志一致。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。
手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。
diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml
index d757a5efd0..06c136e20b 100644
--- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md
-2026-07-14-provider-routed-llm-adapters.md: 4cc3cc3cdceeefdfea864ccf3465e528e32159c7
-2026-07-14-provider-routed-llm-adapters.zh.md: 098a6af7c951c6dee8a1b4f948bc69725e8d189c
+2026-07-14-provider-routed-llm-adapters.md: 24d6e7e439dc74a158801b647ddac96169730af1
+2026-07-14-provider-routed-llm-adapters.zh.md: f740468ed67830dabf5769eca206402d91c90aac
diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md
index 4cc3cc3cdc..24d6e7e439 100644
--- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md
+++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md
@@ -30,7 +30,7 @@ A provider has exactly one adapter owner in a Cordis context. `dsh-llm-deepseek`
`dsh-llm-pi-ai` takes one non-empty list of provider profiles. Provider names must be unique within the list and present in pi-ai's `getProviders()` result. Each profile contains the provider name plus optional `apiKey`, `baseURL`, headers, reasoning level and budgets, cache retention, transport, SDK timeouts, a Harness stream-idle timeout, and a provider-owned `retryPolicy`. The adapter forces pi-ai's `maxRetries` to zero so one `stream()` call makes one visible provider attempt, while `dsh-llm-retry` executes the resolved policy at the agent failed-step extension point. Credentials are never global: an explicit key applies only to its profile, while an absent key lets pi-ai resolve its standard environment variable, OAuth token, AWS credential chain, Google ADC, or other provider-native ambient authentication. An explicitly empty key is invalid configuration rather than an environment fallback.
-The plugin registers all configured provider names against one `PiAiAdapter` in one all-or-nothing call. A request uses its provider to select the matching profile and finds its model in `getModels(provider)` to obtain the catalog descriptor. An unknown provider fails at plugin load; an unknown model fails before network I/O with `UNKNOWN_MODEL`. The catalog object is never mutated. When a profile supplies `baseURL`, the adapter clones the selected descriptor and overrides only `baseUrl`, so a private endpoint can retain pi-ai's API, capabilities, compatibility flags, context limits, and reasoning map. The private endpoint must implement the selected provider's protocol, and the model id must still exist in the installed pi-ai catalog.
+The plugin registers configured provider names against one `PiAiAdapter` in one atomic call. Each immutable request snapshot combines the effective profiles and their serviceable model descriptors. Catalog-external models require an explicit or inferable protocol and endpoint. Stored catalog errors remain visible and repairable under the [settings catalog recovery decision](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md), while writes validate changed providers and requests reject the selected failed model before network I/O.
The adapter calls pi-ai's `streamSimple()` so each catalog model chooses its registered API implementation, including OpenAI Responses instead of Chat Completions where the descriptor says `openai-responses`. Harness temperature, maximum tokens, signal, session id, and the profile's common stream options flow through directly. Profile headers merge with the mandatory Harness attribution headers, with Harness attribution winning its reserved names. The adapter no longer maintains DeepSeek-specific payload rewrites or a provider-protocol matrix.
diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md
index 098a6af7c9..f740468ed6 100644
--- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md
@@ -30,7 +30,7 @@ Status: implemented
`dsh-llm-pi-ai` 接受一个非空的提供方配置列表。列表内的提供方名称必须唯一,并且存在于 pi-ai 的 `getProviders()` 结果中。每项配置包含提供方名称,以及可选的 `apiKey`、`baseURL`、headers、推理级别和预算、缓存保留设置、传输方式、SDK 超时、Harness 流空闲超时,以及由提供方拥有的 `retryPolicy`。适配器强制将 pi-ai 的 `maxRetries` 设为零,使一次 `stream()` 调用只发起一次可见的提供方请求;`dsh-llm-retry` 则在 agent 失败步骤扩展点上执行解析后的策略。凭据不设全局值:显式密钥仅对所属配置生效;未提供密钥时,pi-ai 使用标准环境变量、OAuth token、AWS 凭据链、Google ADC 或其他提供方原生环境认证。显式空密钥属于无效配置,不会回退到环境认证。
-插件通过一次全有或全无调用,将所有已配置的提供方名称注册到同一个 `PiAiAdapter`。请求按 provider 选择对应配置,并在 `getModels(provider)` 中查找模型以取得目录描述符。未知提供方会在插件加载时失败;未知模型会在网络 I/O 前以 `UNKNOWN_MODEL` 失败。适配器不会修改目录对象。当配置提供 `baseURL` 时,适配器复制选中的描述符,仅覆盖 `baseUrl`,使私有端点保留 pi-ai 的 API、能力、兼容标志、上下文限制与推理映射。私有端点必须实现所选提供方的协议,模型 ID 也仍须存在于已安装的 pi-ai 目录中。
+插件通过一次原子调用,将已配置的提供方名称注册到同一个 `PiAiAdapter`。每个不可变请求快照组合有效 profile 与可服务模型的描述符。目录外模型需要显式指定或可推断的协议与端点。根据[设置目录恢复决策](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md),已存储的目录错误保持可见、可修复,写入仍会校验已修改提供方,请求则在网络 I/O 前拒绝所选错误模型。
适配器调用 pi-ai 的 `streamSimple()`,因此每个目录模型会选择其注册的 API 实现;描述符为 `openai-responses` 时使用 OpenAI Responses,而非 Chat Completions。Harness 的 temperature、最大 token 数、signal、session ID,以及提供方配置中的通用流选项均直接传递。配置 headers 与 Harness 强制归因 headers 合并;发生保留名称冲突时,以 Harness 归因为准。适配器不再维护 DeepSeek 专用 payload 重写或提供方协议矩阵。
diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml
index 7d108bbf7d..f633a831d3 100644
--- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md
-2026-07-22-slot-type-chain-implementation.md: 0e493a19e232acf6f289714e53e0cf64b3ffc4b0
-2026-07-22-slot-type-chain-implementation.zh.md: 5e5c2ec808cec6949396a333c6a86e267bb4268b
+2026-07-22-slot-type-chain-implementation.md: 98585a42f71844594da4ada19d7438a7d8321702
+2026-07-22-slot-type-chain-implementation.zh.md: d1093537ccbc5ee5ff2dc18c234233ffca39879d
diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md
index 0e493a19e2..98585a42f7 100644
--- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md
+++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md
@@ -12,6 +12,8 @@ The page is composed at runtime from independently loaded plugins, so the UI nee
## Decision
+Global main-panel selection and its root lifetime are defined by the [global main-panels decision](2026-09-08-global-main-panels.md).
+
One sentence: **the ui-renderer renders only `'root'`; a plugin composes UI through a single `register` call that simultaneously occupies a slot, declares+authorizes its child slots, declares its store, and injects its business face; components are pure functions whose props arrive in four shares, each auto-derived from its single source of truth.**
### 'root' is the only a-priori slot
@@ -25,7 +27,7 @@ ctx.slots.register({
name: 'root',
children: {
'sidebar': { kind: 'single', scope: 'root' },
- 'conversation': { kind: 'single', scope: 'session' },
+ 'main': { kind: 'keyed', scope: 'root' },
},
store: createLayoutStore, // StoreHandle or factory (below)
inject: injectFrame, // business face (below)
diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md
index 5e5c2ec808..d1093537cc 100644
--- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md
@@ -12,6 +12,8 @@ Status: implemented
## 决策
+全局主面板选择及其 root 生命周期由[全局主面板决策](2026-09-08-global-main-panels.zh.md)定义。
+
一句话:**ui-renderer 只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——这一次调用同时占用 slot、声明并授权子 slot、声明 store、注入业务面;组件是纯函数,props 分四份额到达,每一份额都从各自唯一的真源自动推导。**
### 'root' 是唯一的先验 slot
@@ -25,7 +27,7 @@ ctx.slots.register({
name: 'root',
children: {
'sidebar': { kind: 'single', scope: 'root' },
- 'conversation': { kind: 'single', scope: 'session' },
+ 'main': { kind: 'keyed', scope: 'root' },
},
store: createLayoutStore, // StoreHandle or factory (below)
inject: injectFrame, // business face (below)
diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml
index 9ccf82b4a5..e8b41ca73b 100644
--- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
-2026-08-31-released-session-format-migrations.md: eb5eb14f28a6459bd388caa2ea106ec01d1ebbe6
-2026-08-31-released-session-format-migrations.zh.md: b06bfac059541e9c397ff46fe7d169690c5b1213
+2026-08-31-released-session-format-migrations.md: 286737730198ad895de2f03a48f9c74848839582
+2026-08-31-released-session-format-migrations.zh.md: 228b09e03916c1fa447398edb45508a7a6ab61e5
diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
index eb5eb14f28..2867377301 100644
--- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
+++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
@@ -94,6 +94,8 @@ The JSONL provider scans frame boundaries once, reuses one Zstandard decoder, pa
Current encoding is record based. The provider serializes about 1 MiB of plaintext per main-thread slice, streams it through one Zstandard context with source-error propagation, writes compressed output in 4 MiB batches to an exclusively created same-directory temporary file, and syncs it before publication. A process-wide scheduler admits at most two full verification Workers and hands a released permit directly to the oldest waiter.
+The shipped `lib/worker.cjs` bundles its JavaScript workspace dependencies so each fresh verifier avoids resolving and compiling their runtime module graph. This is safe because the Worker communicates through plain request/result messages and shares no service or class identity with its host. The Host build applies the existing TypeScript and Typert transforms; the Client pass skips this Node-only package instead of replacing its worker with untransformed source. Native add-ons remain external. Verification, scheduler admission, termination, and durable publication still complete before writable open returns. The built-worker smoke copies the package manifest and worker into an isolated temporary package with ambient module paths removed, accepts a valid generation, and rejects an incorrect event count.
+
Preparation forwards cancellation through source reads and observes it at the existing approximately 500 ms Decode yield boundary. Once `publish()` starts, encode, Worker verification, and publication do not receive caller cancellation and run to settlement; write open checks its caller signal again afterward. A published generation is never rolled back.
The Stage pipeline ends at one prepared current artifact. [Historical Session read preparation](2026-09-05-read-only-session-migration-preparation.md) defines how read open consumes that artifact immediately while write open performs encode, verification, and publication before returning append access.
diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md
index b06bfac059..228b09e039 100644
--- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md
@@ -94,6 +94,8 @@ JSONL provider 只扫描一次 frame boundary,复用一个 Zstandard decoder
Current encode 以单条 record 为单位。Provider 在主线程每个 slice 序列化约 1 MiB plaintext,通过一个会传播 source error 的 Zstandard context 流式压缩,以 4 MiB batch 写入同目录排他创建的临时文件,并在 publication 前 sync。进程级 scheduler 最多允许两个完整 verification Worker 并行,并把释放的 permit 直接交给最早的 waiter。
+发布的 `lib/worker.cjs` 将 JavaScript workspace 依赖一起打包,使每个新 verifier 无需解析并编译它们的运行时模块图。Worker 只通过普通 request/result 消息通信,与 host 不共享 service 或 class identity,因此可以这样处理。Host build 应用现有 TypeScript 与 Typert 转换;Client pass 跳过这个 Node-only package,不会用未经转换的源代码覆盖 worker。Native add-on 保持 external。Verification、scheduler admission、termination 与 durable publication 仍在 writable open 返回前完成。Built-worker 冒烟测试把 package manifest 与 worker 复制到隔离的临时 package,移除环境中的模块搜索路径,接受有效 generation,并拒绝错误的 event count。
+
Preparation 会把 cancellation 传给 source read,并在现有的约 500 ms Decode yield 边界观察它。`publish()` 一旦开始,encode、Worker verification 与 publication 不接收 caller cancellation,并运行到终态;write open 会在之后再次检查 caller signal。已经发布的 generation 绝不会回滚。
Stage pipeline 终止于一份 prepared current artifact。[历史 Session 只读迁移准备](2026-09-05-read-only-session-migration-preparation.zh.md)定义 read open 如何立即消费该 artifact,以及 write open 如何在返回 append 权限前完成 encode、verification 与 publication。
diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml
new file mode 100644
index 0000000000..697c410f3a
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md
+2026-09-01-parent-owned-subagent-catalog.md: 5926af77219680a48af1e5fb062ddc11a91a2280
+2026-09-01-parent-owned-subagent-catalog.zh.md: ddc77a665207169cfc048b9f2cfbd4f912cb93dc
diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md
new file mode 100644
index 0000000000..5926af7721
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md
@@ -0,0 +1,49 @@
+# Agent Note: Parent-owned subagent catalog events
+
+Status: implemented
+
+English | [中文](2026-09-01-parent-owned-subagent-catalog.zh.md)
+
+## Problem
+
+Direct-child discovery once reconstructed a catalog from the global Session corpus and each selected child's log. Creation already knows the direct parent, child id, mode, and label, so repository-wide enumeration and child-log reads duplicated an owned fact and made browser refresh cost depend on unrelated Sessions.
+
+The child descriptor remains necessary for recovery and composition, but it cannot be the discovery source because a reader must find and open the child before it can read the descriptor. Forks add a separate requirement: a seeded copy of a parent log must not inherit the original Session's children.
+
+## Decision
+
+The parent Session's required `subagent/catalog` events are the persistent authority for direct-child discovery. Each event is one successful creation fact containing `childId`, `childCreatedAt`, mode, and the mode-discriminated label. Remote one-shot runs without a local Session remain outside this catalog. Invalid own facts, including unsupported payload versions, reject projection restoration because silently dropping a required fact would return an incomplete catalog.
+
+Creation publishes only successful facts. A one-shot run appends the catalog event after its provider returns a local child and before the run reaches its caller. A continuable run admits the initial prompt, appends the catalog event, then returns the child id. If admission or catalog append fails, creation fails and releases the activation; there is no compensating catalog event or rollback protocol.
+
+The child header and `subagent/descriptor` remain authoritative for recovery and composition. An Activation and the exact parent relationship remain authoritative for authorization and delivery. Mode and label are snapshotted once and the same detached values reach the parent catalog fact and child descriptor.
+
+The registered `subagentCatalog` projection materializes the parent facts. It delegates storage, append, iteration, and checkpoint validation to [`dsh-chunked-list`](../../../../packages/util/chunked-list/README.md), which stores facts in a persistent stack of 64-entry chunks, so an append copies at most the head chunk in bounded O(1) work. Materialization visits chunks from oldest to newest and preserves parent catalog event order in O(D) time for D facts. Concurrent creation is ordered by successful catalog append, independent of child timestamps and ids. A projection checkpoint clones the state once in O(D); projection-cache writes remain asynchronous and use the existing mandatory creation, turn-end, and disposal points.
+
+The utility owns chunk layout and its shared capacity constant; the catalog owns event validation, fork filtering, and row conversion. Catalog projection state version 2 stores generic chunk values, so the projection registry rebuilds incompatible caches from Session events. Session event payloads and public catalog rows retain their formats.
+
+Fork isolation uses the exact `Session.inheritedEventCount` supplied to projection initialization. The fold ignores `subagent/catalog` events below that offset. The state stores the inherited offset but not each event seq because acceptance is decided during folding.
+
+Headless snapshot collection assigns sibling fixture roles by their parent catalog order, regardless of child creation timestamps: provider startup can publish an older Session after a newer one. The collection preserves each log verbatim.
+
+Snapshot normalizers zero `childCreatedAt` because it originates from the process clock. Event order and source-event references remain intact: adjacent facts can come from sequential creation, so adjacency does not establish commutativity.
+
+Current-writer snapshot expectations include catalog facts even when replay input retains a historical Session generation. The comparison preserves the catalog and its source-event references; historical replay files remain unchanged.
+
+## Alternatives considered
+
+**A flat immutable array.** Appending with `[...facts, fact]` copies D facts, so creation is O(D). Mutating a shared array would violate projection state ownership and checkpoint safety.
+
+**A node-per-fact linked list.** It provides O(1) append and O(D) read, but persisted projection checkpoints form JSON nested D levels deep. Sixty-four-entry chunks preserve the asymptotic costs while reducing nesting.
+
+**Separate host-state observation output.** Returning internal projection states duplicates the existing observation result mechanism and copies states unrelated to child discovery. A catalog view supplies the direct-child list through the existing typed projection map.
+
+**A durable SQLite child index.** An index would create another write path, reconciliation protocol, schema, and corruption surface for a fact already ordered in the parent Session log.
+
+**A compensating failure event.** Recording catalog membership before initial prompt admission requires a second operation, pairing rules, rollback cleanup, and client reconciliation. Delaying the success fact until admission completes removes that protocol.
+
+## Consequences
+
+Session observations and client snapshots expose the direct-child list through `projections.values.subagentCatalog`. The projection change feed publishes a complete list when catalog state changes. Each view costs O(D), so D creations can incur O(D²) cumulative view work; this follows the existing projection mechanism. Direct-child and descendant listing still use the Session corpus and child identity projection.
+
+Backends that do not know the required event refuse the log under the existing Session event mechanism. Pre-release format policy requires no fallback scan for old logs.
diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md
new file mode 100644
index 0000000000..ddc77a6652
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md
@@ -0,0 +1,49 @@
+# Agent Note: parent 自有的 subagent 目录事件
+
+Status: implemented
+
+[English](2026-09-01-parent-owned-subagent-catalog.md) | 中文
+
+## 问题
+
+直接 child discovery 曾从全局 Session 语料与每个入选 child 的日志重建目录。创建过程已经知道直接 parent、child id、mode 与 label,因此仓库范围枚举和 child 日志读取重复推导了已有归属的事实,并让浏览器刷新成本取决于无关 Session。
+
+child descriptor 对恢复与 composition 仍然必要,但它不能作为 discovery 来源,因为读取方必须先找到并打开 child 才能读取 descriptor。fork 还有独立要求:从 parent 日志播种的副本不能继承原 Session 的 child。
+
+## 决策
+
+parent Session 的 required `subagent/catalog` 事件是直接 child discovery 的持久化权威。每个事件都是一条成功创建事实,包含 `childId`、`childCreatedAt`、mode 与按 mode 区分的 label。没有本地 Session 的远程 one-shot run 不进入该目录。无效的自身 fact(包括不支持的 payload 版本)会使 projection 恢复失败,因为静默丢弃 required fact 会返回不完整的目录。
+
+创建只发布成功事实。one-shot run 在 provider 返回本地 child 后、run 到达调用方前追加目录事件。continuable run 先准入初始 prompt,再追加目录事件,最后返回 child id。准入或目录追加失败时,创建失败并释放 activation;不存在补偿目录事件或 rollback 协议。
+
+child header 与 `subagent/descriptor` 继续拥有恢复与 composition 权威。Activation 与精确 parent 关系继续拥有授权与投递权威。mode 与 label 只快照一次,同一份分离值写入 parent catalog fact 与 child descriptor。
+
+注册的 `subagentCatalog` projection 物化 parent fact。它将存储、追加、迭代和检查点校验交给 [`dsh-chunked-list`](../../../../packages/util/chunked-list/README.zh.md),后者以每块 64 项的持久 stack 保存事实,因此 append 最多复制 head chunk,以有界 O(1) 工作完成。materialization 从旧到新访问 chunk,对 D 条事实以 O(D) 时间保留父目录事件顺序。并发创建按目录成功追加的顺序排列,与 child 时间戳和 id 无关。projection checkpoint 以 O(D) 克隆 state;projection-cache 继续异步写入,并使用既有创建、turn-end 与 disposal 强制点。
+
+工具库拥有分块布局及其共享容量常量;目录拥有事件校验、fork 过滤和目录行转换。目录 projection state 版本 2 保存通用块值,因此 projection registry 从 Session 事件重建不兼容的缓存。Session 事件载荷和公开目录行保持各自格式。
+
+fork 隔离使用 projection 初始化时提供的精确 `Session.inheritedEventCount`。fold 忽略该 offset 之前的 `subagent/catalog` 事件。state 保存 inherited offset,但不保存每条 event seq,因为接受判定已在 fold 时完成。
+
+Headless 快照采集按父目录顺序分配同父子级的 fixture 角色,不依赖子级创建时间戳:provider 启动可能在较新的 Session 之后发布较旧的 Session。采集过程原样保留每份日志。
+
+snapshot normalizer 会把 `childCreatedAt` 归零,因为它来自 process clock。事件顺序与来源事件引用保持不变:相邻 fact 也可能来自顺序创建,因此相邻关系不能证明可交换性。
+
+即使 replay 输入保留历史 Session generation,当前 writer 的快照预期也包含 catalog 事实。比较保留 catalog 及其来源事件引用;历史 replay 文件保持不变。
+
+## 考虑过的替代方案
+
+**扁平不可变数组。** 用 `[...facts, fact]` append 会复制 D 个 fact,因此创建是 O(D)。修改共享数组会违反 projection state ownership 与 checkpoint 安全。
+
+**每 fact 一个 node 的 linked list。** 它提供 O(1) append 与 O(D) read,但持久 projection checkpoint 会形成 D 层 JSON 嵌套。每块 64 项保留渐进复杂度,同时降低嵌套深度。
+
+**独立的 host state 观察输出。** 返回内部 projection state 会重复已有观察结果机制,并复制与子级发现无关的状态。目录视图通过既有的类型化 projection map 提供直接子级列表。
+
+**持久 SQLite child index。** index 会为 parent Session 日志中已有顺序的 fact 增加另一套写路径、reconciliation protocol、schema 与 corruption surface。
+
+**补偿失败事件。** 在初始 prompt 准入前记录 catalog membership 会引入第二种 operation、配对规则、rollback 清理与 client reconciliation。把成功事实推迟到准入完成后即可删除该协议。
+
+## 后果
+
+Session 观察和客户端快照通过 `projections.values.subagentCatalog` 暴露直接子级列表。目录状态变化时,projection 变更通知发布完整列表。每次视图计算成本为 O(D),因此 D 次创建的累计视图工作量可能为 O(D²);这沿用既有 projection 机制。直接子级和后代列表仍使用 Session 语料库与子级身份 projection。
+
+不认识该 required event 的 backend 会按既有 Session event 机制拒绝日志。pre-release format policy 不要求为旧日志保留 fallback scan。
diff --git a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml
new file mode 100644
index 0000000000..f5b5d88586
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-global-main-panels.md
+2026-09-08-global-main-panels.md: 75be68ac1bf6dceb812a5aedbca74ce58df929b3
+2026-09-08-global-main-panels.zh.md: 343f183367a5cfbd13127e32542f689c50306c89
diff --git a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md
new file mode 100644
index 0000000000..75be68ac1b
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md
@@ -0,0 +1,37 @@
+# Agent Note: Global main panels without default UI additions
+
+Status: implemented
+
+English | [中文](2026-09-08-global-main-panels.zh.md)
+
+## Problem
+
+Plugins need application-wide views that do not belong to a Session. A Session-scoped Conversation view cannot provide that lifetime, and replacing the Conversation's single slot removes the ordinary conversation surface. Adding this extension must not add navigation controls or reserved space to the default application.
+
+## Decision
+
+The layout declares a root-scoped keyed `main` slot. The reserved `conversation` key belongs to the Conversation plugin, whose `main.conversation` child retains optional-Session binding. Other main entries receive no implicit Session binding.
+
+The sidebar owns the root-scoped `sidebar.panellist` list. Each list entry supplies its icon and an id matching its main entry; its string or locale-aware label provides plain visible text, the accessible name, and the collapsed tooltip. The shipped composition registers no panel entry, so the empty list has no DOM or spacing. Selection validates the live main entry and rejects a missing key without replacing the current panel.
+
+One eagerly created root store is shared by the renderer and layout controller. Its `panelInfo` and `layoutInfo` objects preserve independent references. The framework supplies `usePanelInfo`; individual rows and main content subscribe to their required selection values, while AppFrame reads only layout information. The right Sidebar's root controller decides whether to mount its Session subtree and reports the resulting track requirements to the frame.
+
+`uiWorkspace.openSession(id)` selects the Session before returning the main area to the Conversation, including when the same Session is selected again. `openWorkspace` and `forkSession` use the layout's `beginNavigation()` abort signal and their own service lifetime to commit only the latest navigation. The Workspace preparation callback moves drafts synchronously only while the request remains current. Supersession prevents a late UI commit, not Session creation. Panel navigation neither cancels the retained Session nor writes a Session event.
+
+DOM focus is not navigation selection. Search and directory-picker controls can receive focus while the global panel and its selected sidebar row remain visible; opening a Session changes the main selection.
+
+## Alternatives considered
+
+**Session-scoped main views.** Their lifetime and standard props bind application-wide state to whichever Session happens to be current.
+
+**A second navigation stack.** Back buttons and saved return destinations are unnecessary when New Session and workspace Session rows already provide explicit destinations.
+
+**React title slots.** Navigation entries use the same plain label for visible text and accessibility; a separate title registration is outside that presentation.
+
+**Flat selection and layout state with shallow comparison.** Separating the two stored objects preserves reference equality directly and avoids allocating and comparing a fresh layout projection on every panel selection.
+
+## Consequences
+
+The default sidebar snapshots remain unchanged. Extension panels have no right Sidebar, and selecting a different global panel does not change layout preferences. Switching between a Conversation with a visible right Sidebar and a global panel still changes the required column widths; this is not a promise of zero browser layout work.
+
+Panel selection is transient and resets on reload. Plugin disposal removes its contributions; removing the selected main entry returns the main area to the Conversation. Tests register real temporary panels and cover row interaction, focus, independent stored references, invalid ids, superseded asynchronous navigation, declaration lifetimes, and the empty default sidebar. The [Slots reference](../../../../docs/subsystems/slots.md) owns the composition API.
diff --git a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md
new file mode 100644
index 0000000000..343f183367
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md
@@ -0,0 +1,37 @@
+# Agent Note: 不增加默认界面的全局主面板
+
+Status: implemented
+
+[English](2026-09-08-global-main-panels.md) | 中文
+
+## 问题
+
+插件需要不属于任何会话的应用级视图。会话作用域的 Conversation 视图无法提供这种生命周期,而替换 Conversation 的 single slot 又会移除普通会话界面。增加此扩展不能在默认应用中增加导航控件或预留空间。
+
+## 决策
+
+布局声明 root 作用域的 keyed `main` slot。保留的 `conversation` key 属于 Conversation 插件,其 `main.conversation` 子 slot 保留可选的会话绑定。其他主面板条目不获得隐式会话绑定。
+
+侧栏拥有 root 作用域的 `sidebar.panellist` list。每个 list 条目提供图标,以及与主面板条目匹配的 id;字符串或随语言变化的标签提供普通可见文字、无障碍名称和折叠提示。默认组合不注册面板条目,因此空列表没有 DOM 或间距。选中操作检查实时主面板条目,对缺失的 key 报错而不替换当前面板。
+
+渲染器与布局控制器共享一个直接创建的 root 存储。其 `panelInfo` 和 `layoutInfo` 对象保持独立的引用。框架提供 `usePanelInfo`;各行和中央内容订阅所需的选中态值,AppFrame 仅读取布局信息。右侧 Sidebar 的 root 控制器决定是否挂载其会话子树,并把最终所需的列宽报告给框架。
+
+`uiWorkspace.openSession(id)` 先选中会话,再将中央区域切回 Conversation,包括再次选中同一个会话的情况。`openWorkspace` 和 `forkSession` 使用布局的 `beginNavigation()` abort signal 与自身 service 生命周期,只提交最新导航。工作区准备回调仅在请求仍有效时同步搬移草稿。请求过期会阻止晚到的 UI 提交,但不阻止会话创建。面板导航既不取消保留的会话,也不写入会话事件。
+
+DOM 焦点不是导航选中态。搜索和目录选择控件可以获得焦点,同时保留全局面板及其侧栏行的选中态;打开会话才改变中央区域的选中态。
+
+## 考虑过的替代方案
+
+**会话作用域的主视图。** 其生命周期和标准 props 会把应用级状态绑定到恰好处于当前态的会话。
+
+**另一套导航栈。** 新会话和工作区会话行已经提供明确目标,不需要返回按钮或保存返回目的地。
+
+**React 标题 slot。** 导航条目的可见文字与无障碍名称使用同一个普通标签;独立的标题注册不属于这一呈现方式。
+
+**平铺选中态和布局状态,再做浅比较。** 将两者存为独立对象可以直接保持引用相等,避免每次选择面板都分配并比较新的布局投影。
+
+## 后果
+
+默认侧栏快照保持不变。扩展面板没有右侧 Sidebar,选择另一个全局面板不会改变布局偏好。在显示右侧 Sidebar 的 Conversation 与全局面板之间切换时,所需列宽仍会变化;这并不保证浏览器完全不计算布局。
+
+面板选中态是瞬时状态,刷新后重置。插件 dispose(资源释放)会移除其贡献;移除当前选中的主面板条目会使中央区域回到 Conversation。测试注册真实临时面板,覆盖行交互、焦点、存储引用的独立性、无效 id、过期异步导航、声明生命周期和默认空侧栏。[Slots 参考](../../../../docs/subsystems/slots.zh.md)拥有组合 API 的说明。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml
new file mode 100644
index 0000000000..43f6f3b14c
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md
+2026-09-07-pi-ai-settings-catalog-recovery.md: 21fe532a491775ff875f6b9bcb000d917a95e13c
+2026-09-07-pi-ai-settings-catalog-recovery.zh.md: 80bd0758e13b767671f4cec52ee3832c9250651f
diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md
new file mode 100644
index 0000000000..21fe532a49
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md
@@ -0,0 +1,39 @@
+# Agent Note: Repairable pi-ai settings after catalog changes
+
+Status: implemented
+
+English | [中文](2026-09-07-pi-ai-settings-catalog-recovery.zh.md)
+
+## Problem
+
+An installed pi-ai catalog can change the validity of unchanged user settings. OpenRouter models outside the catalog can inherit a protocol while all shipped models agree; adding a second protocol removes that inference. Removing a catalog model also invalidates an override keyed by its former id. Rejecting the entire settings namespace at registration makes unrelated providers disappear and removes the controls needed to repair the configuration.
+
+## Decision
+
+The pi-ai consumer uses the existing settings `validate` callback. During namespace registration it tolerates catalog diagnostics; after registration it strictly checks changed providers against the current resolved section. Settings invokes this callback before persistence for update, replacement, and path mutation. External reload uses the same strict check and retains the last accepted section on failure. The settings service and its public API remain unchanged.
+
+Initial profile resolution retains catalog diagnostics, while schema and self-contained profile constraints still reject loading. Writes strictly resolve each new or changed provider, comparing effective provider values against the committed snapshot. Unchanged failed providers do not block another provider's edit, and deletion remains possible. Editing a provider-wide setting validates all models it affects.
+
+Profile resolution keeps valid models beside per-model errors. A missing override retains its diagnostic without disabling the remaining catalog. A route-level catalog failure retains its provider and editable settings but supplies no callable models. When route-wide validation aborts catalog resolution, the incomplete catalog and its collected per-model diagnostics are discarded; model requests on that route report the route-level error. The adapter checks the selected model's recorded failure before credentials or network I/O and reports `INVALID_CONFIG`. No protocol is guessed and no user configuration is rewritten during loading. Immutable snapshots still keep an in-flight request on its captured configuration.
+
+`LlmConfigurableProvider.error` carries the first available model diagnostic for the provider row, falling back to the route error. A provider-construction failure does not overwrite a collected model diagnostic, preserving the specific correction for a missing protocol. The configurable-provider directory publishes diagnostic changes so configuration repair refreshes the browser without re-registering the adapter. Failed model ids remain in settings, while the model selector receives serviceable entries. Models settings displays the diagnostic and retains edit/delete controls. Both add actions require their owning settings namespace; the ordinary add menu filters out unavailable namespaces.
+
+This extends the [provider-routed adapter decision](../architecture/2026-07-14-provider-routed-llm-adapters.md): provider ownership and request snapshots remain unchanged, while catalog validity does not determine whether settings can be managed. That note remains active for routing, ownership, and replay rationale.
+
+## Alternatives considered
+
+**Reject catalog errors at registration.** This prevents users from repairing an otherwise parseable configuration and lets an unused stale model disable unrelated providers.
+
+**Relax save validation too.** A newly entered model with no inferable protocol can be rejected immediately with the offending provider and model named. Accepting it creates an avoidable request-time failure.
+
+**Strictly revalidate the entire namespace on every save.** An unrelated provider's old error would block adding a healthy provider or repairing providers independently.
+
+**Assign OpenRouter a fixed route protocol.** A route override replaces every model's protocol and can change working catalog entries that intentionally use another API.
+
+## Consequences
+
+Upgrade-dependent errors remain visible and repairable without weakening validation of new provider edits. Configuration errors remain distinct from remote model existence: a catalog-external id with an explicit protocol is accepted, and its endpoint decides whether that id exists. Scalar or document errors still fail early. Models settings does not explain namespace registration failures; those errors require inspecting the configuration and startup diagnostics. No settings API, storage format, or session event is added; configurable-provider entries gain one optional diagnostic field.
+
+## Testing
+
+Adapter tests cover mixed valid/invalid models, deleted override referents, independent provider edits, route deletion, pre-network failure, and repair. A file-watcher regression verifies that invalid external edits retain the last accepted profiles and a repaired file takes effect. The assembled Web expectation boots with stale OpenRouter settings, preserves zai and both add controls, rejects an invalid save without changing the file, and repairs the route by removing the stale model. Existing snapshot tests continue to own request freezing and replay behavior.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md
new file mode 100644
index 0000000000..80bd0758e1
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md
@@ -0,0 +1,39 @@
+# Agent Note: pi-ai 目录变化后可修复的设置
+
+Status: implemented
+
+[English](2026-09-07-pi-ai-settings-catalog-recovery.md) | 中文
+
+## Problem
+
+已安装的 pi-ai 目录可能改变未修改用户设置的有效性。OpenRouter 的目录外模型可以在所有内置模型协议一致时继承协议;加入第二种协议会使这种推断失效。删除目录模型也会使按其旧 ID 声明的覆盖失效。注册时拒绝整个 settings 命名空间,会让无关提供方消失,也移除了修复配置所需的控件。
+
+## Decision
+
+pi-ai 消费者使用现有 settings `validate` 回调。命名空间注册期间容忍目录诊断;注册完成后,将变化的提供方与当前已解析分节比较并严格校验。Settings 在更新、整体替换和路径修改的持久化之前调用此回调。外部重载使用相同的严格校验,失败时保留最后一次接受的分节。Settings 服务及其公共 API 保持不变。
+
+首次解析 profile 时保留目录诊断,而 schema 与 profile 自身的约束仍会拒绝加载。写入会将有效提供方值与已提交快照比较,严格解析每个新增或修改的提供方。未修改的错误提供方不会阻止其他提供方编辑,删除仍然可用。修改提供方级设置会校验其影响的所有模型。
+
+Profile 解析在有效模型旁保留逐模型错误。失去引用目标的覆盖会保留诊断,而不会禁用其余目录。路由级目录失败会保留提供方与可编辑设置,但不提供可调用模型。路由级校验中止目录解析时,不完整的目录及其已收集的逐模型诊断会被丢弃,该路由上的模型请求统一报告路由级错误。适配器在解析凭据和网络 I/O 前检查所选模型已记录的错误,并报告 `INVALID_CONFIG`。加载过程不会猜测协议或改写用户配置。不可变快照仍保证进行中的请求使用其捕获的配置。
+
+`LlmConfigurableProvider.error` 优先为提供方行携带首个模型诊断,无模型诊断时返回路由错误。提供方构造失败不会覆盖已收集的模型诊断,从而保留缺少协议时的具体修复提示。可配置提供方目录发布诊断变化,修复配置会刷新浏览器,无需重新注册适配器。错误模型 ID 保留在设置中,模型选择器只接收可服务条目。模型设置页显示诊断并保留编辑、删除控件。两个添加操作都要求其所属 settings 命名空间存在;普通添加菜单会过滤不可用的命名空间。
+
+本决策扩展了[按提供方路由的适配器决策](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):提供方所有权与请求快照不变,目录有效性不决定设置是否可管理。旧记录仍保留为路由、所有权与回放设计的依据。
+
+## Alternatives considered
+
+**在注册时拒绝目录错误。** 这会阻止用户修复结构可解析的配置,并让未使用的过期模型禁用无关提供方。
+
+**同时放宽保存校验。** 对无法推断协议的新模型,可以立即拒绝并点名提供方与模型。接受它只会制造可避免的请求时错误。
+
+**每次保存都严格重校验整个命名空间。** 无关提供方的旧错误会阻止添加正常提供方,或逐个修复提供方。
+
+**为 OpenRouter 指定固定路由协议。** 路由覆盖会替换所有模型的协议,可能改变有意使用其他 API 的正常目录条目。
+
+## Consequences
+
+依赖升级产生的错误仍可见、可修复,而新增提供方编辑的校验不会放宽。配置错误与远端模型是否存在仍然不同:显式指定协议的目录外 ID 可以被接受,由端点决定该 ID 是否存在。标量或文档错误仍尽早失败。模型设置页不展示命名空间注册失败的原因,此类错误需要检查配置与启动诊断。不增加 Settings API、存储格式或 session 事件;可配置提供方条目新增一个可选诊断字段。
+
+## Testing
+
+适配器测试覆盖有效与错误模型混合、覆盖目标被删除、独立提供方编辑、路由删除、联网前失败及修复。文件监听回归验证非法外部编辑保留最后一次接受的 profile,而修复后的文件能够生效。完整 Web 期望测试从过期 OpenRouter 设置启动,保留 zai 与两个添加控件,在不改变文件的前提下拒绝无效保存,并通过删除过期模型修复路由。已有快照测试继续负责请求冻结与回放行为。
diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml
index 55a907edf1..97110cbb0c 100644
--- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md
-2026-07-31-even-out-shipped-tool-rosters.md: df334bc0bd9d011ea2e2f4ee938fb29c4c4bfe2c
-2026-07-31-even-out-shipped-tool-rosters.zh.md: ddee380f836bf50a733541b2d3ea6dd8a74126a2
+2026-07-31-even-out-shipped-tool-rosters.md: caadb4601a56e9180ccb8d1eae571208941cbcba
+2026-07-31-even-out-shipped-tool-rosters.zh.md: 9503baf9f1f8f2567c3ccd81db685c74db189430
diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md
index df334bc0bd..caadb4601a 100644
--- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md
+++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md
@@ -12,7 +12,7 @@ The result was a user-visible difference nobody had decided: the same model, ask
## Decision
-The rows that are not surface-specific move into [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-reminder`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces assemble the same roster, including fixed `glob` and `grep` members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../../archived/architecture/2026-08-01-packaged-ripgrep-search.md). Two later decisions narrow that roster: the [session-search decision](../../archived/feature/2026-08-02-session-search-not-shipped-default.md) keeps `tool-session-query` opt-in, and the [single-editor decision](../../archived/simplification/2026-08-10-default-presets-single-editor.md) keeps `tool-str-replace-editor` out of the general-purpose presets while retaining it in `minimal`.
+The rows that are not surface-specific move into [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-reminder`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces assemble the same roster, including fixed `glob` and `grep` members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../../archived/architecture/2026-08-01-packaged-ripgrep-search.md). Later decisions narrow that roster: the [session-search decision](../../archived/feature/2026-08-02-session-search-not-shipped-default.md) keeps `tool-session-query` opt-in, the [single-editor decision](../../archived/simplification/2026-08-10-default-presets-single-editor.md) removes `tool-str-replace-editor` from general-purpose presets, and the [persistent-shell-only decision](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.md) removes it from the minimal compositions.
Two rows stay surface-specific. `tmux-context` is TUI-only because a browser surface has no terminal multiplexer to describe. `session-reference` is TUI-only because it drives the shared session-query index from the launcher's process-local path, and the browser sidebar reconciles that index on its own first search.
diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md
index ddee380f83..9503baf9f1 100644
--- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md
@@ -12,7 +12,7 @@ Status: implemented
## 决策
-那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-reminder`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.zh.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单,其中 `glob` 和 `grep` 是固定成员,因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../../archived/architecture/2026-08-01-packaged-ripgrep-search.md)。之后有两项决策收窄这份清单:[session-search 决策](../../archived/feature/2026-08-02-session-search-not-shipped-default.md)让 `tool-session-query` 保持需显式启用,[单一编辑器决策](../../archived/simplification/2026-08-10-default-presets-single-editor.md)让通用 preset 不提供 `tool-str-replace-editor`,但在 `minimal` 中保留它。
+那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-reminder`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.zh.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单,其中 `glob` 和 `grep` 是固定成员,因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../../archived/architecture/2026-08-01-packaged-ripgrep-search.md)。后续决策收窄了这份清单:[session-search 决策](../../archived/feature/2026-08-02-session-search-not-shipped-default.md)让 `tool-session-query` 保持需显式启用,[单一 editor 决策](../../archived/simplification/2026-08-10-default-presets-single-editor.md)从通用 preset 移除 `tool-str-replace-editor`,[仅持久 shell 决策](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md)则从极简组合移除它。
有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。
diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml
index 5a8b133ed6..98786c2a8b 100644
--- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md
-2026-08-11-minimal-profiles-bare-two-tool-runtime.md: 9e4d476c36ab6f920481b1281058d888710a2ef5
-2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: 761c384f1656ce66d027eb2b13c9dbc4f20f3686
+2026-08-11-minimal-profiles-bare-two-tool-runtime.md: 8cfa4aa71e33317947d0661641df1e6e814f5e90
+2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: f1f916bdde92fd104c3d879ff058b2e0474eef38
diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md
index 9e4d476c36..8cfa4aa71e 100644
--- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md
+++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md
@@ -1,4 +1,4 @@
-# Agent Note: Minimal profiles use the bare two-tool runtime
+# Agent Note: Minimal profiles use a bare runtime
Status: implemented
@@ -12,23 +12,23 @@ The two launch paths also have different configuration owners. Web mounts a per-
## Decision
-The shipped Web minimal preset exposes persistent `bash` and `str_replace_editor`; the standalone profile exposes persistent `bash` on Linux/macOS or `pwsh` on Windows, plus the same editor. Both mount no context-compaction provider, suppress every `dsh-system-prompt` runtime-context contribution for fresh sessions, and run the editor against `@deepseek-ai/dsh-fs-local`. The Web preset isolates `ctx.fs` inside the agent entry and mounts `fs-local` beside the editor, so other Web agents retain the host filesystem provider. Its persona remains the fixed complete prompt owned by the earlier [minimal-preset composition decision](../../archived/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) and applies runtime-context suppression only to that agent scope. The standalone spine forwards the same setting to its process-owned system-prompt service. The Web host retains its sandbox and approval services; the standalone profile mounts a danger-full-access sandbox policy and no approval service. Neither contributes model-facing policy context.
+The shipped Web minimal preset exposes persistent `bash`; the standalone profile exposes persistent `bash` on Linux/macOS or `pwsh` on Windows. Both mount no context-compaction or filesystem provider and suppress every `dsh-system-prompt` runtime-context contribution for fresh sessions. The [persistent-shell-only decision](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.md) removes the editor and its otherwise-unused `fs-local` provider from both compositions. The Web preset's persona remains the fixed complete prompt owned by the earlier [minimal-preset composition decision](../../archived/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) and applies runtime-context suppression only to that agent scope. The standalone spine forwards the same setting to its process-owned system-prompt service. The Web host retains its sandbox and approval services; the standalone profile mounts a danger-full-access sandbox policy and no approval service. Neither contributes model-facing policy context.
-The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) remains a complete JSON-RPC process composition behind `dsh --profile sdk-minimal`. It mounts SDK startup and JSON-RPC serving, the local PTY and subprocess services required by the platform-selected persistent shell, `fs-local`, that shell's tool consumer, the editor, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not mount `token-meter`, `compaction-basic`, `fs-sandbox`, or `fs-observation-policy`. The persistent shell consumes the profile's danger-full-access sandbox policy; the editor is not confined by that policy. [docs/architecture.md](../../../../docs/architecture.md) owns this bundle placement and its separation from `dsh-base`.
+The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) remains a complete JSON-RPC process composition behind `dsh --profile sdk-minimal`. It mounts SDK startup and JSON-RPC serving, the local PTY and subprocess services required by the platform-selected persistent shell, that shell's tool consumer, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not mount `token-meter`, `compaction-basic`, `fs-local`, `fs-sandbox`, `fs-observation-policy`, or a filesystem tool. The persistent shell consumes the profile's danger-full-access sandbox policy. [docs/architecture.md](../../../../docs/architecture.md) owns this bundle placement and its separation from `dsh-base`.
`DSH_SYSTEM_PROMPT` selects the standalone persona, and `DSH_CONTEXT_WINDOW` supplies fallback capacity for a model without exact catalog metadata. The SDK client's JSON-RPC `initialize` request is the sole runtime model selection. [`minimal.py`](../../../../python/sdk/examples/minimal.py) may read `DSH_MODEL` only as the command's default `model` argument; an explicit `--model` needs no matching child environment value. Endpoint and credential variables stay owned by the DeepSeek adapter's existing environment-resolution path.
## Verification
-The Web replay boots the complete Web host, creates the agent through the preset service, and asserts that the scoped filesystem is bare, no scoped compaction service exists, no system-prompt-owned runtime-context message was appended, and the assembled request contains exactly the fixed prompt and two tools. It then executes persistent Bash and the editor against the real scoped services.
+The Web replay boots the complete Web host, creates the agent through the preset service, and asserts that no scoped filesystem or compaction service exists, no system-prompt-owned runtime-context message was appended, and the assembled request contains exactly the fixed prompt and persistent Bash. It then executes persistent Bash against the real scoped services.
-The SDK keyless source test boots real `dsh --profile sdk-minimal`, completes a turn with an environment-selected prompt, and asserts the generated one-bundle manifest. The Python SDK bundled-runtime snapshot owns the assembled prompt, exact two-tool catalog, and absence of every system-prompt-owned runtime-context message. Packaged-runtime coverage initializes the standalone profile through each available carrier with environment-selected model, model capacity, and prompt values, then executes the selected persistent shell and editor. Cordis validation checks that both configurations resolve their declared plugins and configuration fields.
+The SDK keyless source test boots real `dsh --profile sdk-minimal`, completes a turn with an environment-selected prompt, and asserts the generated one-bundle manifest. The Python SDK bundled-runtime snapshot owns the assembled prompt, exact single-tool catalog, and absence of every system-prompt-owned runtime-context message. Packaged-runtime coverage initializes the standalone profile through each available carrier with environment-selected model, model capacity, and prompt values, then executes the selected persistent shell. Cordis validation checks that both configurations resolve their declared plugins and configuration fields.
## Alternatives considered
**Keep `compaction-basic` mounted with a high threshold.** Rejected because even an inert-for-short-tests provider permits history replacement in longer sessions and leaves the minimal composition dependent on model-capacity metadata and the token meter.
-**Keep `fs-sandbox` in danger-full-access mode.** Rejected because the sandboxed provider still makes confinement and escalation part of the editor capability. The target runtime requires the bare local provider, whose lack of `sandboxMode` is composition truth.
+**Keep `fs-local` after removing the editor.** Rejected because neither minimal composition has another filesystem consumer. Retaining the provider would increase the runtime roster without adding a model-visible capability.
**Use one Cordis leaf for Web and Python SDK startup.** Rejected because a Web preset contributes agent-scoped services to an existing multi-session host, while the Python SDK must launch a complete process containing the JSON-RPC server and its process-wide dependencies.
@@ -36,4 +36,4 @@ The SDK keyless source test boots real `dsh --profile sdk-minimal`, completes a
## Consequences
-Minimal sessions never summarize or replace earlier history and never add a runtime-context snapshot; callers must keep turns within the selected model's context capacity and must not rely on model-visible narration of standing sandbox or approval policy. The editor can address any absolute path visible to the runtime process, independently of the persistent shell's sandbox policy. The two launch paths share their model-facing tool, no-context, and no-compaction guarantees while retaining different prompt and model configuration appropriate to their owners. The Python SDK path communicates only through the bundled `dsh` stdio JSON-RPC profile.
+Minimal sessions never summarize or replace earlier history and never add a runtime-context snapshot; callers must keep turns within the selected model's context capacity and must not rely on model-visible narration of standing sandbox or approval policy. The two launch paths share their single-tool, no-filesystem, no-context, and no-compaction guarantees while retaining different prompt and model configuration appropriate to their owners. The Python SDK path communicates only through the bundled `dsh` stdio JSON-RPC profile.
diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md
index 761c384f16..f1f916bdde 100644
--- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md
@@ -1,4 +1,4 @@
-# Agent Note: minimal profile 使用裸双工具运行时
+# Agent Note: minimal profile 使用裸运行时
Status: implemented
@@ -12,23 +12,23 @@ Web `minimal` preset 与独立 JSON-RPC minimal 组合对外提供持久 `bash`
## 决策
-随附 Web minimal preset 对外提供持久 `bash` 与 `str_replace_editor`;独立 profile 在 Linux/macOS 上提供持久 `bash`,在 Windows 上提供 `pwsh`,并提供相同 editor。两者都不挂载上下文压缩提供方,为新建会话抑制每个 `dsh-system-prompt` runtime-context 贡献,并让编辑器使用 `@deepseek-ai/dsh-fs-local`。Web preset 在 agent entry 内隔离 `ctx.fs`,将 `fs-local` 与编辑器一起挂载,因此其他 Web agent 仍使用宿主文件系统提供方。其 persona 继续采用较早的 [minimal preset 组合决策](../../archived/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md)所拥有的固定 complete 提示词,并仅为该 agent 作用域实施 runtime-context 抑制。独立 spine 将同一设置转发给其进程拥有的 system-prompt 服务。Web 宿主保留沙箱与批准服务;独立 profile 挂载 danger-full-access 沙箱策略,不挂载批准服务。两者都不贡献面向模型的策略上下文。
+随附 Web minimal preset 对外提供持久 `bash`;独立 profile 在 Linux/macOS 上提供持久 `bash`,在 Windows 上提供 `pwsh`。两者都不挂载上下文压缩或文件系统提供方,并为新建会话抑制每个 `dsh-system-prompt` runtime-context 贡献。[仅持久 shell 决策](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md)从两份组合中移除了编辑器及其原本除此之外无人使用的 `fs-local` 提供方。Web preset 的 persona 继续采用较早的 [minimal preset 组合决策](../../archived/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md)所拥有的固定 complete 提示词,并仅为该 agent 作用域实施 runtime-context 抑制。独立 spine 将同一设置转发给其进程拥有的 system-prompt 服务。Web 宿主保留沙箱与批准服务;独立 profile 挂载 danger-full-access 沙箱策略,不挂载批准服务。两者都不贡献面向模型的策略上下文。
-独立的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)仍是 `dsh --profile sdk-minimal` 后面的完整 JSON-RPC 进程组合。它挂载 SDK 启动与 JSON-RPC 服务、按平台选择的持久 shell 所需的本地 PTY 和子进程服务、`fs-local`、该 shell 的工具消费方、editor,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-sandbox` 或 `fs-observation-policy`。持久 shell 消费该 profile 的 danger-full-access 沙箱策略;编辑器不受该策略限制。[docs/architecture.md](../../../../docs/architecture.zh.md) 负责该组合包的位置及其与 `dsh-base` 的分离。
+独立的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)仍是 `dsh --profile sdk-minimal` 后面的完整 JSON-RPC 进程组合。它挂载 SDK 启动与 JSON-RPC 服务、按平台选择的持久 shell 所需的本地 PTY 和子进程服务、该 shell 的工具消费方,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-local`、`fs-sandbox`、`fs-observation-policy` 或文件系统工具。持久 shell 消费该 profile 的 danger-full-access 沙箱策略。[docs/architecture.md](../../../../docs/architecture.zh.md) 负责该组合包的位置及其与 `dsh-base` 的分离。
`DSH_SYSTEM_PROMPT` 选择独立组合的 persona,`DSH_CONTEXT_WINDOW` 为没有确切目录元数据的模型提供后备容量。SDK 客户端的 JSON-RPC `initialize` 请求是唯一运行时模型选择。[`minimal.py`](../../../../python/sdk/examples/minimal.py)可以只把 `DSH_MODEL` 读作命令的默认 `model` 参数;显式 `--model` 不需要匹配的子进程环境值。端点与凭据变量继续由 DeepSeek 适配器现有的环境解析路径持有。
## 验证
-Web 回放会启动完整 Web 宿主,通过 preset 服务创建 agent,并断言作用域文件系统为裸后端、不存在作用域压缩服务、没有追加 system-prompt 拥有的 runtime-context 消息,而且组装请求只包含固定提示词与两个工具。随后,它通过真实作用域服务执行持久 Bash 和编辑器。
+Web 回放会启动完整 Web 宿主,通过 preset 服务创建 agent,并断言不存在作用域文件系统或压缩服务、没有追加 system-prompt 拥有的 runtime-context 消息,而且组装请求只包含固定提示词与持久 Bash。随后,它通过真实作用域服务执行持久 Bash。
-SDK keyless 源码测试启动真实 `dsh --profile sdk-minimal`,使用环境选择的提示词完成一个回合,并断言生成的单组合包 manifest。Python SDK 打包运行时快照固定组装提示词、精确双工具目录,并固定不存在任何 system-prompt 所拥有的 runtime-context 消息。打包运行时覆盖会通过每种可用载体,使用环境选择的模型、模型容量和提示词值初始化独立 profile,然后执行所选持久 shell 与 editor。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。
+SDK keyless 源码测试启动真实 `dsh --profile sdk-minimal`,使用环境选择的提示词完成一个回合,并断言生成的单组合包 manifest。Python SDK 打包运行时快照固定组装提示词、精确单工具目录,并固定不存在任何 system-prompt 所拥有的 runtime-context 消息。打包运行时覆盖会通过每种可用载体,使用环境选择的模型、模型容量和提示词值初始化独立 profile,然后执行所选持久 shell。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。
## 考虑过的替代方案
**以较高阈值保留 `compaction-basic`。** 不予采用,因为即便提供方在短测试中未触发,较长会话仍允许替换历史记录,而且 minimal 组合仍会依赖模型容量元数据与 token meter。
-**在 danger-full-access 模式下保留 `fs-sandbox`。** 不予采用,因为沙箱提供方仍会使限权与提权成为编辑器能力的一部分。目标运行时要求裸本地提供方,而其不具备 `sandboxMode` 正是组合事实。
+**移除编辑器后保留 `fs-local`。** 不予采用,因为两份 minimal 组合都没有其他文件系统消费方。保留该提供方只会扩大运行时清单,不会新增模型可见能力。
**为 Web 与 Python SDK 启动使用同一个 Cordis leaf。** 不予采用,因为 Web preset 向现有多会话宿主贡献 agent 作用域服务,而 Python SDK 必须启动包含 JSON-RPC 服务器及其进程级依赖的完整进程。
@@ -36,4 +36,4 @@ SDK keyless 源码测试启动真实 `dsh --profile sdk-minimal`,使用环境
## 后果
-Minimal 会话不会摘要或替换较早历史,也不会添加 runtime-context 快照;调用方必须让会话轮次保持在所选模型的上下文容量内,且不得依赖模型可见的常驻沙箱或批准策略说明。编辑器可以访问运行时进程可见的任何绝对路径,且不受持久 shell 沙箱策略影响。两条启动路径共享面向模型的工具、无上下文与无压缩保证,同时保留适合各自所有者的不同提示词和模型配置。Python SDK 路径只通过内置 `dsh` stdio JSON-RPC profile 通信。
+Minimal 会话不会摘要或替换较早历史,也不会添加 runtime-context 快照;调用方必须让会话轮次保持在所选模型的上下文容量内,且不得依赖模型可见的常驻沙箱或批准策略说明。两条启动路径共享单工具、无文件系统、无上下文与无压缩保证,同时保留适合各自所有者的不同提示词和模型配置。Python SDK 路径只通过内置 `dsh` stdio JSON-RPC profile 通信。
diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml
new file mode 100644
index 0000000000..27af3c5438
--- /dev/null
+++ b/.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
diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
new file mode 100644
index 0000000000..6239c9bd92
--- /dev/null
+++ b/.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.
diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md
new file mode 100644
index 0000000000..7aa5bebc97
--- /dev/null
+++ b/.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 导出。
diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml
index 91c1bc8481..95ff31f9f1 100644
--- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.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/process/2026-07-30-generated-third-party-notices.md
-2026-07-30-generated-third-party-notices.md: b81f386f0d0e820b0361c775fb4c45a9e633d04b
-2026-07-30-generated-third-party-notices.zh.md: d3b3e1686bfdf8901902f995205c1dbdd6dfbf38
+2026-07-30-generated-third-party-notices.md: 41ac0c75ca55c81f2055c867bd029ee7e4a350f9
+2026-07-30-generated-third-party-notices.zh.md: 4097831828287e7180cf37d713fa4694ae028b31
diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md
index b81f386f0d..41ac0c75ca 100644
--- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md
+++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md
@@ -20,7 +20,7 @@ One trigger gap is accepted rather than worked around: lefthook inspects only fi
The file discloses **direct** dependencies by default. The complete npm closure with pinned versions already lives in `pnpm-lock.yaml` (`pnpm licenses list` renders it) and the Python closure in `python/sdk/uv.lock`; re-materializing either as prose would be a second, worse copy. The one explicit transitive disclosure is the official Claude platform payload set declared by `@anthropic-ai/claude-agent-sdk` through `optionalDependencies`, because those packages carry the distributed Claude Code executable rather than ordinary library implementation detail.
-**Tiering is by declaring area, not by manifest section.** A package is a runtime dependency when any manifest outside `DEV_ONLY_AREAS` — the root manifest, `packages/test-support/`, `packages/test-support/client-runtime/`, `website/`, `native/` — names it under `dependencies` or `optionalDependencies`. Section names alone are wrong in both directions: a test-support package declares `vitest` under `dependencies` without shipping it, and the root source-run scripts execute through `tsx`, which no manifest declares as a runtime dependency at all (the generator marks it runtime explicitly).
+**Tiering follows distribution, not manifest section.** Installed runtime libraries are identified by `dependencies` or `optionalDependencies` outside `DEV_ONLY_AREAS` — the root manifest, `packages/test-support/`, `packages/test-support/client-runtime/`, `website/`, `native/`. Browser inputs resolved by the shipping tsdown and Vite configurations also count as runtime, even in `devDependencies`; [browser third-party build inputs](2026-09-08-browser-third-party-build-inputs.md) owns that classification. Test-support dependencies do not ship merely because their manifest says `dependencies`, and the generator explicitly discloses `tsx` because source launches execute through its ESM hook.
The runtime tier deliberately covers **every mountable plugin**, not just what the CLI, Web UI, and Python runtime load by default. Source execution can mount any plugin package from a user's `cordis.yml`; `@modelcontextprotocol/sdk` and the OpenTelemetry packages therefore reach real users even though no default assembly imports them. Under-disclosure is the costly direction for a legal notice.
diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md
index d3b3e1686b..4097831828 100644
--- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md
+++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md
@@ -20,7 +20,7 @@ Status: implemented
文件默认只披露**直接**依赖。完整的 npm 闭包连同锁定版本已记录在 `pnpm-lock.yaml`(`pnpm licenses list` 可渲染),Python 闭包记录在 `python/sdk/uv.lock`;再用散文誊一遍只会得到一份更差的副本。唯一明确披露的传递依赖,是 `@anthropic-ai/claude-agent-sdk` 通过 `optionalDependencies` 声明的官方 Claude 平台载荷集合,因为这些包承载随产品分发的 Claude Code 可执行文件,而非普通的库实现细节。
-**分层依据是声明方所在区域,而非 manifest 字段名。** 只要 `DEV_ONLY_AREAS` 之外的任一 manifest——即根 manifest、`packages/test-support/`、`packages/test-support/client-runtime/`、`website/`、`native/` 之外——在 `dependencies` 或 `optionalDependencies` 里点名某个包,它就是运行时依赖。单看字段名在两个方向上都会出错:测试支撑包把 `vitest` 写在 `dependencies` 里却并不交付它;而根目录的源码运行脚本通过 `tsx` 执行,根本没有任何 manifest 把它声明为运行时依赖,只能由生成器显式标记。
+**分层依据是分发内容,而非 manifest 字段名。** 安装的运行时库由 `DEV_ONLY_AREAS` 之外的 `dependencies` 或 `optionalDependencies` 识别;排除区域为根 manifest、`packages/test-support/`、`packages/test-support/client-runtime/`、`website/`、`native/`。发布所用的 tsdown 与 Vite 配置解析到的浏览器输入也属于运行时,即使它们位于 `devDependencies`;[浏览器第三方构建输入](2026-09-08-browser-third-party-build-inputs.zh.md)拥有这项分类。测试支撑依赖不会仅因字段写成 `dependencies` 就被交付,而生成器显式披露 `tsx`,因为源码启动通过其 ESM 钩子执行。
运行时层刻意覆盖**所有可挂载的插件**,而不止 CLI、Web UI 与 Python 运行时默认加载的那些。从源码运行时,用户可以通过 `cordis.yml` 挂载任何插件包;因此,`@modelcontextprotocol/sdk` 与 OpenTelemetry 系列即使没有任何默认装配引入,也会触达真实用户。对法务披露而言,披露不足才是代价更高的那个方向。
diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml
index 5edb76739c..692a6fe140 100644
--- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.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/process/2026-08-10-npm-release-sequences.md
-2026-08-10-npm-release-sequences.md: c403964d8de1c158e5949b5e112a038832709874
-2026-08-10-npm-release-sequences.zh.md: d03da4c4485c4807497dfb28b61ab342bb4c8d12
+2026-08-10-npm-release-sequences.md: c039db2c7463f93f6f8847ae0ae6100df650f2a5
+2026-08-10-npm-release-sequences.zh.md: 1f14c03beb57d3a574305b348379b1542836083c
diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md
index c403964d8d..c039db2c74 100644
--- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md
+++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md
@@ -123,6 +123,8 @@ A dsh verification installs the vendored family's pack output too. The harness p
The verification also packs the Landlock entry, which `dsh-sandbox-local` declares as a plain dependency, and omits optional dependencies. The platform packages behind those optional entries need a musl toolchain and one build per architecture, so a job on one runner cannot produce them; a consumer that cannot install them must still start, which is what optional means here. The verification therefore reads a directory by its contents rather than a pack order, because a directory can hold tarballs packed only to satisfy a cross-sequence dependency.
+The installed-consumer probe captures npm's HTTP diagnostics and includes them when installation fails. Registry response codes and cache status remain visible even when npm reports a failed peer manifest fetch as `ERESOLVE` with an undefined version.
+
### Repository changes this carried
| Item | Content |
diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md
index d03da4c448..1f14c03beb 100644
--- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md
+++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md
@@ -123,6 +123,8 @@ dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 ven
验证还会打一份 Landlock entry 的 tarball——`dsh-sandbox-local` 把它声明为普通 `dependencies`——同时略去可选依赖。那些可选项背后的平台包需要 musl 工具链且每个架构各构建一次,单台 runner 产不出来;而装不到它们的消费方也必须能起,这正是「可选」在这里的含义。因此验证按目录内容读取 tarball,而不是读发布顺序:一个目录可能只装着为满足跨序列依赖而打出来的包,任何发布顺序都不描述它。
+已安装消费方探针会捕获 npm 的 HTTP 诊断信息,并在安装失败时输出。即使 npm 将 peer manifest 获取失败报告为版本未定义的 `ERESOLVE`,日志中仍能看到 registry 响应码和缓存状态。
+
### 本次带出的仓库改动
| 项 | 内容 |
diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml
index d44c9594bb..d24d629c9f 100644
--- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.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/process/2026-08-26-published-dependency-faces.md
-2026-08-26-published-dependency-faces.md: 25e9f2ce139a7cd4efb64dbe71d49d8c9f88c24b
-2026-08-26-published-dependency-faces.zh.md: ccc198b164b7450b6840862faf23b546c99fa2a6
+2026-08-26-published-dependency-faces.md: 1a94ccedcf6f614c7853c96c329e88d037167460
+2026-08-26-published-dependency-faces.zh.md: 4ac3f461018c21047af6359ca5e27d050da5a2c4
diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md
index 25e9f2ce13..1a94ccedcf 100644
--- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md
+++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.md
@@ -28,7 +28,7 @@ A workspace package reached by a runtime value import from the Host entry closur
An export whose constructor identity or module state must be shared appears in `peerRequiredHostExports`; importing one such export keeps the whole package edge in matching `peerDependencies` and `devDependencies`. Each export-table key is an exact module specifier and each value is a reviewed export set. The verifier follows runtime local imports from the Host entry, records named and default imports and re-exports, and rejects exports covered by neither the package list nor an export table; namespace, dynamic, and side-effect imports remain unbounded unless the complete exact entry is package-classified.
-Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, invariant companions, and existing metadata-only peers belong only in `devDependencies`. Ordinary third-party packages imported by the Host runtime belong in `dependencies`; other third-party relationships keep their declared section. Workspace references use `workspace:^`.
+Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, and existing metadata-only peers belong only in `devDependencies`. Host runtime imports, including additional Node entries, follow the Host classification. [Browser third-party build inputs](2026-09-08-browser-third-party-build-inputs.md) governs ordinary third-party declarations; it partially supersedes their preservation in this decision. Workspace references use `workspace:^`.
Some development relationships exist only in `dsh.client.inject` or TypeScript project references. The policy's `configurationOnlyDevDependencies` table names only those reviewed edges and keeps them in `devDependencies`.
diff --git a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md
index ccc198b164..4ac3f46101 100644
--- a/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md
+++ b/.agents/notes/implemented/process/2026-08-26-published-dependency-faces.zh.md
@@ -28,7 +28,7 @@ Host 入口闭包中的运行期 value import 所到达的 workspace 包,只
constructor 身份或模块状态必须共享的导出列入 `peerRequiredHostExports`;一旦使用这类导出,整条包依赖边就保留在范围一致的 `peerDependencies` 与 `devDependencies` 中。每个导出表的 key 都是精确 module specifier,每个 value 都是经审查的导出集合。验证器从 Host 入口沿运行期本地 import 扫描,记录具名与默认 import 和 re-export,并拒绝既没有包级分类、也没有导出级分类的导出;除非完整的精确入口已按包分类,否则 namespace、dynamic 和 side-effect import 仍无法限定范围。
-Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject`、invariant companion 和仅有元数据的现存 peer 只属于 `devDependencies`。Host 运行时导入的普通第三方包属于 `dependencies`;其他第三方关系保持原区段。Workspace 引用使用 `workspace:^`。
+Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject` 和仅有元数据的现存 peer 只属于 `devDependencies`。Host 运行时 import(包括额外 Node 入口)遵循 Host 分类。[浏览器第三方构建输入](2026-09-08-browser-third-party-build-inputs.zh.md)规定普通第三方声明,部分取代本决策对它们原区段的保留。Workspace 引用使用 `workspace:^`。
部分开发期关系只存在于 `dsh.client.inject` 或 TypeScript project reference 中。策略的 `configurationOnlyDevDependencies` 表只列出这些已评审的依赖边,并将它们保留在 `devDependencies` 中。
diff --git a/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.i18n.yaml b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.i18n.yaml
new file mode 100644
index 0000000000..7fc8e364f2
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md
+2026-09-08-browser-third-party-build-inputs.md: 97e61f3b99955cb402f9e622b2d40e3ee2953d79
+2026-09-08-browser-third-party-build-inputs.zh.md: e23c4e9d4615c7c5bc788beafcf6b94699f211ce
diff --git a/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md
new file mode 100644
index 0000000000..97e61f3b99
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md
@@ -0,0 +1,35 @@
+# Agent Note: Browser third-party libraries as build inputs
+
+Status: implemented
+
+English | [中文](2026-09-08-browser-third-party-build-inputs.zh.md)
+
+## Problem
+
+Prebuilt browser plugins distribute their third-party implementations inside JavaScript, but production npm dependencies still make installers download those libraries separately and resolve their peers. When React is declared only for development, installers can select a different React version for those extra dependencies than the browser artifact uses. Both `use-sync-external-store@1.2.0` and `@tanstack/react-virtual@3.14.9` support the current React 18; this problem does not require a React upgrade.
+
+## Decision
+
+Browser-only third-party dependencies belong in `devDependencies`, including implementations inlined into dynamic plugins, static browser-library inputs, and React shared by the Web shell. This partially supersedes the preservation of ordinary third-party declarations in [published dependency faces](2026-08-26-published-dependency-faces.md); that note continues to govern package selection, Host value dependencies, and Cordis identity.
+
+The dependency classifier collects build inputs from source imports and JSX. Third-party libraries reachable by the Host runtime take precedence as `dependencies`; additional Node build entries must also be checked. Type-only source references do not create Host runtime dependencies. Existing configuration-metadata and shared-Host-export classifications remain unchanged.
+
+Npm sections do not select browser bundling behavior. Dynamic plugins inline private libraries and obtain React and other shared modules from the platform table; static browser libraries retain bare imports and styles for the final Vite build. Static packages are Web-shell build inputs, not independently installed libraries with every rebundling dependency provided. Source builds need development dependencies; installed published Web artifacts do not.
+
+License classification follows distributed content. Dependency resolution through the real browser build configurations covers dynamic plugins and the Web shell; resolved third-party implementations remain [runtime disclosures](2026-07-30-generated-third-party-notices.md) even when manifests declare them for development. Test tools, erased type imports, and build tools do not become distributed code merely by appearing in `devDependencies`.
+
+## Alternatives considered
+
+**Declare another production React or override peer resolution.** This retains an extra installed graph that the browser does not use, without making that installed copy the browser's shared instance.
+
+**Inline every static-library dependency early.** This changes Vite's third-party chunks, caching, and CSS handling; dependency classification does not require those build changes.
+
+**Move every third-party dependency of a Client-bearing package.** Dual-face packages still load Host libraries, including `fflate` for ZIP output and `zod` for RPC validation; those installation relationships must remain.
+
+**Classify license disclosures directly by manifest section.** Distributing browser code and having an installer download a same-named package are different facts; that distinction cannot remove license checks on distributed code.
+
+## Consequences
+
+Production dependencies do not install third-party libraries a second time solely for browser implementations. React and React DOM retain one build version, and plugins consume the Web shell's shared instance. Classification tests constrain browser dev-only inputs, Host precedence, and idempotent repair; publication checks reject React installation leaks, while browser artifact verification independently covers module loading.
+
+Classification remains source-based. License resolution likewise must not depend on existing `lib/` files or write build outputs; its tests cover real resolution, type erasure, asset references, and missing dependencies. Developers independently consuming static packages or public types supply the corresponding build dependencies themselves; this decision adds no standalone browser-library support promise.
diff --git a/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.zh.md b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.zh.md
new file mode 100644
index 0000000000..e23c4e9d46
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: 浏览器第三方库作为构建输入
+
+Status: implemented
+
+[English](2026-09-08-browser-third-party-build-inputs.md) | 中文
+
+## 问题
+
+预构建浏览器插件的第三方实现已经随 JavaScript 分发,但生产 NPM 依赖仍让安装器另外下载这些库并解析其对等依赖(peer dependency)。React 只在构建期声明时,安装器可以为这些额外依赖选择与浏览器产物不同的 React 版本。`use-sync-external-store@1.2.0` 与 `@tanstack/react-virtual@3.14.9` 都支持当前 React 18;问题不要求升级 React。
+
+## 决策
+
+浏览器专用第三方依赖属于 `devDependencies`,包括动态插件内联的实现、静态浏览器库的输入,以及由 Web 壳提供共享实例的 React。该规则部分取代[已发布依赖分类](2026-08-26-published-dependency-faces.zh.md)对普通第三方声明的保留策略;该文档继续规定包的选择范围、Host 值依赖与 Cordis 实例身份。
+
+依赖分类器从源码导入和 JSX 收集构建输入。Host 运行时可达的第三方库优先归入 `dependencies`;额外 Node 构建入口也必须纳入检查。源码中的纯类型引用不产生 Host 运行时依赖。配置元数据与共享 Host 导出的既有分类保持不变。
+
+NPM 字段不决定浏览器打包方式。动态插件继续内联私有库,并从平台模块表读取 React 等共享模块;静态浏览器库继续保留裸导入及样式,由最终 Vite 构建处理。静态包是 Web 壳的构建输入,不承诺独立安装后具备二次打包所需的全部依赖。源码构建需要开发依赖;安装已发布的 Web 产物不需要它们。
+
+许可证分类以交付内容为准。真实浏览器构建配置的依赖解析同时覆盖动态插件与 Web 壳;解析到的第三方实现仍计入[运行时披露](2026-07-30-generated-third-party-notices.zh.md),即使清单将其列为开发依赖。测试工具、纯类型导入和构建工具不会仅因处于 `devDependencies` 而算作分发代码。
+
+## 考虑过的替代方案
+
+**为生产安装额外声明 React,或强制覆盖对等依赖。** 这保留了浏览器不使用的额外安装图,且并未使安装副本成为浏览器共享实例。
+
+**把静态库的所有依赖提前内联。** 这会改变 Vite 对第三方分块、缓存和 CSS 的处理;依赖分类不需要改变这些构建行为。
+
+**把带 Client 的包的全部第三方依赖移走。** 双面包仍可能在 Host 加载库,例如 ZIP 输出所用的 `fflate` 和 RPC 校验所用的 `zod`;这些安装关系必须保留。
+
+**按清单字段直接划分许可证披露。** 浏览器代码的交付与安装器是否下载同名包不是同一事实,不能因此取消对分发代码的许可证检查。
+
+## 结果
+
+生产依赖不再为纯浏览器实现重复安装第三方库。React 与 React DOM 保持同一构建版本,插件继续消费 Web 壳的共享实例。分类测试同时约束浏览器 dev-only、Host 优先和修复幂等;发布检查拒绝 React 安装泄漏,浏览器产物验证独立覆盖实际模块加载。
+
+分类检查保留源码执行方式。许可证解析也不能依赖已有 `lib/` 或写入构建输出;其测试覆盖真实解析、纯类型擦除、资源引用与缺失依赖。独立消费静态包或公开类型的开发者需要自己提供相应构建依赖;本决策不增加独立浏览器库的支持承诺。
diff --git a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.i18n.yaml
new file mode 100644
index 0000000000..b85c97a4b8
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.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/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md
+2026-09-03-minimal-profiles-persistent-shell-only.md: d58210cb890e4264b02d591f213045b592ca6bfc
+2026-09-03-minimal-profiles-persistent-shell-only.zh.md: 481ef479c2dc01ae503f621df976529aeb6bdbe0
diff --git a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md
new file mode 100644
index 0000000000..d58210cb89
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md
@@ -0,0 +1,35 @@
+# Agent Note: Minimal profiles expose only a persistent shell
+
+Status: implemented
+
+English | [中文](2026-09-03-minimal-profiles-persistent-shell-only.zh.md)
+
+## Problem
+
+The shipped Web `minimal` preset and standalone `sdk-minimal` profile exposed `str_replace_editor` beside their persistent shell. The editor added a second file-mutation interface and its complete schema to every minimal model request, although the shell already provides file inspection and mutation. It also required a dedicated `fs-local` service that no other row in either minimal composition consumed.
+
+Using one persistent shell gives the model a consistent file-operation interface and keeps the harness composition aligned with that interface. Leaving the editor mounted but hidden through a presentation filter would preserve an inactive capability that could reappear when presentation configuration changes.
+
+## Decision
+
+The shipped minimal compositions expose exactly one platform-selected persistent shell: `bash` on Linux and macOS, or `pwsh` on Windows. Neither composition mounts `@deepseek-ai/dsh-tool-str-replace-editor`, a filesystem tool, or the `fs-local` service that supported the editor. The fixed complete persona, absence of runtime context and compaction, shell timeout, and launch-specific host services remain unchanged.
+
+The standalone editor package remains available for explicit custom compositions. A trusted user-authored preset or higher profile patch must insert the editor into the Cordis tree with a filesystem provider in the same service scope; the shipped `minimal` and `sdk-minimal` defaults never insert it. The [Python SDK guide](../../../../docs/user/guide/python-sdk.md#opt-in-to-str_replace_editor) provides an executable patch example.
+
+The shared [persistent Bash consumer](../../../../packages/shell/tool-bash-persistent/README.md#model-experience) uses the one-shot shell's command-status wording while retaining its persistent state. Settled commands append `[Command finished with exit code N]`, including success; timeout output includes `[Command timed out or OOM]` and the shell-reset notice. Trailing newlines are removed before the status trailer. Both minimal Bash descriptions state that network access depends on the task environment. Explicit compositions using this consumer share its output behavior; the persistent PowerShell description and output remain unchanged.
+
+Exact composition tests assert the single tool and the absence of a preset-local filesystem service. The `sdk-minimal` bundle test and built config dump assert that its row and dependency allowlists contain neither `fs-local` nor `dsh-tool-str-replace-editor`. Web and packaged-Python model-visible snapshots pin the one-tool schema roster. SDK profile smoke tests execute the guide's editor patch and verify file creation and viewing.
+
+This decision partially supersedes the tool selection in [the bare minimal runtime](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md) and the minimal exception in [the base editor decision](2026-09-05-base-default-file-editor.md). Those notes retain authority for prompt ownership, no-compaction behavior, and base-backed file editing. [The application architecture](../../../../docs/architecture.md) owns profile launch and bundle layering.
+
+## Alternatives considered
+
+**Keep the editor row and hide its schema.** Rejected because a presentation or restriction layer would leave the capability in the minimal composition and make its absence depend on another setting.
+
+**Remove the editor package from the distribution.** Rejected because explicit custom compositions remain valid consumers. The requirement concerns the two shipped minimal defaults.
+
+**Keep the editor only in `sdk-minimal`.** Rejected because the two minimal paths would present different tool contracts to the same model class, and the packaged SDK path would retain the schema cost and unused filesystem service.
+
+## Consequences
+
+Minimal agents inspect and modify files through their persistent shell. Their model requests carry one tool schema, and their compositions own no filesystem service. The editor package and explicit editor compositions remain available. Web and SDK replay fixtures pin the persistent Bash status trailers alongside shell state and file effects.
diff --git a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md
new file mode 100644
index 0000000000..481ef479c2
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: 极简 profile 只提供持久 shell
+
+Status: implemented
+
+[English](2026-09-03-minimal-profiles-persistent-shell-only.md) | 中文
+
+## 问题
+
+随附 Web `minimal` preset 与独立 `sdk-minimal` profile 在持久 shell 之外还提供 `str_replace_editor`。Shell 已经可以检查和修改文件,editor 仍会为每个极简模型请求增加第二种文件修改接口及其完整 schema。它还要求挂载一个专用 `fs-local` 服务,而两份极简组合中的其他配置项都不使用该服务。
+
+只使用持久 shell 可以为模型提供一致的文件操作接口,并让 harness 组合与这一接口保持一致。如果保留 editor 的挂载,只通过呈现层过滤隐藏它,那么呈现配置变化时,该能力仍可能重新出现。
+
+## 决策
+
+随附的极简组合只提供一个按平台选择的持久 shell:Linux 与 macOS 使用 `bash`,Windows 使用 `pwsh`。两份组合都不挂载 `@deepseek-ai/dsh-tool-str-replace-editor`、文件系统工具或支撑 editor 的 `fs-local` 服务。固定的 complete persona、运行时上下文与 compaction 的缺失、shell 超时和各启动路径的宿主服务保持不变。
+
+独立 editor 包仍可用于显式自定义组合。受信任的用户自定义 preset 或更高优先级的 profile patch 必须将 editor 插入 Cordis tree,并在同一服务作用域内提供文件系统后端;随附的 `minimal` 与 `sdk-minimal` 默认组合不会插入它。[Python SDK 指南](../../../../docs/user/guide/python-sdk.zh.md#opt-in-to-str_replace_editor)提供可执行的 patch 示例。
+
+共享的[持久 Bash 消费方](../../../../packages/shell/tool-bash-persistent/README.zh.md#model-experience)保留跨调用状态,并采用单次 shell 的命令状态文案。完成的命令追加 `[Command finished with exit code N]`,成功时也包含该标记;超时输出包含 `[Command timed out or OOM]` 和 shell 重置说明。追加状态标记前会移除输出末尾的全部换行。两份极简 Bash 描述都说明网络访问取决于任务环境。使用该消费方的显式组合共享相同的输出行为;持久 PowerShell 的描述与输出保持不变。
+
+精确组合测试会断言单工具清单以及 preset 内不存在文件系统服务。`sdk-minimal` bundle 测试与构建后配置转储会断言配置项和依赖 allowlist 都不含 `fs-local` 或 `dsh-tool-str-replace-editor`。Web 与打包 Python 的模型可见快照会固定单工具 schema 清单。SDK profile 冒烟测试会执行指南中的 editor patch,并验证文件创建和查看。
+
+本决策部分取代[极简裸运行时](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md)中的工具选择,以及[base 编辑器决策](2026-09-05-base-default-file-editor.zh.md)中的极简例外。这些 Agent Note 继续负责提示词所有权、无 compaction 行为和基于 base 的文件编辑。[应用架构](../../../../docs/architecture.zh.md)负责 profile 启动与 bundle 分层。
+
+## 考虑过的替代方案
+
+**保留 editor 配置项并隐藏其 schema。** 不予采用,因为呈现层或限制层会让该能力继续留在极简组合中,并使其缺失依赖另一项设置。
+
+**从发行物中删除 editor 包。** 不予采用,因为显式自定义组合仍是有效消费方。本需求只涉及两份随附的极简默认组合。
+
+**只在 `sdk-minimal` 中保留 editor。** 不予采用,因为两条极简路径会向同类模型提供不同的工具约定,而且打包 SDK 路径仍会承担 schema 成本和未被其他配置项使用的文件系统服务。
+
+## 后果
+
+极简 agent 通过持久 shell 检查和修改文件。模型请求只携带一个工具 schema,组合不拥有文件系统服务。editor 包和显式 editor 组合仍然可用。Web 与 SDK 回放快照会同时固定持久 Bash 的状态标记、shell 状态和文件操作结果。
diff --git a/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.i18n.yaml
index 672c293dc5..a4c4a9902a 100644
--- a/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.i18n.yaml
+++ b/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.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/simplification/2026-09-05-base-default-file-editor.md
-2026-09-05-base-default-file-editor.md: 68a86769a99d697f9c7c767e9ba59b0cf61669b1
-2026-09-05-base-default-file-editor.zh.md: e8302b609a8647b1a0b90923361da06e859868ab
+2026-09-05-base-default-file-editor.md: b87d7986e7beeab30ff5414908239ba95022d231
+2026-09-05-base-default-file-editor.zh.md: db3c70d0a28527391073ddc34a72e9faabe1c3be
diff --git a/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.md b/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.md
index 68a86769a9..b87d7986e7 100644
--- a/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.md
+++ b/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.md
@@ -12,7 +12,7 @@ The shared base selects both `read`/`write`/`edit` and `str_replace_editor`, whi
The [base patch](../../../../packages/bundle/base/cordis.patch.yml) selects `read`, `write`, and `edit` for file editing. It does not insert `tool-str-replace-editor`; SDK and Web application patches therefore need no disabling override. The editor package remains available to compositions that insert it explicitly.
-[Web minimal](../../../../packages/preset/agent-presets/presets/minimal/agent.cordis.yml) inserts its own `str-replace-editor` row in the agent scope. The standalone [sdk-minimal bundle](../../../../packages/bundle/sdk-minimal/cordis.patch.yml) inserts its own row without inheriting base. Both minimal compositions retain their editor.
+Web minimal and the standalone `sdk-minimal` bundle own their tool selection independently of base. The [persistent-shell-only decision](2026-09-03-minimal-profiles-persistent-shell-only.md) owns their single-tool defaults.
This refines the shared tool defaults in [one dsh launcher](../architecture/2026-08-22-single-dsh-application-launcher.md). That note remains active for launch ownership, shared services, and patch precedence; no active note is fully superseded.
@@ -20,7 +20,7 @@ This refines the shared tool defaults in [one dsh launcher](../architecture/2026
**Disable the editor separately in each application.** This leaves overlapping defaults in base and requires each consumer to opt out. The base owns the shared choice directly.
-**Delete the tool package or remove it from minimal.** The dedicated minimal compositions use this interface for file operations. Keeping the package and their explicit rows preserves that behavior.
+**Delete the tool package.** Explicit custom compositions still use this interface. Base default selection does not remove the package or constrain independently owned minimal defaults.
## Consequences
@@ -28,4 +28,4 @@ Base-backed SDK, headless, ACP, and custom profiles omit the editor schema by de
## Verification
-The [SDK process tests](../../../../apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts) capture actual model requests for default file tools, explicit editor insertion, and the standalone minimal roster. The [headless process test](../../../../apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts) checks the shared default through its application. [Web minimal snapshots](../../../../apps/web/tests/minimal-preset.snapshot.ts) exercise the editor through the minimal preset. The [headless](../../../../snapshots/session/headless.snapshot.ts), [SDK](../../../../snapshots/sdk/sdk.snapshot.ts), and [ACP](../../../../snapshots/acp/acp.snapshot.ts) recorded sessions pin the assembled model-visible outputs, including the SDK fixture that explicitly inserts the editor.
+The [SDK process tests](../../../../apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts) capture actual model requests for default file tools, explicit editor insertion, and the standalone minimal roster. The [headless process test](../../../../apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts) checks the shared default through its application. The [headless](../../../../snapshots/session/headless.snapshot.ts), [SDK](../../../../snapshots/sdk/sdk.snapshot.ts), and [ACP](../../../../snapshots/acp/acp.snapshot.ts) recorded sessions pin the assembled model-visible outputs, including the SDK fixture that explicitly inserts the editor.
diff --git a/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.zh.md b/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.zh.md
index e8302b609a..db3c70d0a2 100644
--- a/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.zh.md
+++ b/.agents/notes/implemented/simplification/2026-09-05-base-default-file-editor.zh.md
@@ -12,7 +12,7 @@ Status: implemented
[base patch](../../../../packages/bundle/base/cordis.patch.yml) 选择 `read`、`write` 和 `edit` 负责文件编辑。它不插入 `tool-str-replace-editor`;因此 SDK 与 Web 应用 patch 无需禁用覆盖。编辑器包仍可供显式插入它的组合使用。
-[Web minimal](../../../../packages/preset/agent-presets/presets/minimal/agent.cordis.yml) 在 agent 作用域插入自己的 `str-replace-editor` 配置项。独立的 [sdk-minimal bundle](../../../../packages/bundle/sdk-minimal/cordis.patch.yml) 不继承 base,自行插入配置项。两种极简组合都保留其编辑器。
+Web minimal 与独立 `sdk-minimal` bundle 各自负责工具选择,不依赖 base。[仅持久 shell 决策](2026-09-03-minimal-profiles-persistent-shell-only.zh.md)负责它们的单工具默认值。
本决策细化了[统一 dsh 启动器](../architecture/2026-08-22-single-dsh-application-launcher.zh.md)中的共享工具默认值。该文档对启动所有权、共享服务和 patch 优先级仍然有效;没有被完全取代的活跃 Agent Note。
@@ -20,7 +20,7 @@ Status: implemented
**在每个应用中分别禁用编辑器。** 这会在 base 中保留重叠的默认接口,并要求各消费方主动退出。共享选择由 base 直接负责。
-**删除工具包或从 minimal 移除它。** 专用的极简组合通过此接口完成文件操作。保留包及其显式配置项可以保留这一行为。
+**删除工具包。** 显式自定义组合仍使用此接口。base 的默认选择不删除包,也不约束独立负责的极简默认值。
## Consequences
@@ -28,4 +28,4 @@ Status: implemented
## Verification
-[SDK 进程测试](../../../../apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts) 捕获默认文件工具、显式插入编辑器与独立极简工具清单的实际模型请求。[headless 进程测试](../../../../apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts) 通过所属应用检查共享默认值。[Web minimal 快照](../../../../apps/web/tests/minimal-preset.snapshot.ts) 通过极简 preset 执行编辑器。[headless](../../../../snapshots/session/headless.snapshot.ts)、[SDK](../../../../snapshots/sdk/sdk.snapshot.ts) 与 [ACP](../../../../snapshots/acp/acp.snapshot.ts) 录制会话固定组装后模型可见的输出,包括显式插入编辑器的 SDK fixture。
+[SDK 进程测试](../../../../apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts) 捕获默认文件工具、显式插入编辑器与独立极简工具清单的实际模型请求。[headless 进程测试](../../../../apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts) 通过所属应用检查共享默认值。[headless](../../../../snapshots/session/headless.snapshot.ts)、[SDK](../../../../snapshots/sdk/sdk.snapshot.ts) 与 [ACP](../../../../snapshots/acp/acp.snapshot.ts) 录制会话固定组装后模型可见的输出,包括显式插入编辑器的 SDK fixture。
diff --git a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml
index 413d0f32b5..a1a94872ba 100644
--- a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml
+++ b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md
-2026-09-04-session-open-performance-gate.md: d5447f93666a5cb27af2073a4a66f99d0c39f6ee
-2026-09-04-session-open-performance-gate.zh.md: 657f77fb76cf84afc36301d22d9a7798c559075b
+2026-09-04-session-open-performance-gate.md: d69e7424c5b34bc9f50d4a1d355279b65b7dfd8a
+2026-09-04-session-open-performance-gate.zh.md: cce4ae3921d70d43dde823d64dca5a0976344100
diff --git a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md
index d5447f9366..d69e7424c5 100644
--- a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md
+++ b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md
@@ -56,6 +56,8 @@ The pre-stack implementation keeps V0 as its current format, so first open does
The [standard two-CPU run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34023970384/job/101461539961) at `ca3ffe95dac2c55eefeb16ed9b61067bbd19ee90` uses Node 24.20.0 x64 and Ubuntu image `20260831.293.1`. Its five current-generation `open` samples are 49.2, 47.4, 49.1, 48.6, and 48.1 ms: median 48.6 ms, maximum 49.2 ms. The rounded 50 ms CI expectation gives a 63 ms limit without reapplying the 2× machine scale. The log identifies two available CPUs but not their model; it does not isolate hardware from the Node-version change. This is endpoint-specific runner calibration, not evidence of an application optimization or a new reference-machine measurement. Every other benchmark passes its existing budget. Deterministic controls reject the observed median at the historical 30 ms limit, accept it at 63 ms, reject a synthetic 75 ms reopen median, and reject a synthetic 4,000 ms first-open duration at its unchanged 550 ms limit. These controls verify budget enforcement, not a measured new regression.
+A cold-verifier packaging change removes runtime workspace-module loading without changing these budgets or the measured endpoint. On macOS arm64, Node 24.18.0, the same 127,400-event fixture at `ac48359b195558806ee5a2286697074fd1a52815` takes 164.2, 162.4, 159.7, 149.3, and 167.7 ms for first writable resume (median 162.4 ms). Bundling the verifier through the workspace build gives 119.8, 120.9, 121.9, 122.3, and 121.8 ms (median 121.8 ms, 25% lower). Retained heap stays at 5.4 MB; median peak RSS changes from 144.9 to 143.7 MB. Reopen medians are 27.5 and 27.1 ms, and all 16 Session cases, including the 128 MB completion checks, pass. A CPU profile attributes part of the old verifier cost to module resolution and compilation. The isolated-package built-worker test fails on the original worker because its workspace imports cannot resolve, and passes with the bundled worker, including rejection of an incorrect event count. These local results do not establish Linux runner timing; the existing 450 ms CI gate remains the acceptance check.
+
The calibrated source budgets are:
| Measurement | Reference expectation | CI budget |
diff --git a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md
index 657f77fb76..cce4ae3921 100644
--- a/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md
+++ b/.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md
@@ -56,6 +56,8 @@ Session benchmark 使用固定参数合成 released-v0 输入:200 轮,每轮
`ca3ffe95dac2c55eefeb16ed9b61067bbd19ee90` 上的[标准双 CPU 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34023970384/job/101461539961)使用 Node 24.20.0 x64 和 Ubuntu 镜像 `20260831.293.1`。当前 generation `open` 的五次样本为 49.2、47.4、49.1、48.6 和 48.1 ms:中位数 48.6 ms,最大值 49.2 ms。取整后的 50 ms CI 预期值给出 63 ms 上限,不重复乘以 2 倍机器系数。日志标明两个可用 CPU,但未记录型号;它无法区分硬件变化与 Node 版本变化的影响。这是端点专属的运行器校准,不是应用优化或参考机器新测量的证据。其他每项 benchmark 均通过既有预算。确定性正反例在历史 30 ms 上限下拒绝实测中位数,在 63 ms 下接受它,拒绝合成的 75 ms reopen 中位数,并以未改变的 550 ms 上限拒绝合成的 4,000 ms 首次打开耗时。这些正反例验证预算执行,不代表测得新的退化。
+一次冷 verifier 打包调整移除了运行时 workspace 模块加载,未改变这些预算或测量终点。在 macOS arm64、Node 24.18.0 上,`ac48359b195558806ee5a2286697074fd1a52815` 对同一份 127,400-event fixture 的首次 writable resume 耗时为 164.2、162.4、159.7、149.3、167.7 ms(中位数 162.4 ms)。通过 workspace build 打包 verifier 后为 119.8、120.9、121.9、122.3、121.8 ms(中位数 121.8 ms,降低 25%)。Retained heap 保持 5.4 MB;peak RSS 中位数从 144.9 变为 143.7 MB。Reopen 中位数为 27.5 和 27.1 ms,包含 128 MB completion check 的全部 16 项 Session 用例通过。CPU profile 将旧 verifier 的部分成本归因于模块解析和编译。隔离 package 的 built-worker 测试在旧 worker 上因无法解析 workspace import 而失败,在打包后的 worker 上通过,同时验证错误的 event count 会被拒绝。这些本地结果不能证明 Linux runner 耗时;现有 450 ms CI gate 仍是验收检查。
+
校准后的源码预算如下:
| 测量项 | 参考机预期 | CI 预算 |
diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml
index dfa20e45f0..ab338ea560 100644
--- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml
+++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.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-06-backend-continuation-performance.md
-2026-09-06-backend-continuation-performance.md: f4316e790cf62f5027a0f7bfb2d3148cc79e7536
-2026-09-06-backend-continuation-performance.zh.md: 0fb36ba5375c61e907791b31d96f09062b7f62a3
+2026-09-06-backend-continuation-performance.md: 62d8af10015cc2da7b399aae3c9d197f75931753
+2026-09-06-backend-continuation-performance.zh.md: 7c8904fa019b27362e2cdb0e50697921913e5d09
diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md
index f4316e790c..62d8af1001 100644
--- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md
+++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md
@@ -23,6 +23,8 @@ The shared history has 800 completed two-step turns, four tool calls per turn, a
The tool execution pipeline, request preparation, Session projections required by those services, persistence, and catalog observations remain production code. Only the model adapter and bounded tool body are synthetic. The adapter retains a request counter, not request objects, so the fixture cannot manufacture a growing retention cost. Sequential input means each idle interval belongs to the one request delivered by this worker; it does not generalize idle to a per-message completion API under concurrent input.
+The SDK fixture explicitly inserts `fs-local` and `str_replace_editor` through its profile patch. This preserves the calibrated file-view workload independently of the [minimal profile's shell-only defaults](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.md). File reads, timing endpoints, and budgets remain the same.
+
Five samples report raw wall time, CPU user/system time, peak RSS, endpoint counts, and the minimum, median, and maximum total wall time. Budgets enforce the unrounded median. Continuation additionally measures retained heap against an initialized Host: two explicit GCs separated by an event-loop yield precede and follow the timed operation, while the idle Agent remains reachable. The measured delta therefore includes the resident historical Session and live additions, not just newly appended turns. GC and teardown are outside timing; flush is inside. Request-history retention starts after resume and is diagnostic only. Catalog peak RSS is diagnostic; no retained-heap budget claims to measure already-released child observations.
The parent bounds every child to 60 seconds, checks timeout, signal, exit, and report independently, awaits process close, and removes private roots after failures. Context and Agent teardown run in finally blocks. Seed processes cannot warm the measured process's caches. Filesystem caches are not forcibly evicted: cold means a fresh process, not cold physical storage.
diff --git a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md
index 0fb36ba537..7c8904fa01 100644
--- a/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md
+++ b/.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md
@@ -23,6 +23,8 @@ Status: implemented
工具执行管线、请求准备、这些服务所需的 Session 投影、持久化和目录观察均保留生产代码。只有模型适配器和有界工具体是合成的。适配器只保留请求计数,不保留请求对象,因此 fixture(测试前置数据)不会制造不断增长的保留成本。顺序输入使每个空闲区间对应此 worker 提交的唯一请求;这不代表并发输入时可以把空闲状态推广为逐消息完成 API。
+SDK fixture 通过 profile patch 显式插入 `fs-local` 和 `str_replace_editor`。这使经校准的文件查看负载不依赖[极简 profile 只提供 shell 的默认组合](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md)。文件读取、计时终点和预算保持不变。
+
五个样本报告原始壁钟时间、CPU 用户态/内核态时间、峰值 RSS、终点计数及总壁钟时间的最小值、中位数和最大值。预算约束未经舍入的中位数。续聊还相对已初始化 Host 测量保留堆内存:计时操作前后各执行两次显式 GC,中间让出一次事件循环,空闲 Agent 始终可达。因此该增量包含常驻历史 Session 和实时追加,而不只是新轮次。GC 与资源释放不计时;flush 计时。请求历史的内存基线从恢复后开始,只作诊断。目录峰值 RSS 仅作诊断;没有保留堆预算声称衡量已经释放的子会话观察。
父进程为每个子进程设置 60 秒上限,独立检查超时、信号、退出状态和报告,等待进程关闭,并在失败后删除私有根目录。Context 和 Agent 在 finally 中释放。播种进程无法预热被测进程的缓存。不强制清除文件系统缓存:冷指新进程,不指冷物理存储。
diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml
index de9078ed58..e14bbcc662 100644
--- a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml
+++ b/.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
diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md
index 8ea0a5892d..01d2d7f91a 100644
--- a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md
+++ b/.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
diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md
index 62a4c64081..584d8ea00c 100644
--- a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md
+++ b/.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 具有不同语义。
## 考虑过的替代方案
diff --git a/.github/workflows/node-addon-system.yml b/.github/workflows/node-addon-system.yml
index 1097042f7d..a763a2a8d2 100644
--- a/.github/workflows/node-addon-system.yml
+++ b/.github/workflows/node-addon-system.yml
@@ -1,7 +1,8 @@
# CI for the node-addon-system packages under native/system. A separate
# workflow from ci.yml keeps the native OS/architecture matrix independent of
-# the harness Node matrix. Release assembly and publication use the companion
-# Node Addon System Release workflow.
+# the harness Node matrix. Each platform job builds once and tests those bytes
+# under every supported Node release. Release assembly and publication use the
+# companion Node Addon System Release workflow.
name: Node Addon System
on:
@@ -46,14 +47,11 @@ jobs:
runs-on: ubuntu-24.04
outputs:
ci: ${{ steps.matrix.outputs.ci }}
- compatibility: ${{ steps.matrix.outputs.compatibility }}
steps:
- uses: actions/checkout@v4
- id: matrix
- run: |
- echo "ci=$(node ./scripts/github-matrix.mjs ci)" >> "$GITHUB_OUTPUT"
- echo "compatibility=$(node ./scripts/github-matrix.mjs compatibility)" >> "$GITHUB_OUTPUT"
+ run: echo "ci=$(node ./scripts/github-matrix.mjs ci)" >> "$GITHUB_OUTPUT"
native:
name: ${{ matrix.platform }}
@@ -112,68 +110,59 @@ jobs:
env:
NALR_REQUIRE_LANDLOCK: ${{ runner.os == 'Linux' && '1' || '0' }}
- - name: Verify platform payload rules
+ - name: Verify platform payload rules (Node 24)
run: pnpm test:packaging
- - name: Flock behavior (built addon)
+ - name: Flock behavior (Node 24, built addon)
run: |
pnpm build:test-oracle
pnpm test:flock
- - name: Upload this platform's built addon and entry
- uses: actions/upload-artifact@v4
- with:
- name: system-compat-${{ matrix.platform }}
- path: |
- native/system/packages/*/bin/**
- native/system/packages/entry/lib/**
- if-no-files-found: error
-
- - name: Upload independent syscall test oracle
- uses: actions/upload-artifact@v4
- with:
- name: system-oracle-${{ matrix.platform }}
- path: native/system/test/bin/**
- if-no-files-found: error
-
- compatibility:
- name: ${{ matrix.platform }} / Node ${{ matrix.node }} (same binary)
- needs: [matrix, native]
- strategy:
- fail-fast: false
- matrix:
- include: ${{ fromJson(needs.matrix.outputs.compatibility) }}
- runs-on: ${{ matrix.runner }}
- steps:
- - uses: actions/checkout@v4
-
- - uses: actions/setup-node@v4
- with:
- node-version: ${{ matrix.node }}
-
- - name: Download the original platform build
- uses: actions/download-artifact@v4
- with:
- name: system-compat-${{ matrix.platform }}
- path: native/system/packages
-
- - name: Download independent syscall test oracle
- uses: actions/download-artifact@v4
- with:
- name: system-oracle-${{ matrix.platform }}
- path: native/system/test/bin
-
- - name: Restore oracle executable permissions
- run: find ./test/bin -type f -name flock-oracle -exec chmod +x {} +
-
- - name: Test without rebuilding or installing dependencies
- run: |
- node ./test/link-platform.mjs
- node --test ./test/flock.test.js ./test/package-matrix.test.js
-
- - name: Test the same musl addon without a compiler
+ - name: Test the same musl addon on Node 24 without a compiler
if: runner.os == 'Linux'
run: >-
docker run --rm -v "$PWD:$PWD" -w "$PWD"
- node:${{ matrix.node }}-alpine
+ node:24-alpine
+ node --test ./test/flock.test.js ./test/package-matrix.test.js
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Test the same binaries on Node 20
+ run: node --test ./test/flock.test.js ./test/package-matrix.test.js
+
+ - name: Test the same musl addon on Node 20 without a compiler
+ if: runner.os == 'Linux'
+ run: >-
+ docker run --rm -v "$PWD:$PWD" -w "$PWD"
+ node:20-alpine
+ node --test ./test/flock.test.js ./test/package-matrix.test.js
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Test the same binaries on Node 22
+ run: node --test ./test/flock.test.js ./test/package-matrix.test.js
+
+ - name: Test the same musl addon on Node 22 without a compiler
+ if: runner.os == 'Linux'
+ run: >-
+ docker run --rm -v "$PWD:$PWD" -w "$PWD"
+ node:22-alpine
+ node --test ./test/flock.test.js ./test/package-matrix.test.js
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 26
+
+ - name: Test the same binaries on Node 26
+ run: node --test ./test/flock.test.js ./test/package-matrix.test.js
+
+ - name: Test the same musl addon on Node 26 without a compiler
+ if: runner.os == 'Linux'
+ run: >-
+ docker run --rm -v "$PWD:$PWD" -w "$PWD"
+ node:26-alpine
node --test ./test/flock.test.js ./test/package-matrix.test.js
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 43c4263136..9004b1906f 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -27,7 +27,7 @@ The Cordis framework and its foundation libraries are source-vendored into this
## Runtime npm dependencies
-External packages that a workspace package resolves at runtime. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default.
+External packages installed for runtime use or distributed inside the prebuilt browser artifacts. Browser inputs are resolved through the shipping tsdown and Vite configurations, independently of npm dependency sections. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default.
| Package | License |
| --- | --- |
@@ -55,7 +55,6 @@ External packages that a workspace package resolves at runtime. The tier covers
| [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT |
| [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT |
| [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
-| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
| [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT |
| [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
@@ -88,7 +87,6 @@ External packages that a workspace package resolves at runtime. The tier covers
| [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT |
| [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT |
| [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT |
-| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
| [`mime-types`](https://github.com/jshttp/mime-types) | MIT |
| [`negotiator`](https://github.com/jshttp/negotiator) | MIT |
| [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT |
@@ -138,7 +136,7 @@ The installed SDK 0.3.263 declares the following optional platform packages. Eac
## Development-only npm dependencies
-External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles.
+External packages **directly declared** for development, tests, types, or tooling, without a runtime installation or browser-build relationship. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on that full closure.
| Package | License |
| --- | --- |
@@ -155,6 +153,7 @@ External packages **directly declared** only by repository tooling, test infrast
| [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/mime-types`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
@@ -190,6 +189,7 @@ External packages **directly declared** only by repository tooling, test infrast
| [`lefthook`](https://github.com/evilmartians/lefthook) | MIT |
| [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 |
| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT |
+| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
| [`msgpackr`](http://github.com/kriszyp/msgpackr) | MIT |
| [`oxlint`](https://github.com/oxc-project/oxc) | MIT |
| [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT |
diff --git a/apps/cli/package.json b/apps/cli/package.json
index d56d9c5e29..5e87c205c3 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -77,6 +77,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:^",
diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml
index 1c72034bf2..ae963e570a 100644
--- a/apps/cli/reference/README.i18n.yaml
+++ b/apps/cli/reference/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/reference/README.md
-README.md: 42fb2855e97465417ca284944102d3c6a610416d
-README.zh.md: 786774dae50a2dfcadbf3fd260b4680277c0d39e
+README.md: 99ef8310cd1316e9ee4ade55fcac00766ae1e821
+README.zh.md: aa446d539a5b666e7745f459b5a014b22e72f90e
diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md
index 42fb2855e9..99ef8310cd 100644
--- a/apps/cli/reference/README.md
+++ b/apps/cli/reference/README.md
@@ -90,11 +90,11 @@ The production Web runner needs built package and frontend artifacts (`pnpm run
Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
-The base-backed modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. The standalone `sdk-minimal` profile uses the invoking directory as its local filesystem and sandbox-policy root but intentionally omits instruction discovery and SQLite. A `patchReload: live` profile watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a `startup` profile applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers.
+The base-backed modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. The standalone `sdk-minimal` profile uses the invoking directory as its sandbox-policy root but intentionally omits filesystem tools, instruction discovery, and SQLite. A `patchReload: live` profile watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a `startup` profile applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers.
New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service.
-`DSH_TOOLS_MODE` selects `native`, `ptc`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only persistent `bash` plus `str_replace_editor`. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place.
+`DSH_TOOLS_MODE` selects `native`, `ptc`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only the platform-selected persistent shell. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place.
## Shared deployment behavior
diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md
index 786774dae5..aa446d539a 100644
--- a/apps/cli/reference/README.zh.md
+++ b/apps/cli/reference/README.zh.md
@@ -92,11 +92,11 @@ dsh web --help
进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。
-基于 base 的模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。独立的 `sdk-minimal` profile 把运行命令时所在的目录作为本地文件系统与沙箱策略根目录,但刻意省略指令发现与 SQLite。`patchReload: live` profile 会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;`startup` profile 则只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose(资源释放)所有实时监视器。
+基于 base 的模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。独立的 `sdk-minimal` profile 把运行命令时所在的目录作为沙箱策略根目录,但刻意省略文件系统工具、指令发现与 SQLite。`patchReload: live` profile 会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;`startup` profile 则只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose(资源释放)所有实时监视器。
基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。
-`DSH_TOOLS_MODE` 为进程选择 `native`、`ptc` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合持久 `bash` 和 `str_replace_editor`。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。
+`DSH_TOOLS_MODE` 为进程选择 `native`、`ptc` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合按平台选择的持久 shell。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。
## 共享部署行为
diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts
index ee1d6f3c04..ef606d49af 100644
--- a/apps/cli/tests/built-bin.e2e.ts
+++ b/apps/cli/tests/built-bin.e2e.ts
@@ -1081,7 +1081,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
['pty', '@deepseek-ai/dsh-terminal'],
['terminal-bash', '@deepseek-ai/dsh-terminal-bash'],
['terminal-pwsh', '@deepseek-ai/dsh-terminal-bash'],
- ['fs-local', '@deepseek-ai/dsh-fs-local'],
['timer', '@deepseek-ai/cordis-plugin-timer'],
['llm', '@deepseek-ai/dsh-llm'],
['session', '@deepseek-ai/dsh-session'],
@@ -1099,7 +1098,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
['agent-loop', '@deepseek-ai/dsh-agent-loop'],
['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'],
['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'],
- ['str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor'],
['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'],
])
expect(stdout).toContain('# == @deepseek-ai/dsh-sdk-minimal')
diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl
index 7b41f6017e..44d31a36ab 100644
--- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl
+++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl
@@ -22,6 +22,7 @@
{"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[3],"source":{"kind":"fallback"}}}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"delegate-write","name":"subagent","args":["{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}
+{"type":"subagent/catalog","data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"one-shot","label":"Delegated write probe"}}
{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"delegate-write"},"content":[{"type":"tool-result","toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":2,"step":1}}
{"type":"step/start","data":{"turn":2,"step":2}}
diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl
index c2d9145aaf..b027378207 100644
--- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl
+++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl
@@ -12,16 +12,17 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"start-child","name":"subagent","args":["{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":16,"time":0,"data":{"title":"Subagent settlement","messageSeqs":[8],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"start-child"},"content":[{"type":"tool-result","toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":18,"time":0,"data":{"turn":1,"step":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":19,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":20,"time":0,"data":{"turn":1,"step":2}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["STARTED"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":22,"time":0,"data":{"turn":1,"step":2}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":23,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":25,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":26,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["PARENT_RECEIVED_CHILD_RESULT"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":27,"time":0,"data":{"turn":1,"step":3}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":28,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":17,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Return child result"}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"start-child"},"content":[{"type":"tool-result","toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":19,"time":0,"data":{"turn":1,"step":1}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":20,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":21,"time":0,"data":{"turn":1,"step":2}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":22,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["STARTED"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":24,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":26,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":27,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["PARENT_RECEIVED_CHILD_RESULT"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_RECEIVED_CHILD_RESULT"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":3}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":29,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}
{"type":"result","sessionId":"{{sessionId}}","output":"PARENT_RECEIVED_CHILD_RESULT","usage":{"inputTokens":30,"outputTokens":15}}
diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts
index 6db08281f3..c879e5b8b9 100644
--- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts
+++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts
@@ -189,8 +189,25 @@ describe('Python SDK dsh profile keyless smoke', () => {
}
}, 40_000)
- it('boots the standalone minimal profile through its generated manifest', async () => {
+ it.each([
+ { label: 'boots the standalone minimal profile through its generated manifest', editorEnabled: false },
+ { label: 'executes the documented editor opt-in patch with sdk-minimal', editorEnabled: true },
+ ])('$label', async ({ editorEnabled }) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-minimal-'))
+ const editorPatch = join(root, 'editor.patch.yml')
+ if (editorEnabled) {
+ const guide = await readFile(join(repoRoot, 'docs/user/guide/python-sdk.md'), 'utf8')
+ const yaml = guide.split('')[1]
+ ?.match(/```yaml\n([\s\S]*?)```/)?.[1]
+ expect(yaml).toBeDefined()
+ await writeFile(editorPatch, yaml!)
+ }
+ const editorFile = join(root, 'editor.txt')
+ const editorContent = 'sdk-minimal editor opt-in\n'
+ const editorCalls = editorEnabled ? [
+ { command: 'create', path: editorFile, file_text: editorContent },
+ { command: 'view', path: editorFile },
+ ] : []
const modelRequests: Record[] = []
const modelServer = createServer((request, response) => {
let body = ''
@@ -200,8 +217,21 @@ describe('Python SDK dsh profile keyless smoke', () => {
modelRequests.push(JSON.parse(body) as Record)
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.write('data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n')
- response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
- response.write('data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n')
+ const toolCall = editorCalls[modelRequests.length - 1]
+ if (toolCall) {
+ response.write(`data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{
+ index: 0,
+ id: `editor-${toolCall.command}`,
+ type: 'function',
+ function: { name: 'str_replace_editor', arguments: JSON.stringify(toolCall) },
+ }] } }] })}\n\n`)
+ } else {
+ response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
+ }
+ response.write(`data: ${JSON.stringify({
+ choices: [{ delta: {}, finish_reason: toolCall ? 'tool_calls' : 'stop' }],
+ usage: { prompt_tokens: 3, completion_tokens: 1 },
+ })}\n\n`)
response.end('data: [DONE]\n\n')
})
})
@@ -214,6 +244,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
binScript,
'--profile',
'sdk-minimal',
+ ...(editorEnabled ? ['--patch', editorPatch] : []),
], {
cwd: repoRoot,
env: {
@@ -251,11 +282,14 @@ describe('Python SDK dsh profile keyless smoke', () => {
method: 'session/prompt',
params: { sessionId: 'minimal', contentBlocks: [{ type: 'text', text: 'inspect tools' }] },
})}\n`)
- await waitForLine(lines, (value) => {
+ const turnEnd = await waitForLine(lines, (value) => {
const params = value.params as Record | undefined
const event = params?.event as Record | undefined
return params?.sessionId === 'minimal' && event?.type === 'turn/end'
}, () => stderr)
+ expect(turnEnd).toMatchObject({
+ params: { event: { data: { reason: { kind: 'completed' } } } },
+ })
const profile = JSON.parse(
await readFile(join(root, '.dsh', 'profiles', 'sdk-minimal', 'package.json'), 'utf8'),
@@ -266,13 +300,27 @@ describe('Python SDK dsh profile keyless smoke', () => {
})
expect(modelRequests[0]?.tools).toEqual(expect.any(Array))
const tools = modelRequests[0]?.tools as { function?: { name?: string } }[]
- expect(tools.map(tool => tool.function?.name).sort()).toEqual(
- [process.platform === 'win32' ? 'pwsh' : 'bash', 'str_replace_editor'].sort(),
- )
+ expect(tools.map(tool => tool.function?.name)).toEqual([
+ process.platform === 'win32' ? 'pwsh' : 'bash',
+ ...(editorEnabled ? ['str_replace_editor'] : []),
+ ])
+ expect(modelRequests).toHaveLength(editorEnabled ? 3 : 1)
+ if (editorEnabled) {
+ expect(await readFile(editorFile, 'utf8')).toBe(editorContent)
+ expect(modelRequests[2]?.messages).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ role: 'tool',
+ tool_call_id: 'editor-view',
+ content: expect.stringContaining(editorContent.trim()) as unknown,
+ }),
+ ]))
+ }
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`)
await waitForLine(lines, value => value.id === 3, () => stderr)
const exit = await child
+ expect(exit.timedOut, stderr).toBe(false)
+ expect(exit.signal, stderr).toBeUndefined()
expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0)
} finally {
child.kill('SIGKILL')
diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts
index 3ac7af61ce..eacd602971 100644
--- a/apps/cli/tests/web-agent-presets.e2e.ts
+++ b/apps/cli/tests/web-agent-presets.e2e.ts
@@ -32,8 +32,7 @@ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
const MINIMAL_PROMPT = 'You are a helpful software engineer assistant.'
const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell
* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.
-* You don't have access to the internet via this tool.
-* You do have access to a mirror of common linux and python packages via apt and pip.
+* Network access depends on the task environment. Prefer configured mirrors/proxies when they are available.
* State is persistent across command calls and discussions with the user.
* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.
* Please avoid commands that may produce a very large amount of output.
@@ -63,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
@@ -189,9 +190,7 @@ describe('the shipped Web composition', () => {
it('leaves the global tool layer empty', () => {
// Every model-facing tool belongs to a preset, `ask_user_question`
// included: a tool in the global layer reaches EVERY agent regardless of
- // which preset composed it, so a two-tool benchmark surface would really
- // present three. A regression here means an agent-plane row came back to
- // the host composition.
+ // which preset composed it, expanding that preset's tool list.
expect(toolNames(ctx)).toEqual([])
})
@@ -243,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',
])
@@ -287,7 +286,7 @@ describe('the shipped Web composition', () => {
}
})
- it('composes the exact RL prompt and two tools from `minimal`', async () => {
+ it('composes the exact RL prompt and persistent shell from `minimal`', async () => {
const handle = await ctx.agents.create({
sessionId: SessionId('preset-minimal'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
@@ -297,11 +296,13 @@ describe('the shipped Web composition', () => {
expect(assembly.sections).toEqual([
{ name: 'deployment:persona-prefix', text: MINIMAL_PROMPT },
])
- expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'str_replace_editor'])
+ expect(assembly.tools.map(tool => tool.name)).toEqual(['bash'])
expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION)
- expect(JSON.stringify(assembly.tools.find(tool => tool.name === 'str_replace_editor')?.parameters))
- .toContain('Absolute path')
expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined()
+ // serviceFor reports preset-owned providers; unisolated consumers inherit the host fs.
+ expect(ctx.agentPresets.serviceFor(handle.agent, 'fs')).toBeUndefined()
+ expect(ctx.get('fs')?.sandboxMode).toBeDefined()
+ expect(handle.agent.ctx.get('fs')?.sandboxMode).toBe(ctx.get('fs')?.sandboxMode)
expect(ctx.agentPresets.serviceFor(handle.agent, 'compaction')).toBeUndefined()
expect(handle.agent.ctx.get('compaction')).toBeUndefined()
} finally {
@@ -319,7 +320,7 @@ describe('the shipped Web composition', () => {
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
})
try {
- expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'str_replace_editor'])
+ expect(toolNames(ctx, minimal.agent)).toEqual(['bash'])
expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10)
await minimal.dispose()
@@ -470,7 +471,7 @@ describe('the shipped Web composition', () => {
// stays the preset's choice — minimal mounts no `tool-skill`, so its
// tool table has no loader even though the global layer is readable.
expect((await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)).toContain('dsh-badge')
- expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
+ expect(toolNames(ctx, handle.agent)).toEqual(['bash'])
} finally {
await handle.dispose()
}
@@ -849,7 +850,7 @@ describe('authoring a preset on the shipped composition', () => {
try {
// The same tools the shipped `minimal` composes, from a directory copied
// through the service into a root outside the installed harness.
- expect(toolNames(authorCtx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
+ expect(toolNames(authorCtx, handle.agent)).toEqual(['bash'])
} finally {
await handle.dispose()
}
@@ -884,9 +885,9 @@ describe('the default preset as a user setting', () => {
setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined),
})
try {
- // `mount()` with no id resolves the effective default. Two tools, not
+ // `mount()` with no id resolves the effective default. One tool, not
// `standard`'s catalog: the setting decided the composition.
- expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
+ expect(toolNames(ctx, handle.agent)).toEqual(['bash'])
} finally {
await handle.dispose()
}
@@ -911,7 +912,7 @@ describe('a session keeps the preset it was created with', () => {
try {
// The api-proxy guard reads exactly this: the header records what the
// session runs, so naming anything else is a caller error rather than a
- // switch. Its history was produced under `minimal`'s two tools.
+ // switch. Its history was produced under `minimal`'s single tool.
expect(handle.agent.session.header.agentPreset).toBe('minimal')
} finally {
await handle.dispose()
@@ -973,7 +974,7 @@ describe('a composition that configures its own preset roots', () => {
setup: agentCtx => rootsCtx.agentPresets.mount(agentCtx, 'team-spec').then(() => undefined),
})
try {
- expect(toolNames(rootsCtx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
+ expect(toolNames(rootsCtx, handle.agent)).toEqual(['bash'])
} finally {
await handle.dispose()
}
diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts
index db33227043..fe7b5ec14e 100644
--- a/apps/web/tests/agent-preset-authoring.e2e.ts
+++ b/apps/web/tests/agent-preset-authoring.e2e.ts
@@ -150,7 +150,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8'))
const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8')
expect(metadata).toContain('name: 我的模式')
- expect(metadata).toContain('description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。')
+ expect(metadata).toContain('description: 仅提供持久 shell 的单工具编码 Agent。')
expect(metadata).not.toContain('order:')
}, 60_000)
diff --git a/apps/web/tests/expected/agent-preset-authoring/created.expected.md b/apps/web/tests/expected/agent-preset-authoring/created.expected.md
index 37dc43d9a6..f9daab0c26 100644
--- a/apps/web/tests/expected/agent-preset-authoring/created.expected.md
+++ b/apps/web/tests/expected/agent-preset-authoring/created.expected.md
@@ -43,7 +43,7 @@
- text: 复制
- listitem:
- 'button "设为默认: 极简模式"':
- - text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
+ - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。
- code: minimal
- 'button "查看: 极简模式"':
- img
@@ -65,7 +65,7 @@
- list:
- listitem:
- 'button "设为默认: 我的模式"':
- - text: 我的模式 自定义 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
+ - text: 我的模式 自定义 仅提供持久 shell 的单工具编码 Agent。
- code: my-agent
- 'button "查看路径: 我的模式"':
- img
diff --git a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md
index 7f8537f392..cfae761ba3 100644
--- a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md
+++ b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md
@@ -43,7 +43,7 @@
- text: 复制
- listitem:
- 'button "设为默认: 极简模式"':
- - text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
+ - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。
- code: minimal
- 'button "查看: 极简模式"':
- img
diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md
index e6c83143d7..7efe280eb5 100644
--- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md
+++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md
@@ -43,7 +43,7 @@
- text: 复制
- listitem:
- 'button "设为默认: 极简模式"':
- - text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。
+ - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。
- code: minimal
- 'button "查看: 极简模式"':
- img
diff --git a/apps/web/tests/expected/agent-preset-selection/menu.expected.md b/apps/web/tests/expected/agent-preset-selection/menu.expected.md
index c89bfd50a5..7c05a3332a 100644
--- a/apps/web/tests/expected/agent-preset-selection/menu.expected.md
+++ b/apps/web/tests/expected/agent-preset-selection/menu.expected.md
@@ -3,6 +3,6 @@
- text: Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.
- img
- menuitem "PTC mode Full coding agent without the workflow tool; other tools are exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program."
- - menuitem "Minimal mode Two-tool coding agent with persistent bash and str_replace_editor."
+ - menuitem "Minimal mode Single-tool coding agent with a persistent shell."
- menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance."
- menuitem "Refusing mode Resolves, then refuses to start."
diff --git a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md
new file mode 100644
index 0000000000..6a1a3e0511
--- /dev/null
+++ b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md
@@ -0,0 +1,42 @@
+- dialog "设置":
+ - navigation:
+ - text: 设置
+ - button "通用设置":
+ - img
+ - text: 通用设置
+ - button "模型":
+ - img
+ - text: 模型
+ - button "插件":
+ - img
+ - text: 插件
+ - button "Agent 预设":
+ - img
+ - text: Agent 预设
+ - button "打开配置文件"
+ - button "关闭":
+ - img
+ - text: 关闭
+ - heading "模型" [level=2]
+ - paragraph: 填入各提供方的 API 密钥即可使用其模型。
+ - list:
+ - listitem:
+ - text: openrouter
+ - button "编辑 openrouter": 编辑
+ - button "删除 openrouter": 删除
+ - alert: "llm-pi-ai: provider \"openrouter\" model \"111\" needs an api; the installed catalog does not describe it, so set the route's api to the wire protocol its endpoint speaks"
+ - listitem:
+ - text: zai
+ - button "编辑 zai": 编辑
+ - button "删除 zai": 删除
+ - listitem:
+ - text: acme-gateway 自定义
+ - button "编辑 acme-gateway": 编辑
+ - button "删除 acme-gateway": 删除
+ - alert: "llm-pi-ai: provider \"acme-gateway\" model \"custom-model\" needs an api; the installed catalog does not describe it, so set the route's api to the wire protocol its endpoint speaks"
+ - button "添加提供方":
+ - img
+ - text: 添加提供方
+ - button "添加自定义提供方":
+ - img
+ - text: 添加自定义提供方
diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts
index 30d9f922be..f42337be37 100644
--- a/apps/web/tests/lifecycle-chrome.e2e.ts
+++ b/apps/web/tests/lifecycle-chrome.e2e.ts
@@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page, WebSocketRoute } from 'playwright'
import { chromium } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria,
@@ -72,10 +72,17 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
it.skipIf(MODE === 'record')('opens the shared slash menu from plus with only Command candidates', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-command-menu-launcher'))
+ const input = page.locator('[data-composer-input]').first()
+ onTestFinished(async () => {
+ await input.press('Escape')
+ await writeComposerDraft(page, input, '')
+ await page.getByRole('listbox', { name: 'Trigger suggestions' }).waitFor({ state: 'hidden' })
+ })
const launcher = page.getByRole('button', { name: 'Commands' })
await launcher.click()
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
- await menu.waitFor({ timeout: 10_000 })
+ await menu.getByRole('option').first().waitFor({ timeout: 10_000 })
+ await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 })
const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE)
expect(snapshot).toContain('text: Commands')
@@ -84,9 +91,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
const launchedBox = await menu.boundingBox()
await page.locator('[data-composer-input]').first().press('Escape')
await expect.poll(() => menu.count()).toBe(0)
- const input = page.locator('[data-composer-input]').first()
await writeComposerDraft(page, input, '/')
- await menu.waitFor({ timeout: 10_000 })
+ await menu.getByRole('option').first().waitFor({ timeout: 10_000 })
+ await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 })
const typedBox = await menu.boundingBox()
expect(launchedBox).not.toBeNull()
expect(typedBox).not.toBeNull()
@@ -114,7 +121,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
const launcher = zhPage.getByRole('button', { name: '指令' })
await launcher.click()
const menu = zhPage.getByRole('listbox', { name: '触发候选建议' })
- await menu.waitFor({ timeout: 10_000 })
+ await menu.getByRole('option').first().waitFor({ timeout: 10_000 })
+ await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 })
const snapshot = await captureStableAria(zhPage, '[role="listbox"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(COMMAND_MENU_ZH_EXPECTED, snapshot, MODE)
expect(zhTripwire.pageErrors).toEqual([])
diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts
index 3df0703839..fa69b3644e 100644
--- a/apps/web/tests/live-interactions.e2e.ts
+++ b/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 }),
diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts
index 1b34c986b0..22ab1bc476 100644
--- a/apps/web/tests/minimal-preset.snapshot.ts
+++ b/apps/web/tests/minimal-preset.snapshot.ts
@@ -1,4 +1,4 @@
-import { mkdir, writeFile } from 'node:fs/promises'
+import { mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
@@ -76,7 +76,7 @@ describe('minimal agent preset', () => {
if (failures.length > 1) throw new AggregateError(failures, 'minimal preset smoke teardown failed')
})
- it('sends the exact RL prompt and schemas, then executes the persistent shell and editor', async () => {
+ it('sends the exact RL prompt and shell schema, then executes the persistent shell', async () => {
const requestHeader = agentHandle.agent.session.requestHeader()
if (requestHeader === undefined) throw new Error('the minimal agent issued no model request')
const systemPrompt = systemPromptText(agentHandle.agent.session)
@@ -84,9 +84,7 @@ describe('minimal agent preset', () => {
expect(agentHandle.agent.session.snapshotEvents().some(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(false)
- const presetFileSystem = scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'fs')
- expect(presetFileSystem).toBeDefined()
- expect(presetFileSystem?.sandboxMode).toBeUndefined()
+ expect(scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'fs')).toBeUndefined()
expect(scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'compaction')).toBeUndefined()
const stateDir = join(scaffold.workspaceCwd, 'persistent-state')
@@ -106,16 +104,6 @@ describe('minimal agent preset', () => {
arguments: { command: 'printf \'%s:%s\n\' "$DSH_MINIMAL_STATE" "$PWD"' },
agent: agentHandle.agent,
})
- const seedPath = join(scaffold.workspaceCwd, 'preset-smoke.txt')
- await writeFile(seedPath, 'MINIMAL_EDITOR_OK\n')
- const editor = await scaffold.ctx.tools.execute({
- signal,
- callId: ToolCallId('minimal-editor-smoke'),
- name: 'str_replace_editor',
- arguments: { command: 'view', path: seedPath },
- agent: agentHandle.agent,
- })
-
const text = (result: typeof bash): string => result.content
.filter(block => block.type === 'text')
.map(block => block.text)
@@ -128,18 +116,14 @@ describe('minimal agent preset', () => {
tools: requestHeader.tools?.map(tool => tool.name),
goalCommand: scaffold.ctx.commands.find(agentHandle.agent, 'goal') !== undefined,
bash: text(bash),
- editor: text(editor),
}).toMatchInlineSnapshot(`
{
- "bash": "PERSISTED:{{cwd}}/persistent-state",
- "editor": "Here's the content of {{cwd}}/preset-smoke.txt with line numbers (which has a total of 2 lines):
- 1 MINIMAL_EDITOR_OK
- 2",
+ "bash": "PERSISTED:{{cwd}}/persistent-state
+ [Command finished with exit code 0]",
"goalCommand": false,
"prompt": "You are a helpful software engineer assistant.",
"tools": [
"bash",
- "str_replace_editor",
],
}
`)
@@ -178,7 +162,7 @@ describe('minimal agent preset', () => {
const call = row.locator('xpath=..')
await call.getByText('IN', { exact: true }).waitFor()
await call.getByText('OUT', { exact: true }).waitFor()
- await call.getByText('MINIMAL_BASH_CARD_OK', { exact: true }).waitFor()
+ await call.getByText('MINIMAL_BASH_CARD_OK\n[Command finished with exit code 0]', { exact: true }).waitFor()
await call.getByText(/"command": "printf 'MINIMAL_BASH_CARD_OK/).waitFor()
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
diff --git a/apps/web/tests/models-settings-recovery.e2e.ts b/apps/web/tests/models-settings-recovery.e2e.ts
new file mode 100644
index 0000000000..1087f8be3d
--- /dev/null
+++ b/apps/web/tests/models-settings-recovery.e2e.ts
@@ -0,0 +1,94 @@
+/** Stored catalog drift remains repairable through the assembled Models settings page. */
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+ captureStableAria, compareOrRefreshGolden, launchWebScaffold,
+ watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
+
+const EXPECTED = fileURLToPath(new URL('./expected/models-settings-recovery/stored-error.expected.md', import.meta.url))
+const FAILURE = 'llm-pi-ai: provider "openrouter" model "111" needs an api; '
+ + 'the installed catalog does not describe it, so set the route\'s api to the wire protocol its endpoint speaks'
+const CUSTOM_FAILURE = 'llm-pi-ai: provider "acme-gateway" model "custom-model" needs an api; '
+ + 'the installed catalog does not describe it, so set the route\'s api to the wire protocol its endpoint speaks'
+
+describe('web e2e: repairs a stored provider after catalog drift', () => {
+ let home: string
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+
+ beforeAll(async () => {
+ home = await mkdtemp(join(tmpdir(), 'dsh-models-recovery-'))
+ await writeFile(join(home, 'settings.yaml'), [
+ 'llm-pi-ai:', ' providers:', ' openrouter:', ' models:',
+ ' - id: "111"', ' zai: {}', ' acme-gateway:',
+ ' baseURL: https://gateway.example/v1', ' models:', ' - id: "custom-model"', '',
+ ].join('\n'))
+ scaffold = await launchWebScaffold({ harnessHome: home })
+ browser = await chromium.launch()
+ page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+ await page.getByRole('button', { name: '设置', exact: true }).click()
+ const dialog = page.getByRole('dialog', { name: '设置' })
+ await dialog.getByRole('button', { name: '模型', exact: true }).click()
+ await dialog.getByText(FAILURE, { exact: true }).waitFor()
+ }, 120_000)
+
+ afterAll(async () => {
+ try {
+ await browser?.close()
+ } finally {
+ try {
+ await scaffold?.close()
+ } finally {
+ if (home !== undefined) await rm(home, { recursive: true, force: true })
+ }
+ }
+ })
+
+ it('shows the failed provider beside healthy providers and keeps both add actions usable', async () => {
+ onTestFailed(() => saveFailureShot(page, 'models-settings-recovery'))
+ const dialog = page.getByRole('dialog', { name: '设置' })
+ expect(await dialog.getByRole('button', { name: '编辑 openrouter', exact: true }).count()).toBe(1)
+ expect(await dialog.getByRole('button', { name: '编辑 zai', exact: true }).count()).toBe(1)
+ expect(await dialog.getByRole('button', { name: '编辑 acme-gateway', exact: true }).count()).toBe(1)
+ expect(await dialog.getByText(CUSTOM_FAILURE, { exact: true }).count()).toBe(1)
+ expect(await dialog.getByRole('button', { name: '添加提供方', exact: true }).isEnabled()).toBe(true)
+ expect(await dialog.getByRole('button', { name: '添加自定义提供方', exact: true }).isEnabled()).toBe(true)
+ await compareOrRefreshGolden(EXPECTED, await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode())
+
+ await dialog.getByRole('button', { name: '添加提供方', exact: true }).click()
+ await dialog.getByLabel('提供方', { exact: true }).selectOption('minimax-cn')
+ await dialog.getByRole('button', { name: '保存', exact: true }).click()
+ await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor()
+ expect(await readFile(join(home, 'settings.yaml'), 'utf8')).toContain('minimax-cn: {}')
+ expect(await dialog.getByText(FAILURE, { exact: true }).count()).toBe(1)
+ })
+
+ it('rejects an invalid edit without persisting and accepts removal of the obsolete model', async () => {
+ const dialog = page.getByRole('dialog', { name: '设置' })
+ await dialog.getByRole('button', { name: '编辑 openrouter', exact: true }).click()
+ await dialog.getByText('自定义设置', { exact: true }).click()
+ await dialog.getByLabel('API 地址', { exact: true }).fill('https://gateway.example/v1')
+ const before = await readFile(join(home, 'settings.yaml'), 'utf8')
+ await dialog.getByRole('button', { name: '保存', exact: true }).click()
+ await expect.poll(() => dialog.getByText(FAILURE, { exact: true }).count()).toBe(2)
+ expect(await readFile(join(home, 'settings.yaml'), 'utf8')).toBe(before)
+ await dialog.getByRole('button', { name: '删除模型 1', exact: true }).click()
+ await dialog.getByRole('button', { name: '保存', exact: true }).click()
+ await dialog.getByText('已保存 openrouter。', { exact: true }).waitFor()
+ expect(await dialog.getByText(FAILURE, { exact: true }).count()).toBe(0)
+ const repaired = await readFile(join(home, 'settings.yaml'), 'utf8')
+ expect(repaired).not.toContain('111')
+ expect(repaired).toContain('baseURL: https://gateway.example/v1')
+ expect(tripwire.pageErrors).toEqual([])
+ })
+})
diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts
new file mode 100644
index 0000000000..52aee329fa
--- /dev/null
+++ b/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
+ let sessionId: SessionId
+ let cwd: string
+ let disposeApproval: (() => void) | undefined
+ const events: SessionEvent[] = []
+ let nativeRoot: string | undefined
+ let openLog: string
+ const opened = async (): Promise> => (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([])
+ })
+})
diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts
index e617c9aead..8ef2af7048 100644
--- a/apps/web/tests/settings-chrome.e2e.ts
+++ b/apps/web/tests/settings-chrome.e2e.ts
@@ -12,7 +12,7 @@ import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest'
import { join } from 'node:path'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
@@ -378,6 +378,10 @@ describe('web e2e: settings modal and General preferences', () => {
it('steps the content font size, applies it to body, and persists across reload', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-font-size'))
+ onTestFinished(async () => {
+ await page.keyboard.press('Escape')
+ await page.getByRole('dialog', { name: '设置', exact: true }).waitFor({ state: 'hidden' })
+ })
const readFontSize = async (target: Page = page): Promise => await target.evaluate(
() => document.body.style.getPropertyValue('--dsh-content-font-size'),
)
@@ -392,6 +396,24 @@ describe('web e2e: settings modal and General preferences', () => {
probe.remove()
return size
})
+ // The displayed value is optimistic; wait for the write before the next step.
+ const stepFontSize = async (button: Locator, px: number): Promise => {
+ const [response] = await Promise.all([
+ page.waitForResponse((reply) => {
+ if (new URL(reply.url()).pathname !== '/api/settings/mutate' || reply.request().method() !== 'POST') return false
+ const request = reply.request().postDataJSON() as { payload: { args: { ns: string } } }
+ return request.payload.args.ns === 'ui-theme'
+ }),
+ button.click(),
+ ])
+ expect(await response.finished()).toBeNull()
+ const envelope = await response.json() as { result: { ok: boolean } }
+ expect(envelope.result.ok).toBe(true)
+ await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
+ .toMatch(new RegExp(`ui-theme:\n(?:\\s+\\w+: .*\n)*?\\s+fontSize: ${px}`))
+ await page.getByRole('dialog', { name: '设置' }).getByText(String(px), { exact: true }).waitFor({ timeout: 5_000 })
+ await expect.poll(readFontSize, { timeout: 5_000 }).toBe(`${px}px`)
+ }
expect(await readFontSize()).toBe('14px')
expect(await readSecondaryFontSize()).toBe('13px')
await page.getByRole('button', { name: '设置', exact: true }).click()
@@ -400,17 +422,12 @@ describe('web e2e: settings modal and General preferences', () => {
// The stepper reveals its arrows on hover; the up arrow steps 14 → 15 → 16.
await dialog.getByText('14', { exact: true }).hover()
const increase = dialog.getByRole('button', { name: '增大字号' })
- await increase.click()
- await dialog.getByText('15', { exact: true }).waitFor({ timeout: 5_000 })
+ await stepFontSize(increase, 15)
// 15 is the piecewise boundary: the secondary tier holds at 13px (−2)
// where the ≤14 branch would have given 14px (−1).
await expect.poll(readSecondaryFontSize, { timeout: 5_000 }).toBe('13px')
- await increase.click()
- await dialog.getByText('16', { exact: true }).waitFor({ timeout: 5_000 })
- await expect.poll(readFontSize, { timeout: 5_000 }).toBe('16px')
+ await stepFontSize(increase, 16)
await expect.poll(readSecondaryFontSize, { timeout: 5_000 }).toBe('14px')
- await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
- .toMatch(/ui-theme:\n(?:\s+\w+: .*\n)*?\s+fontSize: 16/)
await page.keyboard.press('Escape')
// Reload: the boot script embeds the durable size and ThemeRuntime seeds
@@ -429,11 +446,8 @@ describe('web e2e: settings modal and General preferences', () => {
await restored.waitFor({ timeout: 10_000 })
await restored.getByText('16', { exact: true }).hover()
const decrease = restored.getByRole('button', { name: '减小字号' })
- await decrease.click()
- await restored.getByText('15', { exact: true }).waitFor({ timeout: 5_000 })
- await decrease.click()
- await restored.getByText('14', { exact: true }).waitFor({ timeout: 5_000 })
- await expect.poll(readFontSize, { timeout: 5_000 }).toBe('14px')
+ await stepFontSize(decrease, 15)
+ await stepFontSize(decrease, 14)
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts
index 61acc027fd..fc0ef1694c 100644
--- a/apps/web/tests/shipped-composition.e2e.ts
+++ b/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',
diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts
index 9457407c0a..0a176a9af0 100644
--- a/apps/web/tests/turn-tail-actions.e2e.ts
+++ b/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 {
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) })
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 1e3fbcd850..2a25226d5f 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -43,6 +43,7 @@
"tests/plugin-config.e2e.ts",
"tests/settings-chrome.e2e.ts",
"tests/models-settings.e2e.ts",
+ "tests/models-settings-recovery.e2e.ts",
"tests/default-model.e2e.ts",
"tests/github-ready-review.e2e.ts",
"tests/streaming-fence-highlight.e2e.ts",
@@ -61,6 +62,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",
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index dff22a99ec..0e15b4f088 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -46,15 +46,21 @@ function rejectStandaloneServe(): Plugin {
*/
function emitPreviewPage(): Plugin {
let bootstrapFile: string | undefined
+ let write = true
return {
name: 'dsh-emit-preview-page',
+ configResolved(config) {
+ write = config.build.write
+ },
generateBundle(_options, bundle) {
+ if (!write) return
for (const item of Object.values(bundle)) {
if (item.type === 'chunk' && item.isEntry && item.name === 'bootstrap') bootstrapFile = item.fileName
}
if (bootstrapFile === undefined) throw new Error('vite: preview bootstrap entry missing from the bundle')
},
async closeBundle() {
+ if (!write) return
// A build that failed before generateBundle has no page to splice.
if (bootstrapFile === undefined) return
const page = await readFile(src('./dist/index.html'), 'utf8')
diff --git a/benchmarks/agent-continuation/README.i18n.yaml b/benchmarks/agent-continuation/README.i18n.yaml
index f2e65cc938..68bb7e50e6 100644
--- a/benchmarks/agent-continuation/README.i18n.yaml
+++ b/benchmarks/agent-continuation/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 benchmarks/agent-continuation/README.md
-README.md: 75f804fc18deb90f1db40093cd52e5df10886b46
-README.zh.md: 047c4988beaebe96e0bd561674e5314139608c41
+README.md: cb3d99d749922e08d493f0b58dab04c63356024a
+README.zh.md: 30331aa886d75f8582c6e386cbb022bb487dbe9f
diff --git a/benchmarks/agent-continuation/README.md b/benchmarks/agent-continuation/README.md
index 75f804fc18..cb3d99d749 100644
--- a/benchmarks/agent-continuation/README.md
+++ b/benchmarks/agent-continuation/README.md
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
## Summary
-Measure long-history request processing, cold tool-heavy continuation, and repeated discovery of inactive fork children without network services or recorded user data. The SDK variant drives 100 turns and 800 real file reads through the shipped sdk-minimal profile; other cases isolate backend service costs. No case renders a browser.
+Measure long-history request processing, cold tool-heavy continuation, and repeated discovery of inactive fork children without network services or recorded user data. The SDK variant drives 100 turns and 800 real file reads through the shipped sdk-minimal profile with an explicit editor patch; other cases isolate backend service costs. No case renders a browser.
## Table of Contents
diff --git a/benchmarks/agent-continuation/README.zh.md b/benchmarks/agent-continuation/README.zh.md
index 047c4988be..30331aa886 100644
--- a/benchmarks/agent-continuation/README.zh.md
+++ b/benchmarks/agent-continuation/README.zh.md
@@ -4,7 +4,7 @@
## Summary
-在不使用网络服务或录制用户数据的情况下,测量长历史请求处理、冷工具密集续聊和重复发现非活动 fork 子会话。SDK 变体通过已发布 sdk-minimal profile 执行 100 个轮次和 800 次真实文件读取;其他用例隔离后端服务成本。所有用例均不渲染浏览器。
+在不使用网络服务或录制用户数据的情况下,测量长历史请求处理、冷工具密集续聊和重复发现非活动 fork 子会话。SDK 变体通过已发布 sdk-minimal profile 和显式 editor patch 执行 100 个轮次和 800 次真实文件读取;其他用例隔离后端服务成本。所有用例均不渲染浏览器。
## Table of Contents
diff --git a/benchmarks/agent-continuation/profile-continuation.worker.ts b/benchmarks/agent-continuation/profile-continuation.worker.ts
index 4e0fade29b..531fc2f50d 100644
--- a/benchmarks/agent-continuation/profile-continuation.worker.ts
+++ b/benchmarks/agent-continuation/profile-continuation.worker.ts
@@ -1,4 +1,4 @@
-/** End-to-end SDK continuation through the built dsh sdk-minimal profile and real file tools. */
+/** End-to-end SDK continuation through built dsh sdk-minimal with an explicitly mounted file editor. */
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
@@ -27,7 +27,10 @@ async function run(root: string): Promise {
await writeFile(patch, [
'- id: llm-deepseek', ' disabled: true',
'- id: sessions', ' config:', ' root: ' + JSON.stringify(join(root, 'profile-sessions')), ' compression: zstd',
- '- insert:', ' - id: benchmark-model', ' name: ' + JSON.stringify(join(import.meta.dirname, 'profile-adapter.js')),
+ '- insert:',
+ ' - id: fs-local', " name: '@deepseek-ai/dsh-fs-local'",
+ ' - id: str-replace-editor', " name: '@deepseek-ai/dsh-tool-str-replace-editor'",
+ ' - id: benchmark-model', ' name: ' + JSON.stringify(join(import.meta.dirname, 'profile-adapter.js')),
'',
].join('\n'))
const env: NodeJS.ProcessEnv = {
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index 4254334b41..919cd841d9 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/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: cb928dbe63e6dfebe2c3a48fdca2b0fd5750180a
-config-catalog.zh.md: db47d7ff19691b7de78ed349ba99c535cf2e487a
+config-catalog.md: 79ea53c3a55467fb26ada6400e82c416e5f0472f
+config-catalog.zh.md: bca6b34f5246b1465cc92117e68e708bf5367bac
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index cb928dbe63..79ea53c3a5 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1389,7 +1389,7 @@ export type PiAiThinkingTokenBudgetField = NonNullable
@@ -2762,7 +2762,7 @@ export interface Config {
}
```
-Source: [`packages/shell/tool-bash-persistent/src/index.ts:432`](../packages/shell/tool-bash-persistent/src/index.ts)
+Source: [`packages/shell/tool-bash-persistent/src/index.ts:435`](../packages/shell/tool-bash-persistent/src/index.ts)
@@ -2891,6 +2891,22 @@ export interface Config {
Source: [`packages/lsp/tool-lsp/src/index.ts:57`](../packages/lsp/tool-lsp/src/index.ts)
+
+
+## `@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)
+
## `@deepseek-ai/dsh-tool-pwsh`
@@ -3463,7 +3479,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))
@@ -3561,6 +3577,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-atomic-write` ([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts))
- `@deepseek-ai/dsh-base` ([`packages/bundle/base/src/index.ts`](../packages/bundle/base/src/index.ts))
- `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts))
+- `@deepseek-ai/dsh-chunked-list` ([`packages/util/chunked-list/src/index.ts`](../packages/util/chunked-list/src/index.ts))
- `@deepseek-ai/dsh-client-store` ([`packages/client/store/src/index.ts`](../packages/client/store/src/index.ts))
- `@deepseek-ai/dsh-client-test-runtime` ([`packages/test-support/client-runtime/src/index.ts`](../packages/test-support/client-runtime/src/index.ts))
- `@deepseek-ai/dsh-client-ui-dockkit` ([`packages/client/ui-dockkit/src/index.ts`](../packages/client/ui-dockkit/src/index.ts))
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index db47d7ff19..bca6b34f52 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -1392,7 +1392,7 @@ export type PiAiThinkingTokenBudgetField = NonNullable
@@ -2765,7 +2765,7 @@ export interface Config {
}
```
-来源:[`packages/shell/tool-bash-persistent/src/index.ts:432`](../packages/shell/tool-bash-persistent/src/index.ts)
+来源:[`packages/shell/tool-bash-persistent/src/index.ts:435`](../packages/shell/tool-bash-persistent/src/index.ts)
@@ -2894,6 +2894,22 @@ export interface Config {
来源:[`packages/lsp/tool-lsp/src/index.ts:57`](../packages/lsp/tool-lsp/src/index.ts)
+
+
+## `@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)
+
## `@deepseek-ai/dsh-tool-pwsh`
@@ -3466,7 +3482,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))
@@ -3563,6 +3579,7 @@ export interface Config {
- `@deepseek-ai/dsh-atomic-write`([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts))
- `@deepseek-ai/dsh-base`([`packages/bundle/base/src/index.ts`](../packages/bundle/base/src/index.ts))
- `@deepseek-ai/dsh-brand`([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts))
+- `@deepseek-ai/dsh-chunked-list`([`packages/util/chunked-list/src/index.ts`](../packages/util/chunked-list/src/index.ts))
- `@deepseek-ai/dsh-client-store`([`packages/client/store/src/index.ts`](../packages/client/store/src/index.ts))
- `@deepseek-ai/dsh-client-test-runtime`([`packages/test-support/client-runtime/src/index.ts`](../packages/test-support/client-runtime/src/index.ts))
- `@deepseek-ai/dsh-client-ui-dockkit`([`packages/client/ui-dockkit/src/index.ts`](../packages/client/ui-dockkit/src/index.ts))
diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml
index 39758901ca..6bad710968 100644
--- a/docs/event-producer-consumer.i18n.yaml
+++ b/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: a31a18e423750a33b1644831deeba1505ebb0815
-event-producer-consumer.zh.md: cd7fde41aaafb03258cf8ec867e0aa1a2c8f06e4
+event-producer-consumer.md: 843c567ea1f9342231f9db22a9d431c351bbdaf3
+event-producer-consumer.zh.md: 0b8803c591e88d52dfe478d49f32d4ada37ea38d
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index a31a18e423..843c567ea1 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -57,10 +57,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
-| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:168`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
-| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:159`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
+| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:170`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
+| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:150`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:161`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
@@ -68,7 +68,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) |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index cd7fde41aa..0b8803c591 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -70,7 +70,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) |
diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml
index c6ed924269..08ab4f66e9 100644
--- a/docs/module-graph.i18n.yaml
+++ b/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: 7b28bef1df9ce985133b475fdee1a13c6b2d724d
-module-graph.zh.md: d66bb0792d8446c8a6282595fc0f42a016b3d9ef
+module-graph.md: bd414dedc39f249580a2d0b89ba9c4d00d1493d1
+module-graph.zh.md: 80096e1fa14e769ba2d9ade73bf4c19b97d7f2ca
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 7b28bef1df..bd414dedc3 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -10,6 +10,7 @@ flowchart TD
subgraph group_util["packages/util"]
pkg_atomic_write["atomic-write"]
pkg_brand["brand"]
+ pkg_chunked_list["chunked-list"]
pkg_deque["deque"]
pkg_home_paths["home-paths"]
pkg_http_proxy["http-proxy"]
@@ -55,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"]
@@ -744,6 +746,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
@@ -1186,6 +1194,7 @@ flowchart TD
| --- | --- | --- |
| [`atomic-write`](../packages/util/atomic-write) | `util` | — |
| [`brand`](../packages/util/brand) | `util` | — |
+| [`chunked-list`](../packages/util/chunked-list) | `util` | — |
| [`deque`](../packages/util/deque) | `util` | — |
| [`home-paths`](../packages/util/home-paths) | `util` | — |
| [`http-proxy`](../packages/util/http-proxy) | `util` | — |
@@ -1383,6 +1392,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) |
diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md
index d66bb0792d..80096e1fa1 100644
--- a/docs/module-graph.zh.md
+++ b/docs/module-graph.zh.md
@@ -12,6 +12,7 @@ flowchart TD
subgraph group_util["packages/util"]
pkg_atomic_write["atomic-write"]
pkg_brand["brand"]
+ pkg_chunked_list["chunked-list"]
pkg_deque["deque"]
pkg_home_paths["home-paths"]
pkg_http_proxy["http-proxy"]
@@ -57,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"]
@@ -746,6 +748,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
@@ -1188,6 +1196,7 @@ flowchart TD
| --- | --- | --- |
| [`atomic-write`](../packages/util/atomic-write) | `util` | — |
| [`brand`](../packages/util/brand) | `util` | — |
+| [`chunked-list`](../packages/util/chunked-list) | `util` | — |
| [`deque`](../packages/util/deque) | `util` | — |
| [`home-paths`](../packages/util/home-paths) | `util` | — |
| [`http-proxy`](../packages/util/http-proxy) | `util` | — |
@@ -1385,6 +1394,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) |
diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml
index 1e60882ff3..8049e2d430 100644
--- a/docs/persistence-catalog.i18n.yaml
+++ b/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: b239204810f92d866d309214678a22901e6f3ef6
-persistence-catalog.zh.md: 86d81a5a64322c98ea288c0aa0289550153eedf0
+persistence-catalog.md: a9e1221a564a4ad1af1353278f1215bb33fb9c4f
+persistence-catalog.zh.md: 2b74b03b4b783f848385e66e15c40d73f50a788b
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index b239204810..a9e1221a56 100644
--- a/docs/persistence-catalog.md
+++ b/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/*`
+
+
+
+#### `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/*`
@@ -761,6 +776,20 @@ Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/
### `subagent/*`
+
+
+#### `subagent/catalog` — log-only
+
+```ts persistence-catalog
+/**
+ * A direct child's complete discovery fact.
+ * @param data - versioned parent-owned catalog entry.
+ */
+'subagent/catalog': SubagentCatalogEvent
+```
+
+Source: [`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts)
+
#### `subagent/descriptor` — log-only
diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md
index 86d81a5a64..2b74b03b4b 100644
--- a/docs/persistence-catalog.zh.md
+++ b/docs/persistence-catalog.zh.md
@@ -398,6 +398,21 @@ export type SessionEvent = {
来源:[`packages/compaction/compaction/src/types.ts:34`](../packages/compaction/compaction/src/types.ts)
+### `deliverables/*`
+
+
+
+#### `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/*`
@@ -763,6 +778,20 @@ export type SessionEvent = {
### `subagent/*`
+
+
+#### `subagent/catalog` — log-only
+
+```ts persistence-catalog
+/**
+ * A direct child's complete discovery fact.
+ * @param data - versioned parent-owned catalog entry.
+ */
+'subagent/catalog': SubagentCatalogEvent
+```
+
+来源:[`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts)
+
#### `subagent/descriptor` — log-only
diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml
index d59028fa8b..ff5cdfed88 100644
--- a/docs/subsystems/llm-streaming.i18n.yaml
+++ b/docs/subsystems/llm-streaming.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md
-llm-streaming.md: 35a4db80d17205f91342523c8413054743fb7481
-llm-streaming.zh.md: 5e3ddc55ab2972e3e2a318baf1e37e0013d324e8
+llm-streaming.md: cadfda5553ca5d5e0df78f40b6a7a75ef7cc71a6
+llm-streaming.zh.md: 5f73d544459268ce83a30218b710dd7c29f7a614
diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md
index 35a4db80d1..cadfda5553 100644
--- a/docs/subsystems/llm-streaming.md
+++ b/docs/subsystems/llm-streaming.md
@@ -492,6 +492,8 @@ interface LlmConfigurableProvider {
* from outside.
*/
declared?: boolean
+ /** Configuration diagnostic for repair; unaffected models may remain serviceable. */
+ error?: string
}
```
diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md
index 5e3ddc55ab..5f73d54445 100644
--- a/docs/subsystems/llm-streaming.zh.md
+++ b/docs/subsystems/llm-streaming.zh.md
@@ -498,6 +498,8 @@ interface LlmConfigurableProvider {
* from outside.
*/
declared?: boolean
+ /** Configuration diagnostic for repair; unaffected models may remain serviceable. */
+ error?: string
}
```
diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml
index 1b16e2a532..98c8a3a7b2 100644
--- a/docs/subsystems/sidebar-right.i18n.yaml
+++ b/docs/subsystems/sidebar-right.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md
-sidebar-right.md: db4103a282287d8ea163d66b19efec27e0a0630f
-sidebar-right.zh.md: 1cb0b1e67e99acd7cb6aa12272cd57a5fdd207e0
+sidebar-right.md: 59eda91862282d240d49764d53c17f531ff4bf21
+sidebar-right.zh.md: 3ecb5e9ef496471316414b6aeb0c683a78188025
diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md
index db4103a282..59eda91862 100644
--- a/docs/subsystems/sidebar-right.md
+++ b/docs/subsystems/sidebar-right.md
@@ -8,7 +8,7 @@ This page is the reference for the subsystem's contracts: addresses, tab-type re
## Position and ownership
-One docking surface exists per Session, held in a session-scoped slot store and drawn by the `rightbar` seat; a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and the last pane reseeds the guide tab.
+One docking surface exists per Session, held in a session-scoped slot store and drawn by `rightbar.session`. The root-scoped `rightbar` controller mounts that seat only while Conversation is selected; a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and the last pane reseeds the guide tab.
A tab type is two registrations that share one `kind`: a static definition in `ctx.sidebarRightTabs` saying which addresses the type opens, and a keyed slot registration supplying its body. The framework injects `useTabInfo()` for live Sidebar, pane and tab information; each type keeps its own state in its slot store. Packages import each other's declarations only as types.
diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md
index 1cb0b1e67e..3ecb5e9ef4 100644
--- a/docs/subsystems/sidebar-right.zh.md
+++ b/docs/subsystems/sidebar-right.zh.md
@@ -8,7 +8,7 @@
## 定位与归属
-每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar` 席位绘制;刷新页面后每个会话回到折叠的默认态,切换会话时各自的面保持原状([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,最后一个 pane 会重新种入引导 tab。
+每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar.session` 绘制。root 作用域的 `rightbar` 控制器仅在选中 Conversation 时挂载该席位;刷新页面后每个会话回到折叠的默认态,切换会话时各自的面保持原状([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,最后一个 pane 会重新种入引导 tab。
一个 tab 类型是共用一个 `kind` 的两次注册:在 `ctx.sidebarRightTabs` 里的静态定义说明该类型打开哪些地址,一次 keyed slot 注册提供它的正文。框架注入 `useTabInfo()` 以读取 Sidebar、窗格和标签的实时信息;各类型把自身状态放在 slot store 里。各包之间只以类型形式引用彼此的声明。
diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml
index 49925a9aa5..d829845174 100644
--- a/docs/subsystems/slots.i18n.yaml
+++ b/docs/subsystems/slots.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/slots.md
-slots.md: 136607b035c4a40c8ab96d6bf933e6b5c8a925bf
-slots.zh.md: b3a54048f5df20ad3ef271aea868ce15d5e18bce
+slots.md: e659a0ccff548dcf1d9aa6f422d1773a838a62ef
+slots.zh.md: 92d1af68f8b67637342055d3c11aea29194048ad
diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md
index 136607b035..e659a0ccff 100644
--- a/docs/subsystems/slots.md
+++ b/docs/subsystems/slots.md
@@ -82,6 +82,7 @@ The shipped adapters add these standard props. They are available according to t
|---|---|---|
| every scope | `useSessions`, `useSessionPendingInteraction` | `ui-session` |
| every scope | `useWorkspaces` | `ui-workspace` |
+| every scope | `usePanelInfo` | `ui-layout` |
| `session` | `sessionId`, `useSession`, `useProjection` | `ui-session` |
| `session-maybe` | optional `sessionId`, `useSession`, `useProjection` results | `ui-session` |
| `session` | `useConversation`, `useInput`, `inputActions` | `ui-conversation` |
@@ -112,6 +113,7 @@ root
├─ sidebar
│ ├─ sidebar.brand.mark
│ ├─ sidebar.brand.name
+│ ├─ sidebar.panellist
│ ├─ sidebar.footer.action
│ ├─ sidebar.workspaces
│ │ └─ sidebar.workspaces.directoryFlow
@@ -127,43 +129,45 @@ root
│ ├─ settings.models.footer
│ └─ settings.plugins.tab
│ └─ settings.plugin.item
-├─ conversation
-│ ├─ conversation.session
-│ │ └─ conversation.view
-│ │ ├─ conversation.chat.node
-│ │ │ ├─ conversation.chat.assistant-actions
-│ │ │ ├─ conversation.chat.commandview
-│ │ │ ├─ conversation.chat.turnTail
-│ │ │ └─ tool.call.toolview
-│ │ │ ├─ tool.call.images
-│ │ │ └─ tool.view.cordis
-│ │ ├─ conversation.message.images
-│ │ └─ conversation.trajectory.images
-│ ├─ conversation.session.header
-│ │ ├─ conversation.session.header.lineage
-│ │ ├─ conversation.session.header.actions
-│ │ ├─ conversation.session.header.utilities
-│ │ └─ conversation.session.header.corner
-│ ├─ conversation.composer
-│ │ └─ conversation.approval.detail
-│ ├─ conversation.composer.bar
-│ │ ├─ conversation.input.attachments
-│ │ ├─ conversation.input.plan
-│ │ └─ conversation.input.model
-│ ├─ conversation.input.overlay
-│ ├─ conversation.input.dock
-│ ├─ conversation.composer.dock
-│ ├─ conversation.input.left
-│ ├─ conversation.input.right
-│ ├─ conversation.hero.brand.mark
-│ ├─ conversation.hero.workspace
-│ │ └─ conversation.hero.workspace.directoryFlow
-│ └─ conversation.hero.agentPreset
+├─ main
+│ └─ main.conversation
+│ ├─ conversation.session
+│ │ └─ conversation.view
+│ │ ├─ conversation.chat.node
+│ │ │ ├─ conversation.chat.assistant-actions
+│ │ │ ├─ conversation.chat.commandview
+│ │ │ ├─ conversation.chat.turnTail
+│ │ │ └─ tool.call.toolview
+│ │ │ ├─ tool.call.images
+│ │ │ └─ tool.view.cordis
+│ │ ├─ conversation.message.images
+│ │ └─ conversation.trajectory.images
+│ ├─ conversation.session.header
+│ │ ├─ conversation.session.header.lineage
+│ │ ├─ conversation.session.header.actions
+│ │ ├─ conversation.session.header.utilities
+│ │ └─ conversation.session.header.corner
+│ ├─ conversation.composer
+│ │ └─ conversation.approval.detail
+│ ├─ conversation.composer.bar
+│ │ ├─ conversation.input.attachments
+│ │ ├─ conversation.input.plan
+│ │ └─ conversation.input.model
+│ ├─ conversation.input.overlay
+│ ├─ conversation.input.dock
+│ ├─ conversation.composer.dock
+│ ├─ conversation.input.left
+│ ├─ conversation.input.right
+│ ├─ conversation.hero.brand.mark
+│ ├─ conversation.hero.workspace
+│ │ └─ conversation.hero.workspace.directoryFlow
+│ └─ conversation.hero.agentPreset
├─ rightbar
-│ ├─ sidebar.right.pane.tab
-│ │ └─ sidebar.right.tab.guide
-│ ├─ sidebar.right.pane.tab.title
-│ └─ sidebar.right.tab.menu.item
+│ └─ rightbar.session
+│ ├─ sidebar.right.pane.tab
+│ │ └─ sidebar.right.tab.guide
+│ ├─ sidebar.right.pane.tab.title
+│ └─ sidebar.right.tab.menu.item
└─ shell.overlay
```
diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md
index b3a54048f5..92d1af68f8 100644
--- a/docs/subsystems/slots.zh.md
+++ b/docs/subsystems/slots.zh.md
@@ -82,6 +82,7 @@ Slot 声明固定两个相互独立的维度。
|---|---|---|
| 所有 scope | `useSessions`、`useSessionPendingInteraction` | `ui-session` |
| 所有 scope | `useWorkspaces` | `ui-workspace` |
+| 所有作用域 | `usePanelInfo` | `ui-layout` |
| `session` | `sessionId`、`useSession`、`useProjection` | `ui-session` |
| `session-maybe` | 结果可选的 `sessionId`、`useSession`、`useProjection` | `ui-session` |
| `session` | `useConversation`、`useInput`、`inputActions` | `ui-conversation` |
@@ -112,6 +113,7 @@ root
├─ sidebar
│ ├─ sidebar.brand.mark
│ ├─ sidebar.brand.name
+│ ├─ sidebar.panellist
│ ├─ sidebar.footer.action
│ ├─ sidebar.workspaces
│ │ └─ sidebar.workspaces.directoryFlow
@@ -127,43 +129,45 @@ root
│ ├─ settings.models.footer
│ └─ settings.plugins.tab
│ └─ settings.plugin.item
-├─ conversation
-│ ├─ conversation.session
-│ │ └─ conversation.view
-│ │ ├─ conversation.chat.node
-│ │ │ ├─ conversation.chat.assistant-actions
-│ │ │ ├─ conversation.chat.commandview
-│ │ │ ├─ conversation.chat.turnTail
-│ │ │ └─ tool.call.toolview
-│ │ │ ├─ tool.call.images
-│ │ │ └─ tool.view.cordis
-│ │ ├─ conversation.message.images
-│ │ └─ conversation.trajectory.images
-│ ├─ conversation.session.header
-│ │ ├─ conversation.session.header.lineage
-│ │ ├─ conversation.session.header.actions
-│ │ ├─ conversation.session.header.utilities
-│ │ └─ conversation.session.header.corner
-│ ├─ conversation.composer
-│ │ └─ conversation.approval.detail
-│ ├─ conversation.composer.bar
-│ │ ├─ conversation.input.attachments
-│ │ ├─ conversation.input.plan
-│ │ └─ conversation.input.model
-│ ├─ conversation.input.overlay
-│ ├─ conversation.input.dock
-│ ├─ conversation.composer.dock
-│ ├─ conversation.input.left
-│ ├─ conversation.input.right
-│ ├─ conversation.hero.brand.mark
-│ ├─ conversation.hero.workspace
-│ │ └─ conversation.hero.workspace.directoryFlow
-│ └─ conversation.hero.agentPreset
+├─ main
+│ └─ main.conversation
+│ ├─ conversation.session
+│ │ └─ conversation.view
+│ │ ├─ conversation.chat.node
+│ │ │ ├─ conversation.chat.assistant-actions
+│ │ │ ├─ conversation.chat.commandview
+│ │ │ ├─ conversation.chat.turnTail
+│ │ │ └─ tool.call.toolview
+│ │ │ ├─ tool.call.images
+│ │ │ └─ tool.view.cordis
+│ │ ├─ conversation.message.images
+│ │ └─ conversation.trajectory.images
+│ ├─ conversation.session.header
+│ │ ├─ conversation.session.header.lineage
+│ │ ├─ conversation.session.header.actions
+│ │ ├─ conversation.session.header.utilities
+│ │ └─ conversation.session.header.corner
+│ ├─ conversation.composer
+│ │ └─ conversation.approval.detail
+│ ├─ conversation.composer.bar
+│ │ ├─ conversation.input.attachments
+│ │ ├─ conversation.input.plan
+│ │ └─ conversation.input.model
+│ ├─ conversation.input.overlay
+│ ├─ conversation.input.dock
+│ ├─ conversation.composer.dock
+│ ├─ conversation.input.left
+│ ├─ conversation.input.right
+│ ├─ conversation.hero.brand.mark
+│ ├─ conversation.hero.workspace
+│ │ └─ conversation.hero.workspace.directoryFlow
+│ └─ conversation.hero.agentPreset
├─ rightbar
-│ ├─ sidebar.right.pane.tab
-│ │ └─ sidebar.right.tab.guide
-│ ├─ sidebar.right.pane.tab.title
-│ └─ sidebar.right.tab.menu.item
+│ └─ rightbar.session
+│ ├─ sidebar.right.pane.tab
+│ │ └─ sidebar.right.tab.guide
+│ ├─ sidebar.right.pane.tab.title
+│ └─ sidebar.right.tab.menu.item
└─ shell.overlay
```
diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml
index f64a9d5c91..4750a413d5 100644
--- a/docs/subsystems/subagent.i18n.yaml
+++ b/docs/subsystems/subagent.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/subagent.md
-subagent.md: 55e8f8af23cb71c1103c017c09e9dfd2ef365b75
-subagent.zh.md: 71a71b33771ff83ccaa6802358b8534c11930181
+subagent.md: 5f927f4c0bef57c0fda0bcb39280a5ac2012343e
+subagent.zh.md: ce827f154989404825e322682c4af6ba83182253
diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md
index 55e8f8af23..5f927f4c0b 100644
--- a/docs/subsystems/subagent.md
+++ b/docs/subsystems/subagent.md
@@ -8,6 +8,8 @@ Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subag
Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts)
+The `subagentCatalog` projection exposes `SubagentCatalogEntry[]` in parent event order through Session observations and client snapshots. Each entry contains the child id, creation time, mode, and mode-dependent label; fork-inherited catalog facts are excluded. [The subagent package](../../packages/subagent/subagent/README.md) owns catalog creation and persistence semantics.
+
## Two kinds of capability, discovered two ways
A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a one-shot run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. Those flags describe only the one-shot [`start()`](#the-provider-contract-subagentprovider) path, where the provider composes the child. **Continuable** children are composed by the continuation manager itself, so they are gated by one optional method whose presence IS the capability, with TS narrowing as the discovery mechanism: [`SubagentProvider.prepareContinuable`](#the-provider-contract-subagentprovider).
@@ -672,6 +674,8 @@ list(): string[]
* fulfills; a rejection therefore has no run for the caller to dispose and
* emits no run lifecycle events. Post-publication turn and infrastructure
* failures settle through the returned run.
+ * A catalog append failure disposes the run and handles its result rejection;
+ * the caller receives the catalog error even if disposal also fails.
* @param name - the provider to use.
* @param request - child label, prompt, parent, signal, and optional capabilities.
* @returns the published holder-owned run.
diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md
index 71a71b3377..ce827f1549 100644
--- a/docs/subsystems/subagent.zh.md
+++ b/docs/subsystems/subagent.zh.md
@@ -8,6 +8,8 @@ Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.sub
源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts)
+`subagentCatalog` projection 通过 Session 观察和客户端快照暴露按父会话事件排序的 `SubagentCatalogEntry[]`。每个条目包含子级 id、创建时间、模式和依模式确定的标签;fork 继承的目录事实不在其中。[subagent 包](../../packages/subagent/subagent/README.zh.md) 定义目录创建和持久化语义。
+
## 两类能力,两种发现方式
提供方通过一个静态描述符公布其**启动时**功能,服务会在单次 run 存在之前即行检查;如果请求依赖提供方不具备的功能,会被明确拒绝(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不会被接受后静默忽略。这些 flag 仅描述单次 [`start()`](#the-provider-contract-subagentprovider) 路径,即由提供方组合子 agent 的路径。**可继续**子 agent 由继续执行管理器自行组合,因此它们由唯一一个可选方法把关,方法存在即为能力,并以 TypeScript 的类型收窄作为发现机制:[`SubagentProvider.prepareContinuable`](#the-provider-contract-subagentprovider)。
@@ -676,6 +678,8 @@ list(): string[]
* fulfills; a rejection therefore has no run for the caller to dispose and
* emits no run lifecycle events. Post-publication turn and infrastructure
* failures settle through the returned run.
+ * A catalog append failure disposes the run and handles its result rejection;
+ * the caller receives the catalog error even if disposal also fails.
* @param name - the provider to use.
* @param request - child label, prompt, parent, signal, and optional capabilities.
* @returns the published holder-owned run.
diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml
index 16319731c8..49f6610bd2 100644
--- a/docs/tool-catalog.i18n.yaml
+++ b/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
diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md
index c85201523e..c2ea95ff46 100644
--- a/docs/tool-catalog.md
+++ b/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.
+
+
+## `@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.
+
## `@deepseek-ai/dsh-tool-pwsh`
diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md
index ce5f5b57f6..41fc1eaa3c 100644
--- a/docs/tool-catalog.zh.md
+++ b/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)后,该参数会被完全移除。
+
+
+## `@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 提供源文件打开与卡片。
+
## `@deepseek-ai/dsh-tool-pwsh`
diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml
index 04933034ac..476fb26576 100644
--- a/docs/user/guide/python-sdk.i18n.yaml
+++ b/docs/user/guide/python-sdk.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/user/guide/python-sdk.md
-python-sdk.md: 88cf32f60c285a4ce7c09e424532a6d9b7b00890
-python-sdk.zh.md: e443ec811a3b42034c75162b759408f1acd3486f
+python-sdk.md: 6fde258d9f4f3e8ba1fd41537129ba5b8f02fd58
+python-sdk.zh.md: f50fd6d2bd37e3dc203b519e07d0df4098cab0c7
diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md
index 88cf32f60c..6fde258d9f 100644
--- a/docs/user/guide/python-sdk.md
+++ b/docs/user/guide/python-sdk.md
@@ -129,19 +129,35 @@ The first command initializes the shipped standalone profile. The second forward
Another `profile` is valid when it includes `@deepseek-ai/dsh-sdk-app` or another JSON-RPC server row. Missing server rows, unresolved plugins, and invalid patches fail during startup instead of falling back to another composition.
+
+### Opt in to `str_replace_editor`
+
+The bundled runtime includes `str_replace_editor`, but `sdk-minimal` omits it from the default Cordis tree. To use it, save this configuration as `editor.patch.yml`; `insert` adds both the editor and the filesystem provider that the minimal profile lacks:
+
+```yaml
+- insert:
+ - id: fs-local
+ name: '@deepseek-ai/dsh-fs-local'
+ config:
+ cwd: !!js process.cwd()
+ - id: tool-str-replace-editor
+ name: '@deepseek-ai/dsh-tool-str-replace-editor'
+```
+
+Pass `patches=("/absolute/path/to/editor.patch.yml",)` when constructing `DeepSeekHarness(profile="sdk-minimal", ...)`, or put the patch in `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` for persistent configuration. On the next runtime launch, model requests include `str_replace_editor` beside the persistent shell. The local filesystem provider uses the runtime working directory for relative paths; like the minimal shell, it does not confine access to that directory. For the standard `sdk` profile, insert only the editor row so it uses the existing filesystem provider and policies.
+
## Understand the minimal profile
| Property | Value |
|---|---|
| System prompt | `DSH_SYSTEM_PROMPT`, falling back to `You are a helpful software engineer assistant.` |
| Model in `minimal.py` | `--model`, then `DSH_MODEL`, then `deepseek-v4-flash` |
-| Model-facing tools | Persistent `bash` on Linux/macOS or `pwsh` on Windows, plus `str_replace_editor` |
+| Model-facing tool | Persistent `bash` on Linux/macOS or `pwsh` on Windows |
| Shell timeout | 300 seconds |
-| Editor output limit | 16,000 characters |
| Runtime context and compaction | Absent |
| Session persistence | Uncompressed JSONL under `/sessions` |
-The profile's sole bundle inserts the complete tree over an empty root and does not include `dsh-base`; later base-profile tools therefore cannot appear implicitly. It contains the SDK protocol, one environment-configured DeepSeek adapter, local execution, and persistence, while settings, managed credentials, telemetry, Web tools, subagents, local instruction discovery, and compaction are absent. It pins `danger-full-access`, so the platform-selected persistent shell and editor can modify any path visible to the runtime; use a disposable checkout or container.
+The profile's sole bundle inserts the complete tree over an empty root and does not include `dsh-base`; later base-profile tools therefore cannot appear implicitly. It contains the SDK protocol, one environment-configured DeepSeek adapter, local execution, and persistence, while filesystem tools, settings, managed credentials, telemetry, Web tools, subagents, local instruction discovery, and compaction are absent. It pins `danger-full-access`, so the platform-selected persistent shell can modify any path visible to the runtime; use a disposable checkout or container.
The installed wheel still packages the full `web` profile and frontend assets. Run `dsh web` against an explicit `DSH_HOME` when a Python SDK deployment also needs the browser application; `web` is a separate CLI application and cannot serve a Python SDK client.
diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md
index e443ec811a..f50fd6d2bd 100644
--- a/docs/user/guide/python-sdk.zh.md
+++ b/docs/user/guide/python-sdk.zh.md
@@ -129,19 +129,35 @@ dsh plugin --profile sdk-minimal add file:C:/work/my-plugin-bundle
另一个 `profile` 只有包含 `@deepseek-ai/dsh-sdk-app` 或另一个 JSON-RPC server 配置项时才有效。缺失 server 配置项、无法解析的插件和非法 patch 会在启动时失败,不会回退到其他组合。
+
+### 显式启用 `str_replace_editor`
+
+随附运行时包含 `str_replace_editor`,但 `sdk-minimal` 的默认 Cordis tree 不挂载它。要使用该工具,请将以下配置保存为 `editor.patch.yml`;`insert` 会添加 editor,以及极简 profile 缺少的文件系统后端:
+
+```yaml
+- insert:
+ - id: fs-local
+ name: '@deepseek-ai/dsh-fs-local'
+ config:
+ cwd: !!js process.cwd()
+ - id: tool-str-replace-editor
+ name: '@deepseek-ai/dsh-tool-str-replace-editor'
+```
+
+构造 `DeepSeekHarness(profile="sdk-minimal", ...)` 时传入 `patches=("/absolute/path/to/editor.patch.yml",)`,或将 patch 写入 `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` 以持久保存配置。下次运行时启动后,模型请求会在持久 shell 之外包含 `str_replace_editor`。本地文件系统后端以运行时工作目录解析相对路径;与极简 shell 一样,它不会将访问限制在该目录内。对于标准 `sdk` profile,只插入 editor 配置项,让它使用已有的文件系统后端与策略。
+
## 理解极简 profile
| 属性 | 值 |
|---|---|
| 系统提示词 | `DSH_SYSTEM_PROMPT`,未设置时为 `You are a helpful software engineer assistant.` |
| `minimal.py` 的模型 | `--model`,然后是 `DSH_MODEL`,最后是 `deepseek-v4-flash` |
-| 面向模型的工具 | Linux/macOS 上的持久 `bash` 或 Windows 上的 `pwsh`,以及 `str_replace_editor` |
+| 面向模型的工具 | Linux/macOS 上的持久 `bash` 或 Windows 上的 `pwsh` |
| Shell 超时 | 300 秒 |
-| Editor 输出上限 | 16,000 字符 |
| 运行时上下文与 compaction | 不存在 |
| 会话持久化 | `/sessions` 下的未压缩 JSONL |
-该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;settings、托管凭据、遥测、Web 工具、subagent、本地指令发现和 compaction 均不存在。它固定使用 `danger-full-access`,因此按平台选择的持久 shell 与 editor 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。
+该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;文件系统工具、settings、托管凭据、遥测、Web 工具、subagent、本地指令发现和 compaction 均不存在。它固定使用 `danger-full-access`,因此按平台选择的持久 shell 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。
已安装 wheel 仍会打包完整 `web` profile 与前端产物。如果 Python SDK 部署还需要浏览器应用,请针对显式 `DSH_HOME` 运行 `dsh web`;`web` 是独立 CLI 应用,不能为 Python SDK client 提供服务。
diff --git a/lefthook.yml b/lefthook.yml
index 1c2a693c88..237202e7e0 100644
--- a/lefthook.yml
+++ b/lefthook.yml
@@ -28,7 +28,7 @@ pre-commit:
# lefthook only inspects files present on disk — so that one case still
# falls through to the freshness assertion in the test lane.
- name: third-party notices (staged)
- glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/gen-third-party-notices.ts,scripts/build-exe-for-python-sdk.ts}'
+ glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/gen-third-party-notices.ts,scripts/browser-bundled-externals.ts,scripts/build-exe-for-python-sdk.ts,tsconfig.base*.json,packages/*/*/src/**/*,packages/*/*/tsdown.config.ts,packages/client/tsdown.client.ts,apps/*/src/**/*,apps/*/vite.config.ts}'
run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md
- name: whitespace (staged)
diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts
index 4aa32f078c..325255fb81 100644
--- a/packages/acp/acp/tests/dispose.spec.ts
+++ b/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()
+ const releaseDisposal = Promise.withResolvers()
+ releaseBlockedDisposal = () => { releaseDisposal.resolve(undefined) }
+ const disposalCompleted = Promise.withResolvers()
+ 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()
+ 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 () => {
diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml
index 33721a6727..54dd7ed873 100644
--- a/packages/bundle/base/README.i18n.yaml
+++ b/packages/bundle/base/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bundle/base/README.md
-README.md: 7b78e910b0cb54f0c64efb7816b428fe9f0d131c
-README.zh.md: b25bb58ac031792b0f6ee0b81dfff2713668c5c4
+README.md: 186d3184b344789187e0096b49dd93c191c2fde4
+README.zh.md: fcc42c39c4489afa484844a036a5f85ef72d1e7e
diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md
index 7b78e910b0..186d3184b3 100644
--- a/packages/bundle/base/README.md
+++ b/packages/bundle/base/README.md
@@ -49,7 +49,7 @@ Run `dsh --profile my-profile "your task"` and you get a working agent with mode
Out of the box, every profile built on this core provides: a DeepSeek model connection (the provider and model are configurable, and you can enable extra providers from your settings), the full tool set — file editing, shell commands, web search, public HTTP(S) fetch, subagents, task and goal tracking — durable sessions that survive restarts, and the default permission policy that confines file writes to your workspace and asks before risky actions. Web fetch runs without per-call approval; its provider rejects non-public destinations. Feedback stays in the Session log. [OTel session upload](../../session/session-telemetry-otel/README.md) defaults to `FEEDBACK_ONLY` for all users, including `deepseek-official`: new text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including context. Later records wait for the next explicit feedback; sending an authorized batch needs no further interaction or model call. `DISABLED` prevents OTel capture. The opt-in [DeepSeek session-log contributor](../../session/session-log-deepseek/README.md) remains a separate request path.
-Default file editing uses `read`, `write`, and `edit`. The optional `str_replace_editor` tool is selected independently by Web minimal and `sdk-minimal`. To add it to a base-backed profile, put this entry in the profile, home, or invocation patch:
+Default file editing uses `read`, `write`, and `edit`. The `str_replace_editor` tool remains available as an explicit opt-in. To add it to a base-backed profile, put this entry in the profile, home, or invocation patch:
```yaml
- insert:
diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md
index b25bb58ac0..fcc42c39c4 100644
--- a/packages/bundle/base/README.zh.md
+++ b/packages/bundle/base/README.zh.md
@@ -49,7 +49,7 @@ kind: "package-bundle"
开箱即用,基于本核心构建的每个 profile 都提供:DeepSeek 模型连接(provider 与模型可配置,你还可以在设置中启用额外 provider)、完整工具集——文件编辑、shell 命令、web 搜索、公开 HTTP(S) 抓取、subagent、任务与目标跟踪——可跨重启存活的持久会话,以及默认权限策略:把文件写入限制在工作区内,危险操作前征询许可。Web 抓取无需逐次审批,其提供方会拒绝非公开目的地址。反馈保存在会话日志中。[OTel 会话上传](../../session/session-telemetry-otel/README.zh.md)对所有用户默认使用 `FEEDBACK_ONLY`,包括 `deepseek-official`:新的文本反馈、消息评分、编辑与撤回会释放截至该事件的完整权威日志前缀,包含上下文。后续记录等待下一次显式反馈;发送已授权批次无需进一步交互或模型调用。`DISABLED` 阻止 OTel 捕获。需主动开启的 [DeepSeek 会话日志贡献器](../../session/session-log-deepseek/README.zh.md)仍是独立的请求路径。
-默认文件编辑使用 `read`、`write` 和 `edit`。Web minimal 与 `sdk-minimal` 各自选择可选的 `str_replace_editor` 工具。要将它加入基于 base 的 profile,请在 profile、home 或逐次调用 patch 中添加以下条目:
+默认文件编辑使用 `read`、`write` 和 `edit`。`str_replace_editor` 工具仍可显式启用。要将它加入基于 base 的 profile,请在 profile、home 或逐次调用 patch 中添加以下条目:
```yaml
- insert:
diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json
index 3d32022816..4f3407903a 100644
--- a/packages/bundle/base/package.json
+++ b/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:^",
diff --git a/packages/bundle/sdk-minimal/README.i18n.yaml b/packages/bundle/sdk-minimal/README.i18n.yaml
index cac1c4c770..b4894c1932 100644
--- a/packages/bundle/sdk-minimal/README.i18n.yaml
+++ b/packages/bundle/sdk-minimal/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bundle/sdk-minimal/README.md
-README.md: 3be71c7e319f70bddda716c92ade14336db0a065
-README.zh.md: dff652f53d0a4fd6d50c216e564c46f81f4a188b
+README.md: e67b1eb0637f870f47bd97ecff7133242e2f2210
+README.zh.md: 077d3ac0f603ce6781476b4850306d8f8486f82a
diff --git a/packages/bundle/sdk-minimal/README.md b/packages/bundle/sdk-minimal/README.md
index 3be71c7e31..e67b1eb063 100644
--- a/packages/bundle/sdk-minimal/README.md
+++ b/packages/bundle/sdk-minimal/README.md
@@ -1,5 +1,5 @@
---
-description: "Standalone two-tool SDK profile for users who need a minimal cross-platform coding agent without the shared base bundle."
+description: "Standalone single-tool SDK profile for users who need a minimal cross-platform coding agent without the shared base bundle."
kind: "package-bundle"
---
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
-Use `dsh --profile sdk-minimal` when an SDK client needs a small, explicit coding-agent runtime. The profile advertises a platform-selected persistent shell and `str_replace_editor`, persists sessions as uncompressed JSONL, and selects the model from the SDK initialization request. It supplies a complete Cordis tree and deliberately excludes `dsh-base`, Web, settings, managed credentials, telemetry, compaction, workspace instructions, skills, jobs, and subagents. Its danger-full-access policy lets the shell and editor modify any path available to the process, so use it only with an isolated workspace.
+Use `dsh --profile sdk-minimal` when an SDK client needs a small, explicit coding-agent runtime. The profile advertises only a platform-selected persistent shell, persists sessions as uncompressed JSONL, and selects the model from the SDK initialization request. It supplies a complete Cordis tree and deliberately excludes `dsh-base`, Web, settings, managed credentials, telemetry, compaction, filesystem tools, workspace instructions, skills, jobs, and subagents. Its danger-full-access policy lets the shell modify any path available to the process, so use it only with an isolated workspace.
## Table of Contents
@@ -46,7 +46,7 @@ The profile mounts exactly one persistent shell stack: Bash on Linux and macOS,
Implementation internals — click to expand
-The bundle's single insert is the complete application tree: SDK stdio startup and JSON-RPC serving, one environment-configured DeepSeek adapter, the explicit agent core, local subprocess and unrestricted filesystem providers, a platform-selected persistent shell PTY, the string-replace editor, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not inherit another bundle, so every extra row is an explicit profile change.
+The bundle's single insert is the complete application tree: SDK stdio startup and JSON-RPC serving, one environment-configured DeepSeek adapter, the explicit agent core, local subprocess execution, a platform-selected persistent shell PTY, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not inherit another bundle, so every extra row is an explicit profile change.
### Source map
@@ -77,11 +77,11 @@ The bundle's single insert is the complete application tree: SDK stdio startup a
#### What the model sees
-The system prompt is `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.`. The only advertised tools are owner-scoped persistent `bash` on Linux/macOS or `pwsh` on Windows, plus `str_replace_editor`; runtime context, workspace instructions, skills, jobs controls, compaction, and Harness identity are absent.
+The system prompt is `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.`. The only advertised tool is owner-scoped persistent `bash` on Linux/macOS or `pwsh` on Windows; runtime context, filesystem tools, workspace instructions, skills, jobs controls, compaction, and Harness identity are absent.
#### Token effect
-One stable persona plus the two tool schemas. Tool results and ordinary conversation history grow with the session.
+One stable persona plus one tool schema. Tool results and ordinary conversation history grow with the session.
#### KV Cache effect
diff --git a/packages/bundle/sdk-minimal/README.zh.md b/packages/bundle/sdk-minimal/README.zh.md
index dff652f53d..077d3ac0f6 100644
--- a/packages/bundle/sdk-minimal/README.zh.md
+++ b/packages/bundle/sdk-minimal/README.zh.md
@@ -1,5 +1,5 @@
---
-description: "供需要不含共享 base bundle 的极简跨平台 coding agent 的用户使用的独立双工具 SDK profile。"
+description: "供需要不含共享 base bundle 的极简跨平台 coding agent 的用户使用的独立单工具 SDK profile。"
kind: "package-bundle"
---
@@ -9,7 +9,7 @@ kind: "package-bundle"
## 概述
-当 SDK 客户端需要小型、显式的 coding agent 运行时时,请使用 `dsh --profile sdk-minimal`。该 profile 只公布按平台选择的持久 shell 与 `str_replace_editor`,把会话持久化为未压缩 JSONL,并从 SDK 初始化请求选择模型。它提供完整 Cordis 配置树,并刻意排除 `dsh-base`、Web、settings、托管凭据、遥测、compaction、workspace 指令、skills、jobs 与 subagent。其 danger-full-access 策略允许 shell 与编辑器修改进程可访问的任何路径,因此只能配合隔离 workspace 使用。
+当 SDK 客户端需要小型、显式的 coding agent 运行时时,请使用 `dsh --profile sdk-minimal`。该 profile 只公布按平台选择的持久 shell,把会话持久化为未压缩 JSONL,并从 SDK 初始化请求选择模型。它提供完整 Cordis 配置树,并刻意排除 `dsh-base`、Web、settings、托管凭据、遥测、compaction、文件系统工具、workspace 指令、skills、jobs 与 subagent。其 danger-full-access 策略允许 shell 修改进程可访问的任何路径,因此只能配合隔离 workspace 使用。
## 目录
@@ -46,7 +46,7 @@ dsh --profile sdk-minimal
实现细节——点击展开
-该 bundle 的单个 insert 就是完整应用配置树:SDK stdio 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、显式 agent 核心、本地子进程与不受限文件系统提供方、按平台选择的持久 shell PTY、字符串替换编辑器,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不继承其他 bundle,因此每个额外配置项都是显式 profile 变更。
+该 bundle 的单个 insert 就是完整应用配置树:SDK stdio 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、显式 agent 核心、本地子进程执行、按平台选择的持久 shell PTY,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不继承其他 bundle,因此每个额外配置项都是显式 profile 变更。
### 源码地图
@@ -77,11 +77,11 @@ dsh --profile sdk-minimal
#### 模型看到的内容
-系统提示词取 `DSH_SYSTEM_PROMPT`,未设置时使用 `You are a helpful software engineer assistant.`。对外公布的工具只有 Linux/macOS 上 agent 所有的持久 `bash` 或 Windows 上的 `pwsh`,外加 `str_replace_editor`;运行时上下文、workspace 指令、skills、jobs 控制、compaction 与 Harness 身份均不存在。
+系统提示词取 `DSH_SYSTEM_PROMPT`,未设置时使用 `You are a helpful software engineer assistant.`。对外公布的唯一工具是 Linux/macOS 上 agent 所有的持久 `bash` 或 Windows 上的 `pwsh`;运行时上下文、文件系统工具、workspace 指令、skills、jobs 控制、compaction 与 Harness 身份均不存在。
#### Token 影响
-一个稳定 persona 加两个工具 schema。工具结果与普通对话历史随会话增长。
+一个稳定 persona 加一个工具 schema。工具结果与普通对话历史随会话增长。
#### KV Cache 影响
diff --git a/packages/bundle/sdk-minimal/cordis.patch.yml b/packages/bundle/sdk-minimal/cordis.patch.yml
index f19486e7d0..c96f07d550 100644
--- a/packages/bundle/sdk-minimal/cordis.patch.yml
+++ b/packages/bundle/sdk-minimal/cordis.patch.yml
@@ -63,13 +63,6 @@
shellDialect: pwsh
timeoutMs: 300000
- # The editor uses the bare local filesystem; persistent Bash still consumes
- # the shared danger-full-access sandbox policy above.
- - id: fs-local
- name: '@deepseek-ai/dsh-fs-local'
- config:
- cwd: !!js process.cwd()
-
# Keep the minimal agent kernel explicit: unlike dsh-base, this profile
# owns each service row and omits every optional producer it does not use.
- id: timer
@@ -135,8 +128,7 @@
description: |-
Run commands in a bash shell
* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.
- * You don't have access to the internet via this tool.
- * You do have access to a mirror of common linux and python packages via apt and pip.
+ * Network access depends on the task environment. Prefer configured mirrors/proxies when they are available.
* State is persistent across command calls and discussions with the user.
* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.
* Please avoid commands that may produce a very large amount of output.
@@ -156,11 +148,6 @@
* Please avoid commands that may produce a very large amount of output.
* Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process.
- - id: str-replace-editor
- name: '@deepseek-ai/dsh-tool-str-replace-editor'
- config:
- maxOutputChars: 16000
-
- id: sessions
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json
index 66984a7129..d95375faed 100644
--- a/packages/bundle/sdk-minimal/package.json
+++ b/packages/bundle/sdk-minimal/package.json
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-sdk-minimal",
- "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, editor, and JSONL sessions",
+ "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, and JSONL sessions",
"version": "0.1.5-alpha.1",
"publishConfig": {
"access": "public"
@@ -38,7 +38,6 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^",
- "@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-jobs-local": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
@@ -61,7 +60,6 @@
"@deepseek-ai/dsh-terminal-bash": "workspace:^",
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
"@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^",
- "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^"
},
"peerDependencies": {
diff --git a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts
index d74cba198b..39867f9d21 100644
--- a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts
+++ b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts
@@ -39,7 +39,6 @@ describe('dsh-sdk-minimal bundle', () => {
['pty', '@deepseek-ai/dsh-terminal'],
['terminal-bash', '@deepseek-ai/dsh-terminal-bash'],
['terminal-pwsh', '@deepseek-ai/dsh-terminal-bash'],
- ['fs-local', '@deepseek-ai/dsh-fs-local'],
['timer', '@deepseek-ai/cordis-plugin-timer'],
['llm', '@deepseek-ai/dsh-llm'],
['session', '@deepseek-ai/dsh-session'],
@@ -57,7 +56,6 @@ describe('dsh-sdk-minimal bundle', () => {
['agent-loop', '@deepseek-ai/dsh-agent-loop'],
['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'],
['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'],
- ['str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor'],
['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'],
])
expect(rows.find(row => row.id === 'sdk-app-startup')?.config).toEqual({ profile: 'sdk-minimal' })
diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md
index 0d1633b902..91e57ed5e9 100644
--- a/packages/client/AGENTS.md
+++ b/packages/client/AGENTS.md
@@ -60,9 +60,9 @@ Npm sections describe installation and development relationships; each build fac
1. **Every client package keeps Cordis in matching `peerDependencies` and `devDependencies`.** This includes the static packages because their Node face participates in the same Cordis plugin contract.
2. **A package under `packages/client/` is always covered; `dsh.client` marks a Client/Host package outside that directory.** Explicit include/exclude entries handle exceptions. Every covered package's Host entry is scanned, while a `./client` export alone does not select dependency policy.
-3. **Browser and type relationships are development-only.** Client imports, type-only imports, module augmentations, TypeScript project references, `dsh.client.inject`, invariant companions, and metadata-only peers belong only in `devDependencies`. Configuration-only entries that Knip cannot infer from imports are listed in the dependency policy and projected into `knip.json` by `--fix`.
+3. **Browser and type relationships are development-only.** Workspace and third-party Client imports, type-only imports, module augmentations, TypeScript project references, `dsh.client.inject`, and metadata-only peers belong only in `devDependencies`. Configuration-only entries absent from imports are listed in the dependency policy; Node companion value imports follow the Host rules below.
4. **Host value imports require classified exports.** A workspace value reached from the package's Host entry belongs only in `dependencies` when its exact module specifier and runtime export appear in `safeHostDependencyExports`. Exports whose identity or module state must be shared appear in `peerRequiredHostExports` and keep the whole package edge in matching `peerDependencies` and `devDependencies`. The verifier rejects unclassified exports before `--fix` writes manifests.
-5. **Ordinary installed libraries stay in `dependencies`.** This includes private implementation libraries bundled into `lib/client.js` and bare imports left in a statically linked `lib/index.js`; the final Vite host, not the library build, merges and splits the latter.
+5. **Only Host runtime libraries require installation dependencies.** Browser-only third-party implementations belong in `devDependencies`, whether inlined into `lib/client.js` or retained as bare imports in a static browser library. Static libraries are Web-shell build inputs; the final Vite build merges their imports and styles. Distributed browser code remains subject to runtime license disclosure ([decision](../../.agents/notes/implemented/process/2026-09-08-browser-third-party-build-inputs.md)).
6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents.
7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view.
diff --git a/packages/client/locale/tests/language-row.client.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx
index f10adf664e..d65a1c82f0 100644
--- a/packages/client/locale/tests/language-row.client.spec.tsx
+++ b/packages/client/locale/tests/language-row.client.spec.tsx
@@ -12,6 +12,7 @@ import { createLanguageRowStore } from '../src/client/settings-store.ts'
// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
afterEach(cleanup)
@@ -41,7 +42,7 @@ function mount(active = 'en') {
const props: LanguageRowComponentProps = {
useSessions: emptySessions(),
useSessionPendingInteraction,
- useResource,
+ usePanelInfo, useResource,
useWorkspaces: emptyWorkspaces(),
useStore: bindSnapshotSelector(store),
actions: store.actions,
diff --git a/packages/client/store/README.i18n.yaml b/packages/client/store/README.i18n.yaml
index d6af2417f1..657b1a396d 100644
--- a/packages/client/store/README.i18n.yaml
+++ b/packages/client/store/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/store/README.md
-README.md: 9d675c1189ae194b913293c89904b4c4f39c879e
-README.zh.md: 3ff170b71c320688e50c02c20479ee1d94061a87
+README.md: 0d4525ab6eb2a58bdea89673b96f9aaf5e0e2cf8
+README.zh.md: b66a2e31675c82d933526d40ed9eba20bc5077d9
diff --git a/packages/client/store/README.md b/packages/client/store/README.md
index 9d675c1189..0d4525ab6e 100644
--- a/packages/client/store/README.md
+++ b/packages/client/store/README.md
@@ -32,6 +32,7 @@ None; the stores neither assemble nor send model requests.
- **Persistence is browser-local** — persisted stores use JSON in `localStorage`; non-browser runtimes disable persistence, and the package provides no cross-device synchronization.
+- **Web-shell build input** — the static ESM retains third-party imports for Vite; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)).
diff --git a/packages/client/store/README.zh.md b/packages/client/store/README.zh.md
index 3ff170b71c..b66a2e3167 100644
--- a/packages/client/store/README.zh.md
+++ b/packages/client/store/README.zh.md
@@ -32,6 +32,7 @@ kind: "package-library"
- **持久化仅限浏览器本地**——持久化 store 使用 `localStorage` 中的 JSON;非浏览器运行时会禁用持久化,本包也不提供跨设备同步。
+- **Web 壳构建输入**——静态 ESM 为 Vite 保留第三方导入;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。
diff --git a/packages/client/store/package.json b/packages/client/store/package.json
index 172439a947..6bb0c0a212 100644
--- a/packages/client/store/package.json
+++ b/packages/client/store/package.json
@@ -22,15 +22,13 @@
"./package.json": "./package.json"
},
"license": "MIT",
- "dependencies": {
- "immer": "^10.1.1",
- "zustand": "~4.4.7"
- },
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "zustand": "~4.4.7",
+ "immer": "^10.1.1"
},
"files": [
"lib/index.js",
diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts
index a4dbdd949b..f14ef62142 100644
--- a/packages/client/ui-agent-preset/src/client/locales.ts
+++ b/packages/client/ui-agent-preset/src/client/locales.ts
@@ -39,7 +39,7 @@ export const en: Record = {
'Full coding agent without the workflow tool; other tools are exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program.',
presetMinimalName: 'Minimal mode',
presetMinimalDescription:
- 'Two-tool coding agent with persistent bash and str_replace_editor.',
+ 'Single-tool coding agent with a persistent shell.',
presetCordisName: 'Creator mode',
presetCordisDescription:
'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance.',
@@ -98,7 +98,7 @@ export const zh: Record = {
presetPtcName: 'PTC 模式',
presetPtcDescription: '功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。',
presetMinimalName: '极简模式',
- presetMinimalDescription: '仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。',
+ presetMinimalDescription: '仅提供持久 shell 的单工具编码 Agent。',
presetCordisName: '创造模式',
presetCordisDescription: '用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。',
duplicate: '复制',
diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json
index 7679b48621..9adf41af13 100644
--- a/packages/client/ui-attachment/package.json
+++ b/packages/client/ui-attachment/package.json
@@ -42,9 +42,6 @@
"watch": "tsdown --watch"
},
"license": "MIT",
- "dependencies": {
- "clsx": "^2.0.0"
- },
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@types/react": "~18.3.1",
@@ -58,7 +55,8 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@deepseek-ai/dsh-attachment": "workspace:^",
- "@deepseek-ai/dsh-client-ui-tool": "workspace:^"
+ "@deepseek-ai/dsh-client-ui-tool": "workspace:^",
+ "clsx": "^2.0.0"
},
"files": [
"lib/index.js",
diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx
index 7da3a878e9..3e86094c74 100644
--- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx
+++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx
@@ -12,6 +12,7 @@ import { MessageImages } from '../src/client/MessageImages.tsx'
// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
afterEach(cleanup)
@@ -272,7 +273,7 @@ describe('ImageGallery', () => {
sessionId: 'message-images-test' as MessageImagesProps['sessionId'],
useSession,
useSessions,
- useResource,
+ usePanelInfo, useResource,
useSessionPendingInteraction,
useWorkspaces,
useProjection: () => undefined,
diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml
index 4985ac3796..8ea1e5d5d6 100644
--- a/packages/client/ui-chat/README.i18n.yaml
+++ b/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
diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md
index b5a837f927..7c9b38a1ab 100644
--- a/packages/client/ui-chat/README.md
+++ b/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)
diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md
index f6b8018434..80198a89fe 100644
--- a/packages/client/ui-chat/README.zh.md
+++ b/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)
diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts
index 2d8de50561..0fc9e82a22 100644
--- a/packages/client/ui-chat/src/client/apply.ts
+++ b/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
diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts
index 3fb8bcfcee..b5c3a45b99 100644
--- a/packages/client/ui-chat/src/client/contract/slots.ts
+++ b/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' {
diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx
index 590cc5602c..71a7b5afe9 100644
--- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx
+++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx
@@ -16,6 +16,7 @@ import {
apply as applyChat, inject as injectChat, type ChatViewInjected,
} from '@deepseek-ai/dsh-client-ui-chat/client'
import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import { createChatStore } from '../src/client/stores.ts'
usePinnedBrowserLanguages('zh-CN')
@@ -57,7 +58,11 @@ async function bench() {
)
new TestRemote(runtime.ctx, { session: { openWorkspacePath } })
runtime.ctx.provide('uiWorkspace', {
- connectWorkspace: vi.fn(async () => ROOT),
+ openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
+ beforeOpen(ROOT)
+ runtime.sessions.open(ROOT)
+ }),
+ openSession: (id: SessionId) => { runtime.sessions.open(id) },
} as never)
const session = sessionFakeFor()
await runtime.sessions.add({
@@ -69,7 +74,7 @@ async function bench() {
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.root.declare({
- 'conversation': { kind: 'single', scope: 'session-maybe' },
+ 'main': { kind: 'keyed', scope: 'root' },
}, (_props: { renderSlot?: unknown }) => null)
await runtime.mount({ inject: [...injectConversation], apply: applyConversation })
await runtime.mount({ inject: [...injectChat], apply: applyChat })
@@ -177,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 }
diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx
index fc1cf43bc5..ac364e4cb7 100644
--- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx
@@ -9,6 +9,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import {
apply as applyConversation, inject as injectConversation,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -44,7 +45,11 @@ async function bench() {
runtime.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never)
runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never)
runtime.ctx.provide('uiWorkspace', {
- connectWorkspace: vi.fn(async () => SID),
+ openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
+ beforeOpen(SID)
+ runtime.sessions.open(SID)
+ }),
+ openSession: (id: SessionId) => { runtime.sessions.open(id) },
} as never)
new TestRemote(runtime.ctx, {
session: { openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })) },
@@ -53,7 +58,7 @@ async function bench() {
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.root.declare({
- 'conversation': { kind: 'single', scope: 'session-maybe' },
+ 'main': { kind: 'keyed', scope: 'root' },
'conversation.approval.detail': { kind: 'single', scope: 'session' },
'settings.general.item': { kind: 'list', scope: 'root' },
}, (_props: { renderSlot?: unknown }) => null)
@@ -120,7 +125,8 @@ describe('Chat apply wiring', () => {
await b.chat.dispose()
expect(b.runtime.slots.entries('conversation.view')).toHaveLength(0)
expect(b.runtime.slots.spec('conversation.chat.node')).toBeUndefined()
- expect(b.runtime.slots.entries('conversation')).toHaveLength(1)
+ expect(b.runtime.slots.entries('main').map(row => row.options.key)).toEqual(['conversation'])
+ expect(b.runtime.slots.entries('main.conversation')).toHaveLength(1)
expect(b.runtime.ctx.get('uiConversation')).toBeDefined()
await b.runtime.dispose()
})
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index 7b72d280dc..fc8fd9c15e 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -364,6 +364,7 @@ function makeHarness(
// ChatView never invokes it (pass-through stub).
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children}>
const props: ChatViewSlotProps = {
+ usePanelInfo: selector => selector({ activePanelId: null }),
sessionId: SID,
useSession: bindSnapshotSelector(session.source),
useChat: bindSnapshotSelector(chatSource.source),
diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx
index 5aa8e31047..7004e75a37 100644
--- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx
+++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx
@@ -35,6 +35,7 @@ function mount(mode: 'normal' | 'compact' = 'compact') {
const source = createSnapshotStore(mode)
const setTranscriptView = vi.fn((next: 'normal' | 'compact') => { source.set(next) })
const props: TranscriptViewRowProps = {
+ usePanelInfo: selector => selector({ activePanelId: null }),
useSessions: emptySessions(),
useSessionPendingInteraction: noPendingInteraction(),
useWorkspaces: emptyWorkspaces(),
diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json
index bc7b069162..40ce5a3bff 100644
--- a/packages/client/ui-commands/package.json
+++ b/packages/client/ui-commands/package.json
@@ -41,9 +41,6 @@
"watch": "tsdown --watch"
},
"license": "MIT",
- "dependencies": {
- "clsx": "^2.0.0"
- },
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^"
},
@@ -64,7 +61,8 @@
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
- "@deepseek-ai/dsh-client-ui-session": "workspace:^"
+ "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+ "clsx": "^2.0.0"
},
"files": [
"lib/index.js",
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index b28c9069b7..a1eea2e97a 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/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-conversation/README.md
-README.md: edfcb83193d4513fba846f673d3572c35e8c0d1e
-README.zh.md: f92e4c77ba3d3a2ceb96758e5817e447ba2ec1ac
+README.md: 19a210bb200dae359920e85ff944b68242f45b4b
+README.zh.md: c5a56871eb99607c924b2b2e048de72d918e6a8b
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index edfcb83193..19a210bb20 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -36,7 +36,9 @@ Target packages declaration-merge their snapshot and Location data maps, then re
## Shell and standard props
-The package registers the optional-Session `conversation` shell, strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop.
+Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact.
+
+The package occupies the root-scoped `main` key `conversation`, whose wrapper declares the optional-Session `main.conversation` shell. It registers strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop.
View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell.
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index f92e4c77ba..c5a56871eb 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -36,7 +36,9 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
## Shell 与标准 props
-本包注册 optional-Session `conversation` shell、strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
+工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在请求仍有效时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。
+
+本包占据 root 作用域 `main` 中的 `conversation` key,其包装层声明 optional-Session `main.conversation` shell。本包注册strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific snapshot。
diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json
index 8902063d3c..987aa9574f 100644
--- a/packages/client/ui-conversation/package.json
+++ b/packages/client/ui-conversation/package.json
@@ -46,13 +46,7 @@
},
"license": "MIT",
"dependencies": {
- "clsx": "^2.0.0",
- "@deepseek-ai/schemastery": "workspace:^",
- "@lexical/history": "^0.49.0",
- "@lexical/plain-text": "^0.49.0",
- "@lexical/text": "^0.49.0",
- "@lexical/utils": "^0.49.0",
- "lexical": "^0.49.0"
+ "@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^"
@@ -93,7 +87,13 @@
"react-dom": "^18.2.0",
"@types/react-dom": "~18.3.0",
"zod": "^4.4.3",
- "@deepseek-ai/dsh-settings": "workspace:^"
+ "@deepseek-ai/dsh-settings": "workspace:^",
+ "lexical": "^0.49.0",
+ "clsx": "^2.0.0",
+ "@lexical/utils": "^0.49.0",
+ "@lexical/text": "^0.49.0",
+ "@lexical/plain-text": "^0.49.0",
+ "@lexical/history": "^0.49.0"
},
"files": [
"lib/index.js",
diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
index 65b8374919..7eba82cfc0 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/packages/client/ui-conversation/src/client/apply.ts
@@ -28,6 +28,7 @@ import { queueDockEntry } from './queue/QueueDock.tsx'
import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx'
import type { EnterBehaviorRowInjected } from './settings/EnterBehaviorRow.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
+import { ConversationPanel } from './skeleton/ConversationPanel.tsx'
import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
import { InputBar } from './skeleton/InputBar.tsx'
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
@@ -84,9 +85,11 @@ const ABSENT_FILE_UPLOADS = {
}
interface WorkspaceNavigation {
- connectWorkspace(
+ openSession(sessionId: SessionId): void
+ openWorkspace(
workspaceId: Parameters[0],
- ): Promise
+ beforeOpen: (sessionId: SessionId) => void,
+ ): Promise
}
/** Resolve the session-scoped Conversation action face, failing loud. */
@@ -214,7 +217,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
})
const registerConversationRoot = () => slots.register({
- name: 'conversation',
+ name: 'main.conversation',
locale: NS,
children: {
'conversation.session': { kind: 'single', scope: 'session' },
@@ -230,8 +233,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
hooks: {
composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId),
},
- selectWorkspace: async (workspaceId) => {
- const nextId = await workspaceNavigation.connectWorkspace(workspaceId)
+ selectWorkspace: workspaceId => workspaceNavigation.openWorkspace(workspaceId, (nextId) => {
if (sessionId !== undefined && nextId !== sessionId) {
const from = inputHub.shell(sessionId)
const draft = from.snapshot.draft
@@ -251,8 +253,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
}
}
}
- sessions.open(nextId)
- },
+ }),
}),
}, ConversationRoot)
@@ -284,7 +285,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
store: conversationStore,
inject: (sessionId: SessionId, actions: BoundActions): ConversationSessionHeaderInjected => ({
hooks: { conversationViews },
- open: (id) => { sessions.open(id) },
+ open: (id) => { workspaceNavigation.openSession(id) },
selectView: (view) => {
activateView(sessionId, view)
actions.setView(view)
@@ -384,7 +385,12 @@ export function apply(ctx: Context, config: Config = Config({})): void {
},
}, InputBar)
- slots.inject('conversation', function* () {
+ slots.inject('main', function* () {
+ yield slots.register({
+ name: 'main',
+ key: 'conversation',
+ children: { 'main.conversation': { kind: 'single', scope: 'session-maybe' } },
+ }, ConversationPanel)
yield registerConversationRoot()
yield registerConversationSession()
yield registerConversationHeader()
diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts
index eb38a0f261..cf76e02b1b 100644
--- a/packages/client/ui-conversation/src/client/contract/slots.ts
+++ b/packages/client/ui-conversation/src/client/contract/slots.ts
@@ -117,6 +117,8 @@ export type UseConversationViews = SnapshotSelectorHook
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
+ /** Conversation shell beneath its root-scoped main-panel entry. */
+ 'main.conversation': { kind: 'single'; scope: 'session-maybe' }
/** Strict per-Session Conversation body. */
'conversation.session': { kind: 'single'; scope: 'session' }
/** Strict per-Session title, actions, and View navigation. */
@@ -364,7 +366,7 @@ export interface HeroBrandMarkOwnerProps {
/** Full props of the resident optional-Session Conversation shell. */
export type ConversationSlotProps =
- PropsRuntime<'conversation'>
+ PropsRuntime<'main.conversation'>
& PropsRenderSlots<
| 'conversation.session' | 'conversation.session.header'
| 'conversation.composer' | 'conversation.composer.bar'
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationPanel.tsx
new file mode 100644
index 0000000000..8bd11aac33
--- /dev/null
+++ b/packages/client/ui-conversation/src/client/skeleton/ConversationPanel.tsx
@@ -0,0 +1,12 @@
+/** Root-scoped main occupant; Session binding belongs to its Conversation child. */
+import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type {} from '../contract/slots.ts'
+
+/**
+ * Render the Conversation with optional current-Session binding.
+ * @param props - main-slot inputs and the declared Conversation renderer.
+ * @returns the Conversation subtree.
+ */
+export function ConversationPanel({ renderSlot }: PropsRuntime<'main'> & PropsRenderSlots<'main.conversation'>) {
+ return renderSlot('main.conversation', {})
+}
diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
index 2dde9ce7b3..c6d6226bfe 100644
--- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
@@ -49,7 +49,14 @@ async function bench() {
}
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const connectWorkspace = vi.fn(async () => ROOT)
- runtime.ctx.provide('uiWorkspace', { connectWorkspace } as never)
+ runtime.ctx.provide('uiWorkspace', {
+ openWorkspace: async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
+ const id = await connectWorkspace()
+ beforeOpen(id)
+ runtime.sessions.open(id)
+ },
+ openSession: (id: SessionId) => { runtime.sessions.open(id) },
+ } as never)
const sessionFake = sessionFakeFor()
await runtime.sessions.add({
id: ROOT,
@@ -60,12 +67,12 @@ async function bench() {
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.root.declare({
- 'conversation': { kind: 'single', scope: 'session-maybe' },
+ 'main': { kind: 'keyed', scope: 'root' },
}, (_props: { renderSlot?: unknown }) => null)
const feature = await runtime.mount({ inject: [...inject], apply })
runtime.renderRoot()
- const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar') =>
+ const entryOf = (key: 'main.conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar') =>
runtime.slots.entries(key)[0]!
const conversationApi = (id: SessionId) => {
const entry = entryOf('conversation.session')
@@ -77,7 +84,7 @@ async function bench() {
return { instance, injected }
}
const residentApi = (id: SessionId | undefined) => {
- const entry = entryOf('conversation')
+ const entry = entryOf('main.conversation')
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id)
}
const headerApi = (id: SessionId) => {
diff --git a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx
index eccf4b87a0..3c59deb1c2 100644
--- a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx
@@ -14,14 +14,20 @@ const SID = 'session-1' as SessionId
async function bench(options: { declareConversation?: boolean } = {}) {
const runtime = await SlotTestRuntime.create()
- runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never)
+ runtime.ctx.provide('uiWorkspace', {
+ openWorkspace: vi.fn(async (_workspaceId: unknown, beforeOpen: (id: SessionId) => void) => {
+ beforeOpen(SID)
+ runtime.sessions.open(SID)
+ }),
+ openSession: (id: SessionId) => { runtime.sessions.open(id) },
+ } as never)
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
if (options.declareConversation !== false) {
await runtime.root.declare({
- 'conversation': { kind: 'single', scope: 'session-maybe' },
+ 'main': { kind: 'keyed', scope: 'root' },
'settings.general.item': { kind: 'list', scope: 'root' },
}, (_props: { renderSlot?: unknown }) => null)
}
@@ -31,7 +37,7 @@ async function bench(options: { declareConversation?: boolean } = {}) {
function entry(
runtime: SlotTestRuntime,
- key: 'conversation' | 'conversation.session' | 'conversation.session.header',
+ key: 'main.conversation' | 'conversation.session' | 'conversation.session.header',
) {
return runtime.slots.entries(key)[0] as { store?: unknown } | undefined
}
@@ -39,14 +45,18 @@ function entry(
describe('target-neutral Conversation apply wiring', () => {
it('waits for the layout-owned conversation declaration before registering its subtree', async () => {
const b = await bench({ declareConversation: false })
- expect(b.runtime.slots.entries('conversation')).toHaveLength(0)
+ expect(b.runtime.slots.entries('main')).toHaveLength(0)
+ expect(b.runtime.slots.entries('main.conversation')).toHaveLength(0)
await b.runtime.root.declare({
- 'conversation': { kind: 'single', scope: 'session-maybe' },
+ 'main': { kind: 'keyed', scope: 'root' },
'settings.general.item': { kind: 'list', scope: 'root' },
}, (_props: { renderSlot?: unknown }) => null)
- expect(b.runtime.slots.entries('conversation')).toHaveLength(1)
+ expect(b.runtime.slots.entries('main').map(row => row.options.key)).toEqual(['conversation'])
+ expect(b.runtime.slots.entries('main.conversation')).toHaveLength(1)
+ expect(b.runtime.slots.spec('main.conversation'))
+ .toEqual({ kind: 'single', scope: 'session-maybe' })
expect(b.runtime.slots.entries('conversation.session')).toHaveLength(1)
expect(b.runtime.slots.entries('conversation.session.header')).toHaveLength(1)
expect(b.runtime.slots.entries('conversation.composer.bar')).toHaveLength(1)
@@ -65,7 +75,7 @@ describe('target-neutral Conversation apply wiring', () => {
const b = await bench()
const session = entry(b.runtime, 'conversation.session')
const header = entry(b.runtime, 'conversation.session.header')
- expect(entry(b.runtime, 'conversation')?.store).toBeUndefined()
+ expect(entry(b.runtime, 'main.conversation')?.store).toBeUndefined()
expect(session?.store).toBeDefined()
expect(header?.store).toBe(session?.store)
expect(b.runtime.slots.spec('conversation.composer'))
@@ -110,7 +120,9 @@ describe('target-neutral Conversation apply wiring', () => {
await b.feature.dispose()
expect(b.runtime.ctx.get('conversation')).toBeUndefined()
expect(b.runtime.ctx.get('uiConversation')).toBeUndefined()
- expect(b.runtime.slots.entries('conversation')).toHaveLength(0)
+ expect(b.runtime.slots.entries('main')).toHaveLength(0)
+ expect(b.runtime.slots.entries('main.conversation')).toHaveLength(0)
+ expect(b.runtime.slots.spec('main.conversation')).toBeUndefined()
expect(b.runtime.slots.spec('conversation.view')).toBeUndefined()
await b.runtime.dispose()
})
diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx
index fd99320d93..573a732780 100644
--- a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx
@@ -12,6 +12,7 @@ import {
import { InputHub } from '../src/client/input/hub.ts'
import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
// jsdom implements no Range geometry (Lexical's scroll-into-view measures the
// caret with one once the surface is genuinely contenteditable).
@@ -40,13 +41,13 @@ beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
})
-type AppRootProps = PropsRenderSlots<'conversation'>
+type AppRootProps = PropsRenderSlots<'main'>
function AppRoot({ renderSlot }: AppRootProps) {
- return <>{renderSlot('conversation', {})}>
+ return <>{renderSlot('main', {}, { entryKey: 'conversation' })}>
}
const LAYOUT_CHILDREN = {
- 'conversation': { kind: 'single', scope: 'session-maybe' },
+ 'main': { kind: 'keyed', scope: 'root' },
} as const
function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
@@ -60,7 +61,13 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
async function bench(opts?: { blank?: boolean }) {
const runtime = await SlotTestRuntime.create()
- runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never)
+ runtime.ctx.provide('uiWorkspace', {
+ openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
+ beforeOpen(SID)
+ runtime.sessions.open(SID)
+ }),
+ openSession: (id: SessionId) => { runtime.sessions.open(id) },
+ } as never)
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
@@ -82,7 +89,13 @@ async function bench(opts?: { blank?: boolean }) {
describe('resident composer', () => {
it('renders the locked view state while no session exists at all', async () => {
const runtime = await SlotTestRuntime.create()
- runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never)
+ runtime.ctx.provide('uiWorkspace', {
+ openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
+ beforeOpen(SID)
+ runtime.sessions.open(SID)
+ }),
+ openSession: (id: SessionId) => { runtime.sessions.open(id) },
+ } as never)
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
@@ -109,7 +122,13 @@ describe('resident composer', () => {
it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
const runtime = await SlotTestRuntime.create()
- runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never)
+ runtime.ctx.provide('uiWorkspace', {
+ openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
+ beforeOpen(SID)
+ runtime.sessions.open(SID)
+ }),
+ openSession: (id: SessionId) => { runtime.sessions.open(id) },
+ } as never)
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
@@ -174,7 +193,13 @@ describe('resident composer', () => {
describe('prompt rejection through the assembled composer', () => {
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
const runtime = await SlotTestRuntime.create()
- runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never)
+ runtime.ctx.provide('uiWorkspace', {
+ openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
+ beforeOpen(SID)
+ runtime.sessions.open(SID)
+ }),
+ openSession: (id: SessionId) => { runtime.sessions.open(id) },
+ } as never)
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx
index 0cba7c36b5..4af4d1dbd6 100644
--- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx
@@ -40,6 +40,7 @@ function mount() {
const policy = new ComposerSubmissionPolicy()
const setBusyEnter = vi.fn((behavior: 'queue' | 'steer') => { policy.setBusyEnter(behavior) })
const props: EnterBehaviorRowProps = {
+ usePanelInfo: selector => selector({ activePanelId: null }),
useSessions: emptySessions(),
useSessionPendingInteraction: noPendingInteraction(),
useResource,
diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
index 051aa295e3..75c30b117d 100644
--- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
@@ -164,6 +164,7 @@ function bench(over?: BenchOptions) {
return null
}) as never
const props: InputBarProps = {
+ usePanelInfo: selector => selector({ activePanelId: null }),
sessionId: SID,
SessionProvider: ({ children }) => children,
useSession: bindSnapshotSelector(session),
diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
index c333d25ada..57319d7087 100644
--- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
@@ -48,6 +48,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
removed: over?.disabled ?? false,
})
const props: InputBarProps = {
+ usePanelInfo: selector => selector({ activePanelId: null }),
sessionId: SID,
SessionProvider: ({ children }) => children,
useSession: bindSnapshotSelector(session),
diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
index 9e41f82eb7..88ab3a809f 100644
--- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
@@ -137,6 +137,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
const wiring = shell
const sessionStore = createSnapshotStore(sessionSnapshot(sessionId))
const barProps: InputBarProps = {
+ usePanelInfo: selector => selector({ activePanelId: null }),
sessionId,
SessionProvider: ({ children }) => children,
useSession: bindSnapshotSelector(sessionStore),
diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx
index 446ce0defd..08db337005 100644
--- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx
@@ -73,11 +73,13 @@ function liveSession(initial: SessionSnapshot) {
const INPUT_STATE: InputState = { draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
const t: QueueDockProps['t'] = makeTranslate(zh, commonZh)
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
function kitFor(snapshot: SessionSnapshot, injected: Partial = {}) {
return {
sessionId: SID,
t,
+ usePanelInfo,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook,
useResource,
useSessionPendingInteraction: bindSnapshotSelector(
diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx
index f1735ce415..9ffecfdfaa 100644
--- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx
@@ -199,6 +199,7 @@ function mount(
useChat={useChat}
useTrajectory={useTrajectory}
useSessions={props.useSessions}
+ usePanelInfo={props.usePanelInfo}
useResource={useResource}
useSessionPendingInteraction={useSessionPendingInteraction}
useWorkspaces={props.useWorkspaces}
@@ -225,6 +226,7 @@ function mount(
useChat={useChat}
useTrajectory={useTrajectory}
useSessions={props.useSessions}
+ usePanelInfo={props.usePanelInfo}
useResource={useResource}
useSessionPendingInteraction={useSessionPendingInteraction}
useWorkspaces={props.useWorkspaces}
@@ -251,6 +253,7 @@ function mount(
useSession={useSession}
useConversation={useConversation}
useSessions={props.useSessions}
+ usePanelInfo={props.usePanelInfo}
useSessionPendingInteraction={useSessionPendingInteraction}
useWorkspaces={props.useWorkspaces}
useProjection={(() => undefined)}
@@ -295,6 +298,7 @@ function mount(
: (opts?.fallback ?? null)
)) as ConversationRootProps['renderSlotChain']
const props: ConversationRootProps = {
+ usePanelInfo: selector => selector({ activePanelId: null }),
sessionId: SID,
SessionProvider: ({ children }) => children,
useSession,
diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml
index 01417ca259..9cc543ee66 100644
--- a/packages/client/ui-deliverables/README.i18n.yaml
+++ b/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
diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md
index bfd4c40a95..ae42abb5a6 100644
--- a/packages/client/ui-deliverables/README.md
+++ b/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.
+
+### 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
Implementation internals — click to expand
-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.
@@ -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.
@@ -99,4 +109,4 @@ None.
-**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.
diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md
index 23c983f938..664dfa3cd2 100644
--- a/packages/client/ui-deliverables/README.zh.md
+++ b/packages/client/ui-deliverables/README.zh.md
@@ -27,6 +27,13 @@ kind: "package-reference"
与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经属主的 `openFile` 打开文件——chat 视图把它路由到右侧 Sidebar 作为一个文本预览 tab——相对路径按会话 cwd 解析。该行不提供文件夹动作:Sidebar 没有目录形态,因此省略文件的余数只是一个标签才会打开会话工作区。
+
+### 显式交付
+
+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"
实现细节——点击展开
-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 的工作区解析路径,检查当前文件存在且位于工作区内,再启动默认应用。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
@@ -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 的文本预览中打开文件,该预览仅支持文件,不提供原生文件夹打开动作。
### 开发备注
@@ -99,4 +109,4 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
-**运行时不变式:** 不发布伴生入口。prompt section、slot、dictionary、event definition 与可选 service 注册都归 effect 所有,释放由插件测试证明;本包不持有可变状态。
+**运行时不变式:** 不发布伴生入口。提示词、slot、dictionary、文件操作路由与可选 service 注册归 effect 所有;Session 日志拥有声明,工作区拥有文件内容。
diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json
index 187b548e24..67970786a0 100644
--- a/packages/client/ui-deliverables/package.json
+++ b/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",
diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css
new file mode 100644
index 0000000000..97befd2946
--- /dev/null
+++ b/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; }
diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx
new file mode 100644
index 0000000000..2faf259ad4
--- /dev/null
+++ b/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> }
+ 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 & {
+ matched: DeliverablesMatch
+} & PropsLocale & Pick & InjectFace) {
+ const states = usePresentedOpen(value => value)
+ return <>
+ {matched.produced.length > 0 && }
+ {matched.presented.length > 0 &&
+}
diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts
index 01877b7b57..09156448b4 100644
--- a/packages/client/ui-deliverables/src/client/index.ts
+++ b/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)
diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts
index 158b659f59..d9b468e15c 100644
--- a/packages/client/ui-deliverables/src/client/locales.ts
+++ b/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 = {
+ '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',
diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts
new file mode 100644
index 0000000000..5b49941a57
--- /dev/null
+++ b/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>({})
+ private readonly lifetime = new AbortController()
+ private readonly pending = new Set>()
+
+ /**
+ * 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 {
+ 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 {
+ this.lifetime.abort()
+ await Promise.all(this.pending)
+ }
+
+ private async request(url: string): Promise {
+ 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 })
+ }
+}
diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts
index 90e3248516..3a8025d68d 100644
--- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts
+++ b/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 {
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 {
+ 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()
+ 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
diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts
index 00e1adac9c..4239299e63 100644
--- a/packages/client/ui-deliverables/src/index.ts
+++ b/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'),
diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts
new file mode 100644
index 0000000000..beac77657a
--- /dev/null
+++ b/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>()
+ 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 {
+ 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 })
+ }
+}
diff --git a/packages/client/ui-deliverables/src/presented.ts b/packages/client/ui-deliverables/src/presented.ts
new file mode 100644
index 0000000000..fd5939671d
--- /dev/null
+++ b/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
+ 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
+ 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)
+}
diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts
new file mode 100644
index 0000000000..b201895445
--- /dev/null
+++ b/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()
+ 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()
+ const release = Promise.withResolvers()
+ 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()
+})
diff --git a/packages/client/ui-deliverables/tests/present-open.host.spec.ts b/packages/client/ui-deliverables/tests/present-open.host.spec.ts
new file mode 100644
index 0000000000..b9319587b6
--- /dev/null
+++ b/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> = []
+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()
+ const aborted = Promise.withResolvers()
+ const release = Promise.withResolvers()
+ 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])
+ })
+})
diff --git a/packages/client/ui-deliverables/tests/present-row.client.spec.tsx b/packages/client/ui-deliverables/tests/present-row.client.spec.tsx
new file mode 100644
index 0000000000..05d6b99540
--- /dev/null
+++ b/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[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()
+ 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()
+ 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()
+ expect(view.getByText('Delivering')).toBeTruthy()
+ expect(view.queryByRole('button')).toBeNull()
+})
+
+it('shows orphaned error details and non-text results', () => {
+ const view = render()
+ fireEvent.click(view.getByRole('button'))
+ expect(view.getByText('Missing: missing')).toBeTruthy()
+ view.rerender()
+ expect(view.container.textContent).toContain('Recorded detail')
+ view.rerender()
+ expect(view.queryByRole('button')).toBeNull()
+})
diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx
index 9c006c93e7..9b2d38caaa 100644
--- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx
+++ b/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) => controller.open(...args)),
+ usePresentedOpen: (select: (state: ReturnType) => 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()
+ 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()
+ expect(view.getByText('Produced')).toBeTruthy()
+ expect(view.queryByText('Deliverables')).toBeNull()
+})
+
+it('shows file metadata and descriptions without hiding extensionless deliveries', () => {
+ const view = render( {}} 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( {}} 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')
+})
diff --git a/packages/client/ui-deliverables/tests/prompt.client.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts
similarity index 88%
rename from packages/client/ui-deliverables/tests/prompt.client.spec.ts
rename to packages/client/ui-deliverables/tests/prompt.host.spec.ts
index 9c2ee24471..8f342099fd 100644
--- a/packages/client/ui-deliverables/tests/prompt.client.spec.ts
+++ b/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()
diff --git a/packages/client/ui-deliverables/tsconfig.client.json b/packages/client/ui-deliverables/tsconfig.client.json
new file mode 100644
index 0000000000..3b8f95c87c
--- /dev/null
+++ b/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"
+ }
+ ]
+}
diff --git a/packages/client/ui-deliverables/tsconfig.host.json b/packages/client/ui-deliverables/tsconfig.host.json
new file mode 100644
index 0000000000..0123290583
--- /dev/null
+++ b/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"
+ }
+ ]
+}
diff --git a/packages/client/ui-deliverables/tsconfig.json b/packages/client/ui-deliverables/tsconfig.json
index a99881d93b..2eca820546 100644
--- a/packages/client/ui-deliverables/tsconfig.json
+++ b/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"
}
]
}
diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json
index aaf735a27f..ec0a9d8ed0 100644
--- a/packages/client/ui-directory-picker-browse/package.json
+++ b/packages/client/ui-directory-picker-browse/package.json
@@ -41,9 +41,6 @@
"watch": "tsdown --watch"
},
"license": "MIT",
- "dependencies": {
- "clsx": "^2.0.0"
- },
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^"
},
@@ -59,7 +56,8 @@
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0",
- "react-dom": "^18.2.0"
+ "react-dom": "^18.2.0",
+ "clsx": "^2.0.0"
},
"files": [
"lib/index.js",
diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml
index bd0993ceba..19c343ed54 100644
--- a/packages/client/ui-dockkit/README.i18n.yaml
+++ b/packages/client/ui-dockkit/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-dockkit/README.md
-README.md: e012295852ad3c957695fcfbd0568a45b3f54856
-README.zh.md: 2423887d60460651a224ffe1cb0fc9b61363c0e2
+README.md: 330bff66577464a26eac50d92ee54cc0cdefc8df
+README.zh.md: f5259400055a28ea99f2205f9ba0a28d2cc13bd2
diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md
index e012295852..330bff6657 100644
--- a/packages/client/ui-dockkit/README.md
+++ b/packages/client/ui-dockkit/README.md
@@ -74,6 +74,8 @@ These are not stylistic; each one fixes a defect found in a real browser.
## Build shape
+Its static ESM retains third-party imports for the Web shell's Vite build; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)).
+
The package is statically linked: tsdown's `staticLinked` preset emits one browser ESM bundle at `lib/index.js` (every bare specifier stays an import, sourcemaps chain to the sources) and ships the stylesheet under `lib/` at its `src`-relative path, and the Web shell resolves the package name and bundles that artifact itself, so vite stays the only owner of class hashing. One consequence is load-bearing — the kit keeps **one** stylesheet, `dockkit.module.css`, because a consumer de-duplicates injected sheets by file name and a collision would drop one silently.
diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md
index 2423887d60..f525940005 100644
--- a/packages/client/ui-dockkit/README.zh.md
+++ b/packages/client/ui-dockkit/README.zh.md
@@ -74,6 +74,8 @@ tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOpt
## 构建形态
+静态 ESM 为 Web 壳的 Vite 构建保留第三方导入;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。
+
本包静态链接:tsdown 的 `staticLinked` 预设在 `lib/index.js` 产出一个浏览器 ESM bundle(所有裸说明符保持为 import,sourcemap 链回源码),并把样式表按其相对 `src` 的路径放到 `lib/` 下;Web 外壳按包名解析并自行打包该产物,因此 vite 仍是 class 哈希的唯一拥有者。有一个后果是承重的——套件只保留**一张**样式表 `dockkit.module.css`,因为消费方按文件名去重注入的样式表,撞名会静默丢掉一张。
diff --git a/packages/client/ui-dockkit/package.json b/packages/client/ui-dockkit/package.json
index 8e7a97e4dd..968d1ba505 100644
--- a/packages/client/ui-dockkit/package.json
+++ b/packages/client/ui-dockkit/package.json
@@ -22,18 +22,15 @@
"./package.json": "./package.json"
},
"license": "MIT",
- "dependencies": {
- "clsx": "^2.0.0",
- "react": "^18.2.0",
- "react-dom": "^18.2.0"
- },
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
- "react-dom": "^18.2.0"
+ "react-dom": "^18.2.0",
+ "react": "^18.2.0",
+ "clsx": "^2.0.0"
},
"files": [
"lib/index.js",
diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json
index a5d7b679d9..65e30abb16 100644
--- a/packages/client/ui-input-trigger/package.json
+++ b/packages/client/ui-input-trigger/package.json
@@ -41,9 +41,6 @@
"watch": "tsdown --watch"
},
"license": "MIT",
- "dependencies": {
- "clsx": "^2.0.0"
- },
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^"
},
@@ -61,7 +58,8 @@
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
- "@deepseek-ai/dsh-client-ui-session": "workspace:^"
+ "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+ "clsx": "^2.0.0"
},
"files": [
"lib/index.js",
diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml
index eb1cbebbee..8d680bc72f 100644
--- a/packages/client/ui-layout/README.i18n.yaml
+++ b/packages/client/ui-layout/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-layout/README.md
-README.md: 0e353e21f39ba8a84e661f1c540db2c2424bcbfa
-README.zh.md: 16471fdabb46bbfb35158ef85aa2544db4ceae93
+README.md: 4fe19fb9ec0a549dc76ca311d0564cbe86c9f5f9
+README.zh.md: 7a61e7808ffb3d81214388a2792a901ced8d9564
diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md
index 0e353e21f3..4fe19fb9ec 100644
--- a/packages/client/ui-layout/README.md
+++ b/packages/client/ui-layout/README.md
@@ -25,7 +25,9 @@ This package provides the Web GUI's three-column AppFrame, edge-column widths, a
## Use this package
-The root slot composes the sidebar, conversation, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed; below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen.
+The root slot composes the sidebar, main content, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed; below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen.
+
+Global panels occupy the root-scoped `main` keyed slot; `conversation` is the reserved key for the Conversation. `ctx.layout.selectPanel(id)` selects a registered panel, and `null` selects the Conversation without changing the current Session. No global panel is registered by the shipped composition.
### Theme presentation
@@ -39,7 +41,9 @@ The presenter consumes resolved theme snapshots and projects them onto the docum
Implementation internals — click to expand
-One registration declares four child slots and binds `ctx.layout` methods `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. The store owns the single frame-width measurement, width preferences, and the occupant's presentation report. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame always mounts the conversation and right columns; a connected Session renders through `SessionProvider`, and without one the right column is an empty zero-width track. It projects the selected Session title over the build-configured product title or the localized `common.brand.localBuild` fallback, so locale revisions update document metadata with the root entry. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions.
+`selectPanel(id)` checks the live `main` registry before changing selection; an absent key throws and leaves the current panel intact. `beginNavigation()` returns an abort signal for an asynchronous UI navigation. A later call, a valid panel selection (including repeated selection), or layout disposal aborts that signal without cancelling underlying Session creation. Consumers check the signal before committing navigation or moving drafts.
+
+One registration declares four child slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `usePanelInfo` subscribes to the stable selection object; AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions.
@@ -51,7 +55,7 @@ One registration declares four child slots and binds `ctx.layout` methods `toggl
Read these pages when the layout surface is not enough. They move from the frame to the columns it renders and the theme it presents.
- [ui-sidebar](../ui-sidebar/README.md) — occupies the `sidebar` column and its seats.
-- [ui-conversation](../ui-conversation/README.md) — occupies the `conversation` column.
+- [ui-conversation](../ui-conversation/README.md) — occupies the `main` key `conversation`.
- [ui-sidebar-right](../ui-sidebar-right/README.md) — occupies the `rightbar` column with one docking surface per session.
- [ui-theme](../ui-theme/README.md) — the theme seam whose resolved snapshots the presenter consumes.
- [Web client architecture](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) — how browser plugin rows load and register slots.
diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md
index 16471fdabb..7a61e7808f 100644
--- a/packages/client/ui-layout/README.zh.md
+++ b/packages/client/ui-layout/README.zh.md
@@ -25,7 +25,9 @@ kind: "package-reference"
## 使用本包
-本插件在 root slot 组合侧栏、会话与右栏。左栏为264~420px,默认280px,收起后保留56px;窗口低于1024px时自动收起,打开右栏也会收起手动展开的左栏。右栏首次打开使用窗口宽度的45%,之后保留用户像素偏好,上限为70%;中栏不足400px时先把右栏压到300px,仍不足则通知占用方收起,最后才继续压缩中栏。拖拽跟手且无过渡延迟,关闭或全屏时不显示右栏拖拽区。
+本插件在 root slot 组合侧栏、中央内容与右栏。左栏为264~420px,默认280px,收起后保留56px;窗口低于1024px时自动收起,打开右栏也会收起手动展开的左栏。右栏首次打开使用窗口宽度的45%,之后保留用户像素偏好,上限为70%;中栏不足400px时先把右栏压到300px,仍不足则通知占用方收起,最后才继续压缩中栏。拖拽跟手且无过渡延迟,关闭或全屏时不显示右栏拖拽区。
+
+全局面板占据 root 作用域的 `main` keyed slot;`conversation` 是为会话界面保留的 key。`ctx.layout.selectPanel(id)` 选中已注册面板,`null` 则选中会话界面,但不改变当前会话。默认组合不注册任何全局面板。
### 主题呈现
@@ -39,7 +41,9 @@ kind: "package-reference"
实现细节——点击展开
-一次注册声明四个子slot并绑定 `ctx.layout` 的 `toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。store持有唯一的frame宽度测量、左右栏偏好及占用方报告的呈现状态。`rightbar` 的owner参数为实际 `width`、`viewportWidth` 与普通呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 始终挂载会话与右栏;已连接 Session 经 `SessionProvider` 渲染,没有 Session 时右栏是一条空的零宽轨道。它把所选 Session 标题投影到构建配置的产品标题或本地化 `common.brand.localBuild` 回退值之上,因此 locale revision 会随根 entry 一起更新文档元数据。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。 全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列宽。 退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。
+`selectPanel(id)` 在改变选中态前检查实时 `main` 注册表;缺失的 key 会抛错并保留当前面板。`beginNavigation()` 为异步 UI 导航返回 abort signal。后续调用、有效面板选择(包括重复选择)或布局释放会中止该 signal,但不取消底层会话创建。消费者在提交导航或搬移草稿前检查 signal。
+
+一次注册声明四个子slot并绑定 `ctx.layout` 的 `selectPanel`、`toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。同一个 root 存储把 `panelInfo` 选中态与 `layoutInfo` 测量、宽度偏好、呈现报告分开。`usePanelInfo` 订阅引用稳定的选中态对象,AppFrame 订阅引用稳定的布局对象。`rightbar` 的owner参数为实际 `width`、`viewportWidth` 与普通呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 保持各列容器挂载。右栏的 root 控制器仅在选中会话界面时,经 `SessionProvider` 渲染 `rightbar.session`;内容卸载时的报告释放列宽。独立的标题组件仅在会话界面可见时使用所选会话标题,以构建配置的产品标题或本地化 `common.brand.localBuild` 为回退值;语言变化会更新该回退值。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。 全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列宽。 退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。
@@ -51,7 +55,7 @@ kind: "package-reference"
当布局面不够用时阅读以下页面。它们从框架进入它所渲染的栏与它所呈现的主题。
- [ui-sidebar](../ui-sidebar/README.zh.md)——占据 `sidebar` 栏及其座位。
-- [ui-conversation](../ui-conversation/README.zh.md)——占据 `conversation` 栏。
+- [ui-conversation](../ui-conversation/README.zh.md)——占据 `main` 中的 `conversation` key。
- [ui-sidebar-right](../ui-sidebar-right/README.zh.md)——以每会话一个停靠面占据 `rightbar` 栏。
- [ui-theme](../ui-theme/README.zh.md)——呈现器消费其解析快照的主题 seam。
- [Web 客户端架构](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)——浏览器插件行如何加载并注册槽位。
diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json
index 1f10a544ad..a2c025f39d 100644
--- a/packages/client/ui-layout/package.json
+++ b/packages/client/ui-layout/package.json
@@ -45,6 +45,7 @@
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
+ "@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx
index 905341f503..05bb4b9494 100644
--- a/packages/client/ui-layout/src/client/AppFrame.tsx
+++ b/packages/client/ui-layout/src/client/AppFrame.tsx
@@ -3,10 +3,9 @@
* shell renders only 'root'). Owns the grid tracks (sidebar | center |
* rightbar), the drag handles (pointer capture + rAF throttle), the column
* solve (columns.ts), and the child-slot render decisions: the sidebar slot
- * renders HERE with live parameters from that solve, and the session-aware
- * occupants render in fixed column positions; the strict right-column entry
- * gates itself on current-session availability while the session-maybe
- * conversation retains identity.
+ * receives live parameters from that solve. The root-scoped main slot selects
+ * the Conversation or a global panel. Each column occupant owns its Session
+ * binding and reports the geometry it needs.
*
* The right column is a track, not a box: its occupant draws its panel anchored
* to the frame's right edge at the resolved normal width, and the
@@ -15,7 +14,7 @@
* track but hides the outer resize handle. Everything arrives through the framework
* shares — zero cordis or framework imports, zero self-made hooks.
*/
-import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import type {
PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
@@ -28,7 +27,7 @@ import css from './AppFrame.module.css'
/** Full composed props: runtime share + child-slot render share + store share. */
export type AppFrameProps =
& PropsRuntime<'root'>
- & PropsRenderSlots<'sidebar' | 'conversation' | 'rightbar' | 'shell.overlay'>
+ & PropsRenderSlots<'sidebar' | 'main' | 'rightbar' | 'shell.overlay'>
& PropsStore>
& PropsLocale<'common'>
@@ -37,6 +36,12 @@ function CenterColumn(props: { children?: ReactNode }) {
return
{props.children}
}
+/** Subscribe to the main key without subscribing the column frame to each panel id. */
+function MainPanel({ usePanelInfo, renderSlot }: Pick, 'usePanelInfo'> & PropsRenderSlots<'main'>) {
+ const panelId = usePanelInfo(info => info.activePanelId)
+ return renderSlot('main', {}, { entryKey: panelId ?? 'conversation' })
+}
+
/**
* Right column grid item. Zero-width unless the occupant asked for a track; the
* occupant's panel is positioned against the column's right edge, which never
@@ -116,18 +121,14 @@ function DragHandle(props: { side: 'sidebar' | 'rightbar'; left: number; onStart
export function AppFrame({
useStore,
useSessions,
+ usePanelInfo,
actions,
renderSlot,
- SessionProvider,
t,
}: AppFrameProps) {
- const panels = useStore(s => s)
- const documentTitle = useSessions((s) => {
- const current = s.current
- return current === undefined ? undefined : s.byId[current]?.title
- })
+ const layoutInfo = useStore(state => state.layoutInfo)
const frameRef = useRef(null)
- const viewport = panels.viewportWidth
+ const viewport = layoutInfo.viewportWidth
// Track the frame's own box (not the window): rAF-throttled ResizeObserver.
useLayoutEffect(() => {
@@ -157,15 +158,15 @@ export function AppFrame({
}, [actions])
const narrow = viewport < SIDEBAR_AUTO_COLLAPSE
- const sidebarCollapsed = narrow ? !panels.narrowExpanded : panels.sidebar === 0
+ const sidebarCollapsed = narrow ? !layoutInfo.narrowExpanded : layoutInfo.sidebar === 0
const sidebarPreference = sidebarCollapsed
? 0
- : panels.sidebar === 0 ? SIDEBAR_DEFAULT : panels.sidebar
- const rightbarPreference = panels.rightbar ?? viewport * RIGHTBAR_DEFAULT_RATIO
+ : layoutInfo.sidebar === 0 ? SIDEBAR_DEFAULT : layoutInfo.sidebar
+ const rightbarPreference = layoutInfo.rightbar ?? viewport * RIGHTBAR_DEFAULT_RATIO
// Opening on a narrow frame collapses the left sidebar. Eligibility must
// include that space before the occupant's first shown report arrives.
- const normal = computeColumns(viewport, !panels.rightbarShown && narrow ? 0 : sidebarPreference, rightbarPreference)
- const cols = computeColumns(viewport, sidebarPreference, panels.rightbarTrack ? rightbarPreference : 0)
+ const normal = computeColumns(viewport, !layoutInfo.rightbarShown && narrow ? 0 : sidebarPreference, rightbarPreference)
+ const cols = computeColumns(viewport, sidebarPreference, layoutInfo.rightbarTrack ? rightbarPreference : 0)
const colsRef = useRef(cols)
colsRef.current = cols
const rightbarWidth = useRef(normal.rightbar)
@@ -189,6 +190,14 @@ export function AppFrame({
actions.setRightbar(rightbarBase.current - dx)
}, [actions])
const productTitle = process.env.DSH_CLIENT_TITLE ?? t('brand.localBuild')
+ const sidebar = useMemo(() => renderSlot('sidebar', {
+ collapsed: sidebarCollapsed,
+ width: cols.sidebar,
+ }), [renderSlot, sidebarCollapsed, cols.sidebar])
+ const main = useMemo(() => (
+
+ ), [usePanelInfo, renderSlot])
+ const overlays = useMemo(() => renderSlot('shell.overlay', {}), [renderSlot])
return (
- {/* Render-site slot call with live concession output: a closed
- sidebar keeps the mounted slot at the compact-rail width, and the
- component sees its rendered state as owner params decided here
- (collapsed follows the resolved rail, so a derived auto-collapse
- renders the rail UI too). */}
- {renderSlot('sidebar', {
- collapsed: sidebarCollapsed,
- width: cols.sidebar,
- })}
+ {sidebar}
<>
- {/* Both column occupants stay at fixed tree positions from first
- paint — no loading gate: a bare status line reads worse than
- the shell's own pending rendering. The conversation is
- session-maybe; SessionProvider withholds the strict right-column
- entry while no session is current. */}
- {renderSlot('conversation', {})}
+ {main}
- {/* Strict session entry: with no session there is no surface, and the
- column is an empty zero-width track. The occupant receives the
- panel width it should draw at; the track is the frame's business. */}
-
- {renderSlot('rightbar', { width: normal.rightbar, viewportWidth: viewport, canShow: normal.rightbar > 0 })}
-
+ {renderSlot('rightbar', { width: normal.rightbar, viewportWidth: viewport, canShow: normal.rightbar > 0 })}
>
- {renderSlot('shell.overlay', {})}
+ {overlays}
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
{!sidebarCollapsed && }
- {panels.rightbarShown && !panels.rightbarFullscreen && normal.rightbar > 0 && (
+ {layoutInfo.rightbarShown && !layoutInfo.rightbarFullscreen && normal.rightbar > 0 && (
)}
diff --git a/packages/client/ui-layout/src/client/DocumentTitle.tsx b/packages/client/ui-layout/src/client/DocumentTitle.tsx
index 4060a3b187..69cd5f4cfe 100644
--- a/packages/client/ui-layout/src/client/DocumentTitle.tsx
+++ b/packages/client/ui-layout/src/client/DocumentTitle.tsx
@@ -1,9 +1,9 @@
+/** Browser title selection follows the active main panel without subscribing the frame. */
import { useEffect } from 'react'
+import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
/** Props for the browser title projection. */
-export interface DocumentTitleProps {
- /** Durable title of the selected session, or undefined for the product title. */
- title?: string
+export type DocumentTitleProps = Pick, 'useSessions' | 'usePanelInfo'> & {
/** Build-configured or localized product title. */
productTitle: string
}
@@ -14,7 +14,12 @@ export interface DocumentTitleProps {
* @param props - Selected session title projection.
* @returns No rendered content.
*/
-export function DocumentTitle({ title, productTitle }: DocumentTitleProps): null {
+export function DocumentTitle({ useSessions, usePanelInfo, productTitle }: DocumentTitleProps): null {
+ const showSessionTitle = usePanelInfo(info => info.activePanelId === null)
+ const title = useSessions((state) => {
+ const current = state.current
+ return !showSessionTitle || current === undefined ? undefined : state.byId[current]?.title
+ })
useEffect(() => {
document.title = title === undefined ? productTitle : `${title} — ${productTitle}`
return () => { document.title = productTitle }
diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts
index 649215b96d..ffbbd68bb9 100644
--- a/packages/client/ui-layout/src/client/index.ts
+++ b/packages/client/ui-layout/src/client/index.ts
@@ -3,8 +3,8 @@
* the runtime's built-in 'root' slot and, in the same breath, declares the
* four child slots (declaration = exclusive render authority), seats the
* layout store (panel geometry), and wires the panel-action service face.
- * ctx.layout is the cross-plugin panel-action contract; navigation state lives
- * with the runtime sessions service. A second effect seats the theme
+ * ctx.layout selects the main panel and controls column geometry; Session
+ * selection belongs to the Session Controller. A second effect seats the theme
* presenter, which projects ctx.theme snapshots onto document.body.
*/
import type { Context as ClientContext } from '@deepseek-ai/cordis'
@@ -12,7 +12,8 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
-import type { PanelActions } from './service.ts'
+import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
+import type { PanelInfo } from './service.ts'
import { AppFrame } from './AppFrame.tsx'
import { createLayoutStore } from './stores.ts'
import { LayoutController } from './service.ts'
@@ -24,7 +25,10 @@ import { ThemePresenter } from './theme-presenter.ts'
// OwnerShare contracts below are the render-side halves registrants compose
// against; the frame components and the store factory are package-internal.
export { LayoutController } from './service.ts'
-export type { ILayout } from './service.ts'
+export type { ILayout, MainPanelId, PanelInfo } from './service.ts'
+
+/** Selector hook over root-scoped panel selection. */
+export type UsePanelInfo = SnapshotSelectorHook
declare module '@deepseek-ai/cordis' {
interface Context {
@@ -34,6 +38,11 @@ declare module '@deepseek-ai/cordis' {
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
+ interface GlobalStandardProps {
+ /** Subscribe to the selected main panel independently of parent renders. */
+ usePanelInfo: UsePanelInfo
+ }
+
interface SlotMap {
// The 'root' entry itself is the runtime's built-in slot (declared
// there); these four are the frame's children, declared by the same
@@ -51,18 +60,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
*/
'sidebar': { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps }
/**
- * The whole center column, across both the no-session hero and a live
- * conversation. OCCUPIED by ui-conversation's ConversationRoot, which
- * declares the session body, composer, and input seats inside it —
- * registering here replaces the entire conversation surface (and removes
- * every seat it declares) rather than adding to it.
- *
- * Current-session-optional: the occupant owns both states without
- * changing its React identity, so it keeps its own state across a session
- * switch. It receives no owner props; session facts arrive through the
- * framework hooks of the `session-maybe` scope.
+ * Central panel selected by sidebar entry id. The reserved `conversation`
+ * key hosts the Conversation; other keys receive no Session binding.
*/
- 'conversation': { kind: 'single'; scope: 'session-maybe'; owner: ConvOwnerProps }
+ 'main': { kind: 'keyed'; scope: 'root' }
/**
* The right column: a track the centre makes room for, or nothing. OCCUPIED
* by the right Sidebar, which uses the resolved column width in normal
@@ -73,10 +74,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* occupant's own recorded business — it reports the composition of its
* expanded and presentation state through `ctx.layout`, and the frame sizes
* the track and places the resize handle from that. The expand control is
- * not this column's: it is a button in the conversation header. With no
- * current session nothing is mounted here.
+ * not this column's: it is a button in the conversation header. The root
+ * occupant decides when to render its Session-bound content.
*/
- 'rightbar': { kind: 'single'; scope: 'session'; owner: RightbarOwnerProps }
+ 'rightbar': { kind: 'single'; scope: 'root'; owner: RightbarOwnerProps }
/**
* Frame-wide floating layer, above every column and outside their scroll
* containers. Deliberately generic and unowned by any feature: a badge, a
@@ -105,9 +106,6 @@ export interface SidebarOwnerProps {
width: number
}
-/** Conversation owner share: business state and actions belong to the registrant. */
-export interface ConvOwnerProps {}
-
/** Right column owner share: resolved normal geometry and opening eligibility. */
export interface RightbarOwnerProps {
/** Resolved normal panel width in px, not the saved preference; zero if it cannot fit. */
@@ -127,34 +125,44 @@ export const inject = ['slots', 'theme', 'locale']
/**
* Client plugin body: provide ctx.layout, then one register() call — AppFrame
* into 'root' with the four child-slot declarations, the layout store seat,
- * and the inject hook that hands the store's bound actions to the service.
+ * and the shared root instance supplying commands and the panel-info source.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
- const layout = new LayoutController()
ctx.effect(() => {
+ const handle = createLayoutStore()
+ const instance = handle.create()
+ const store: typeof handle = { ...handle, create: () => instance }
+ const layout = new LayoutController(instance.actions, id =>
+ ctx.slots.entries('main').some(entry => entry.options.key === id))
+ const retainMainPanels = (): void => {
+ instance.actions.retainMainPanels(ctx.slots.entries('main').flatMap(entry =>
+ entry.options.key === undefined ? [] : [entry.options.key]))
+ }
+ const panelInfo: HostObservable = {
+ getSnapshot: () => instance.getSnapshot().panelInfo,
+ subscribe: listener => instance.subscribe(listener),
+ }
+ const disposePanelInfo = ctx.slots.provideRoot({ hooks: { panelInfo } })
const disposeService = ctx.reflect.provide('layout', layout)
const disposeRegistration = ctx.slots.register({
name: 'root',
locale: 'common',
children: {
'sidebar': { kind: 'single', scope: 'root' },
- 'conversation': { kind: 'single', scope: 'session-maybe' },
- 'rightbar': { kind: 'single', scope: 'session' },
+ 'main': { kind: 'keyed', scope: 'root' },
+ 'rightbar': { kind: 'single', scope: 'root' },
'shell.overlay': { kind: 'list', scope: 'root' },
},
- // Exclusive store: the factory itself — the framework instantiates per
- // entry and delivers useStore/actions to AppFrame as standard props.
- store: createLayoutStore,
- // The hook's only side effect connects the root store to ctx.layout;
- // conversation business actions belong to their registrants.
- inject: (actions: PanelActions) => {
- layout.attachPanels(actions)
- return {}
- },
+ store,
}, AppFrame)
+ const disposePanels = ctx.slots.subscribe('main', retainMainPanels)
+ retainMainPanels()
return () => {
+ layout.dispose()
+ disposePanels()
disposeRegistration()
+ disposePanelInfo()
// provide()'s disposer settles asynchronously; teardown is synchronous fire-and-forget.
void disposeService()
}
diff --git a/packages/client/ui-layout/src/client/service.ts b/packages/client/ui-layout/src/client/service.ts
index 46369f0346..14a3924d63 100644
--- a/packages/client/ui-layout/src/client/service.ts
+++ b/packages/client/ui-layout/src/client/service.ts
@@ -1,26 +1,42 @@
/**
* LayoutController: the cross-plugin panel-action face behind ctx.layout.
- * Panel geometry itself lives in the root entry's layout store (stores.ts);
+ * Panel geometry and main-panel selection live in the root layout store;
* the current-session selection lives with the runtime sessions service, and
* the per-session active view dissolved into ui-conversation's session store
* (its only consumer). What remains here is the contract other plugins'
- * apply worlds reach for panel transitions (sidebar toggle from ui-sidebar,
+ * apply worlds reach for panel transitions (main-panel selection and sidebar toggle,
* right-panel show/hide from ui-sidebar-right) — writes stay inside the
- * store's declared action set, delivered as the registration's bound actions.
+ * store's declared action set, shared with the root registration.
*/
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
+import type { Branded } from '@deepseek-ai/dsh-brand'
import type { createLayoutStore } from './stores.ts'
+/** Identity shared by a sidebar panel entry and its main-slot occupant. */
+export type MainPanelId = Branded<'MainPanelId'>
+
+/** Root-scoped navigation state exposed to panel-aware components. */
+export interface PanelInfo {
+ /** Selected global panel; null displays the current Conversation. */
+ readonly activePanelId: MainPanelId | null
+}
+
/** The layout store's bound action set (framework-baked, draft params peeled). */
export type PanelActions = BoundActions>
-/**
- * The outward layout face (`ctx.layout`): the panel transitions other
- * plugins may trigger — and exactly what a test fake must supply. The
- * attachPanels wiring hook stays on the concrete class (root-entry assembly
- * only).
- */
+/** Panel navigation and geometry actions exposed through ctx.layout. */
export interface ILayout {
+ /**
+ * Select a global central panel without changing the current Session.
+ * @param panelId - registered main key, or null to show the Conversation.
+ * @throws if the selected main key is not registered; preserves the current selection.
+ */
+ selectPanel(panelId: MainPanelId | null): void
+ /**
+ * Start an asynchronous navigation, superseding any earlier pending navigation.
+ * @returns a signal aborted by the next navigation or layout disposal; check it before committing UI state.
+ */
+ beginNavigation(): AbortSignal
/** Toggle the sidebar panel (closed ⟷ contract default width). */
toggleSidebar(): void
/**
@@ -37,39 +53,50 @@ export interface ILayout {
/** Cross-plugin panel-action face (ctx.layout). */
export class LayoutController implements ILayout {
- #panels: PanelActions | undefined
+ private navigation = new AbortController()
/**
- * Adopt the root entry's bound store actions. Called from the root
- * registration's inject hook (a sanctioned assembly side effect), so the
- * face is live from the entry's first render; on entry re-register the
- * fresh actions overwrite the stale set.
- * @param actions - bound actions of the entry's layout store instance.
+ * @param panels - actions of the instance shared with the root entry.
+ * @param hasMainPanel - checks the live main-slot registry for a panel id.
*/
- attachPanels(actions: PanelActions): void {
- this.#panels = actions
+ constructor(
+ private readonly panels: PanelActions,
+ private readonly hasMainPanel: (id: MainPanelId) => boolean,
+ ) {}
+
+ /** Select a global panel or return to the Conversation. */
+ selectPanel(panelId: MainPanelId | null): void {
+ if (panelId !== null && !this.hasMainPanel(panelId)) {
+ throw new Error(`layout.selectPanel: main panel "${panelId}" is not registered`)
+ }
+ this.navigation.abort()
+ this.panels.selectPanel(panelId)
+ }
+
+ /** @returns the new pending navigation's cancellation signal. */
+ beginNavigation(): AbortSignal {
+ this.navigation.abort()
+ this.navigation = new AbortController()
+ return this.navigation.signal
+ }
+
+ /** Invalidate pending navigations when the layout owner is unloaded. */
+ dispose(): void {
+ this.navigation.abort()
}
/** Toggle the sidebar panel (closed ⟷ contract default width). */
toggleSidebar(): void {
- this.#require().toggleSidebar()
+ this.panels.toggleSidebar()
}
/** Report the right panel's track and fullscreen presentation. */
openRightbar(track: boolean, fullscreen: boolean): void {
- this.#require().openRightbar(track, fullscreen)
+ this.panels.openRightbar(track, fullscreen)
}
/** Report the right panel as hidden: no track, no handle. */
closeRightbar(): void {
- this.#require().closeRightbar()
- }
-
- #require(): PanelActions {
- // Callers are UI gestures, which cannot fire before the root entry
- // rendered (the inject hook runs in its first render) — reaching this
- // unwired is a boot-order bug, not a race to tolerate.
- if (this.#panels === undefined) throw new Error('layout: panel actions not wired (root entry not mounted)')
- return this.#panels
+ this.panels.closeRightbar()
}
}
diff --git a/packages/client/ui-layout/src/client/stores.ts b/packages/client/ui-layout/src/client/stores.ts
index 9191745a66..3a53caf4f1 100644
--- a/packages/client/ui-layout/src/client/stores.ts
+++ b/packages/client/ui-layout/src/client/stores.ts
@@ -3,6 +3,7 @@
* The registration supplies a fresh store and binds its actions to ctx.layout.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
+import type { MainPanelId } from './service.ts'
import {
clampWidth, RIGHTBAR_DEFAULT_RATIO, RIGHTBAR_MAX_RATIO, RIGHTBAR_MIN,
SIDEBAR_AUTO_COLLAPSE, SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
@@ -13,6 +14,14 @@ import {
* the right panel's expanded state belongs to its occupant.
*/
type LayoutState = {
+ panelInfo: {
+ /** Null selects the Conversation; global panels keep the current Session intact. */
+ activePanelId: MainPanelId | null
+ }
+ layoutInfo: LayoutInfo
+}
+
+type LayoutInfo = {
sidebar: number
/** Last positive frame measurement; window width bootstraps the first render. */
viewportWidth: number
@@ -47,6 +56,8 @@ type LayoutState = {
* return type); drift fails assignability at the defineStore call.
*/
type LayoutActions = {
+ selectPanel: (draft: LayoutState, panelId: MainPanelId | null) => void
+ retainMainPanels: (draft: LayoutState, panelIds: readonly string[]) => void
setSidebar: (draft: LayoutState, px: number) => void
toggleSidebar: (draft: LayoutState) => void
setViewportWidth: (draft: LayoutState, width: number) => void
@@ -67,56 +78,67 @@ type LayoutActions = {
export function createLayoutStore(): EngineStoreHandle {
const handle = defineStore({
init: (): LayoutState => ({
- sidebar: SIDEBAR_DEFAULT,
- viewportWidth: window.innerWidth,
- narrowExpanded: false,
- rightbar: null,
- rightbarShown: false,
- rightbarTrack: false,
- rightbarFullscreen: false,
- rightbarInstant: false,
+ panelInfo: { activePanelId: null },
+ layoutInfo: {
+ sidebar: SIDEBAR_DEFAULT,
+ viewportWidth: window.innerWidth,
+ narrowExpanded: false,
+ rightbar: null,
+ rightbarShown: false,
+ rightbarTrack: false,
+ rightbarFullscreen: false,
+ rightbarInstant: false,
+ },
}),
actions: {
+ selectPanel: (d, panelId: MainPanelId | null) => {
+ d.panelInfo.activePanelId = panelId
+ },
+ retainMainPanels: (d, panelIds: readonly string[]) => {
+ if (d.panelInfo.activePanelId !== null && !panelIds.includes(d.panelInfo.activePanelId)) {
+ d.panelInfo.activePanelId = null
+ }
+ },
setSidebar: (d, px: number) => {
- d.rightbarInstant = false
- d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX)
+ d.layoutInfo.rightbarInstant = false
+ d.layoutInfo.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX)
},
// Narrow toggles flip only the override: the width preference survives
// untouched, so re-widening restores the pre-squeeze layout.
toggleSidebar: (d) => {
- d.rightbarInstant = false
- if (d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.narrowExpanded = !d.narrowExpanded
- else d.sidebar = d.sidebar === 0 ? SIDEBAR_DEFAULT : 0
+ d.layoutInfo.rightbarInstant = false
+ if (d.layoutInfo.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.layoutInfo.narrowExpanded = !d.layoutInfo.narrowExpanded
+ else d.layoutInfo.sidebar = d.layoutInfo.sidebar === 0 ? SIDEBAR_DEFAULT : 0
},
// Crossing the breakpoint in either direction drops the override: the
// narrow default is auto-collapsed, the wide state is the preference.
setViewportWidth: (d, width: number) => {
- if (d.viewportWidth === width) return
- d.rightbarInstant = false
- if ((d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) !== (width < SIDEBAR_AUTO_COLLAPSE)) {
- d.narrowExpanded = false
+ if (d.layoutInfo.viewportWidth === width) return
+ d.layoutInfo.rightbarInstant = false
+ if ((d.layoutInfo.viewportWidth < SIDEBAR_AUTO_COLLAPSE) !== (width < SIDEBAR_AUTO_COLLAPSE)) {
+ d.layoutInfo.narrowExpanded = false
}
- d.viewportWidth = width
+ d.layoutInfo.viewportWidth = width
},
setRightbar: (d, px: number) => {
- d.rightbarInstant = false
- d.rightbar = clampWidth(px, RIGHTBAR_MIN, Math.max(RIGHTBAR_MIN, d.viewportWidth * RIGHTBAR_MAX_RATIO))
+ d.layoutInfo.rightbarInstant = false
+ d.layoutInfo.rightbar = clampWidth(px, RIGHTBAR_MIN, Math.max(RIGHTBAR_MIN, d.layoutInfo.viewportWidth * RIGHTBAR_MAX_RATIO))
},
openRightbar: (d, track: boolean, fullscreen: boolean) => {
- if (!d.rightbarShown || d.rightbarTrack !== track || d.rightbarFullscreen !== fullscreen) {
- d.rightbarInstant = d.rightbarFullscreen && !fullscreen
+ if (!d.layoutInfo.rightbarShown || d.layoutInfo.rightbarTrack !== track || d.layoutInfo.rightbarFullscreen !== fullscreen) {
+ d.layoutInfo.rightbarInstant = d.layoutInfo.rightbarFullscreen && !fullscreen
}
- if (!d.rightbarShown && d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.narrowExpanded = false
- d.rightbar ??= Math.max(RIGHTBAR_MIN, Math.round(d.viewportWidth * RIGHTBAR_DEFAULT_RATIO))
- d.rightbarShown = true
- d.rightbarTrack = track
- d.rightbarFullscreen = fullscreen
+ if (!d.layoutInfo.rightbarShown && d.layoutInfo.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.layoutInfo.narrowExpanded = false
+ d.layoutInfo.rightbar ??= Math.max(RIGHTBAR_MIN, Math.round(d.layoutInfo.viewportWidth * RIGHTBAR_DEFAULT_RATIO))
+ d.layoutInfo.rightbarShown = true
+ d.layoutInfo.rightbarTrack = track
+ d.layoutInfo.rightbarFullscreen = fullscreen
},
closeRightbar: (d) => {
- if (d.rightbarShown) d.rightbarInstant = d.rightbarFullscreen
- d.rightbarShown = false
- d.rightbarTrack = false
- d.rightbarFullscreen = false
+ if (d.layoutInfo.rightbarShown) d.layoutInfo.rightbarInstant = d.layoutInfo.rightbarFullscreen
+ d.layoutInfo.rightbarShown = false
+ d.layoutInfo.rightbarTrack = false
+ d.layoutInfo.rightbarFullscreen = false
},
},
})
diff --git a/packages/client/ui-layout/tests/app-frame.client.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx
index 62623b9b81..55e51bdeac 100644
--- a/packages/client/ui-layout/tests/app-frame.client.spec.tsx
+++ b/packages/client/ui-layout/tests/app-frame.client.spec.tsx
@@ -1,14 +1,13 @@
// @vitest-environment jsdom
/** Frame interactions with a real store and explicitly driven browser measurements. */
-import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
+import type { GlobalStandardProps, RenderOpts } from '@deepseek-ai/dsh-client-ui-slots'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
-import { useSyncExternalStore } from 'react'
import { AppFrame } from '../src/client/AppFrame.tsx'
import type { AppFrameProps } from '../src/client/AppFrame.tsx'
-import type { RightbarOwnerProps, SidebarOwnerProps } from '../src/client/index.ts'
+import type { MainPanelId, RightbarOwnerProps, SidebarOwnerProps } from '../src/client/index.ts'
import { createLayoutStore } from '../src/client/stores.ts'
-import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -19,8 +18,6 @@ let workspacesReady = true
type AttentionSnapshot = Parameters[0]>[0]
const noAttention: AttentionSnapshot = new Map()
const useSessionPendingInteraction: AppFrameProps['useSessionPendingInteraction'] = selector => selector(noAttention)
-const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) =>
- selectedSession === undefined ? <>{empty?.() ?? null}> : <>{children}>
let observers: ResizeObserverStub[]
class ResizeObserverStub {
@@ -63,19 +60,15 @@ function resize(width: number): void {
})
}
-function hookOf(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
- return function useSelector(sel: (s: T) => S): S { return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) }
-}
-
function mountFrame(windowWidth = frameWidth) {
vi.stubGlobal('innerWidth', windowWidth)
const instance = createLayoutStore().create()
- const slotCalls: { key: string; props: object }[] = []
- const renderSlot = ((key: string, owner: object) => {
- slotCalls.push({ key, props: owner })
- return
- }) as AppFrameProps['renderSlot']
- const useSessions = ((sel: (s: SessionListState) => unknown) => sel({
+ const slotCalls: { key: string; props: object; options: RenderOpts | undefined }[] = []
+ const renderSlot: AppFrameProps['renderSlot'] = (key, owner, options) => {
+ slotCalls.push({ key, props: owner, options })
+ return
+ }
+ const useSessions: AppFrameProps['useSessions'] = sel => sel({
ids: selectedSession === undefined ? [] : [selectedSession],
byId: selectedSession === undefined ? {} : {
[selectedSession]: {
@@ -85,22 +78,29 @@ function mountFrame(windowWidth = frameWidth) {
},
current: selectedSession,
phase: 'ready',
- } as SessionListState)) as AppFrameProps['useSessions']
+ subagentsByParent: {},
+ jobsBySession: {},
+ currentAddress: undefined,
+ })
const workspaceState: WorkspaceSnapshot = {
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
...(workspacesReady ? {} : { state: 'loading' as const, phase: 'pending' as const }),
}
- const useStore = hookOf(instance)
+ const useStore = bindSnapshotSelector(instance)
+ const usePanelInfo = bindSnapshotSelector({
+ getSnapshot: () => instance.getSnapshot().panelInfo,
+ subscribe: listener => instance.subscribe(listener),
+ })
const element = () => (
unknown) => sel(workspaceState)) as AppFrameProps['useWorkspaces']}
- SessionProvider={SessionProviderStub}
+ useWorkspaces={sel => sel(workspaceState)}
t={key => key === 'brand.localBuild' ? 'DSH Local Build' : key}
/>
)
@@ -202,22 +202,22 @@ describe('AppFrame', () => {
expect(tracks(frame)).toEqual([280, 0])
expect(sidebarOwner()).toEqual({ collapsed: false, width: 280 })
expect(rightOwner()).toEqual({ width: 864, viewportWidth: 1920, canShow: true })
- expect(slotCalls.find(c => c.key === 'conversation')!.props).toEqual({})
+ expect(slotCalls.find(c => c.key === 'main')).toEqual({ key: 'main', props: {}, options: { entryKey: 'conversation' } })
})
- it('retains conversation and sidebar content without a current Session', () => {
+ it('renders the main, sidebar, and root-scoped rightbar outlets without a current Session', () => {
selectedSession = undefined
- const { frame, getByTestId, queryByTestId } = mountFrame()
- expect(getByTestId('conversation-content')).toBeTruthy()
+ const { frame, getByTestId } = mountFrame()
+ expect(getByTestId('main-content').getAttribute('data-entry-key')).toBe('conversation')
expect(getByTestId('sidebar-content')).toBeTruthy()
- expect(queryByTestId('rightbar-content')).toBeNull()
+ expect(getByTestId('rightbar-content')).toBeTruthy()
expect(tracks(frame)).toEqual([280, 0])
})
it('renders both occupants before workspace baselines settle', () => {
workspacesReady = false
const { getByTestId } = mountFrame()
- expect(getByTestId('conversation-content')).toBeTruthy()
+ expect(getByTestId('main-content').getAttribute('data-entry-key')).toBe('conversation')
expect(getByTestId('rightbar-content')).toBeTruthy()
})
@@ -229,16 +229,34 @@ describe('AppFrame', () => {
expect(getByTestId('sidebar-content')).toBeTruthy()
expect(frame.querySelector('[data-side="sidebar"]')).toBeNull()
})
+
+ it('switches only the keyed main outlet when the active panel changes', () => {
+ selectedSessionTitle = 'Session title'
+ const { instance, frame, slotCalls, getByTestId } = mountFrame()
+ const sessionId = selectedSession
+ const layoutInfo = instance.getSnapshot().layoutInfo
+ for (const panelId of ['panel-a' as MainPanelId, 'panel-b' as MainPanelId, null]) {
+ slotCalls.length = 0
+ act(() => { instance.actions.selectPanel(panelId) })
+ expect(slotCalls).toEqual([{ key: 'main', props: {}, options: { entryKey: panelId ?? 'conversation' } }])
+ expect(getByTestId('main-content').getAttribute('data-entry-key')).toBe(panelId ?? 'conversation')
+ expect(instance.getSnapshot().panelInfo).toEqual({ activePanelId: panelId })
+ expect(instance.getSnapshot().layoutInfo).toBe(layoutInfo)
+ expect(tracks(frame)).toEqual([280, 0])
+ expect(selectedSession).toBe(sessionId)
+ expect(document.title).toBe(panelId === null ? 'Session title — DSH Local Build' : 'DSH Local Build')
+ }
+ })
})
describe('AppFrame normal width concessions', () => {
it('measures the frame, not the window, before choosing the first-open preference', () => {
frameWidth = 1000
const { instance, rightOwner } = mountFrame(1920)
- expect(instance.getSnapshot().viewportWidth).toBe(1000)
+ expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1000)
expect(rightOwner()).toEqual({ width: 450, viewportWidth: 1000, canShow: true })
act(() => { instance.actions.openRightbar(true, false) })
- expect(instance.getSnapshot().rightbar).toBe(450)
+ expect(instance.getSnapshot().layoutInfo.rightbar).toBe(450)
resize(1920)
expect(rightOwner().width).toBe(450)
})
@@ -255,7 +273,7 @@ describe('AppFrame normal width concessions', () => {
expect(tracks(frame)).toEqual([420, 0])
expect(rightOwner()).toEqual({ width: 0, viewportWidth: 1119, canShow: false })
expect(frame.querySelector('[data-side="rightbar"]')).toBeNull()
- expect(instance.getSnapshot()).toMatchObject({ rightbarShown: true, rightbar: 864 })
+ expect(instance.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbar: 864 })
act(() => { instance.actions.closeRightbar() })
resize(455)
expect(tracks(frame)).toEqual([56, 0])
@@ -271,7 +289,7 @@ describe('AppFrame normal width concessions', () => {
expect(rightOwner()).toEqual({ width: 344, viewportWidth: 800, canShow: true })
act(() => { instance.actions.openRightbar(true, false) })
expect(tracks(frame)).toEqual([56, 344])
- expect(instance.getSnapshot()).toMatchObject({ narrowExpanded: false, rightbar: 360 })
+ expect(instance.getSnapshot().layoutInfo).toMatchObject({ narrowExpanded: false, rightbar: 360 })
expect(rightOwner().canShow).toBe(true)
})
@@ -312,7 +330,7 @@ describe('AppFrame normal width concessions', () => {
resize(980)
act(() => { instance.actions.toggleSidebar() })
expect(tracks(frame)[0]).toBe(280)
- expect(instance.getSnapshot().sidebar).toBe(0)
+ expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0)
})
})
@@ -404,7 +422,7 @@ describe('AppFrame right panel presentation', () => {
act(() => { instance.actions.openRightbar(false, true) })
expect(tracks(frame)).toEqual([56, 0])
expect(rightOwner()).toEqual({ width: 0, viewportWidth: 700, canShow: false })
- expect(instance.getSnapshot().rightbarShown).toBe(true)
+ expect(instance.getSnapshot().layoutInfo.rightbarShown).toBe(true)
expect(frame.querySelector('[data-side="rightbar"]')).toBeNull()
})
@@ -415,7 +433,7 @@ describe('AppFrame right panel presentation', () => {
expect(tracks(frame)).toEqual([280, 0])
expect(rightOwner().width).toBe(420)
drag(handleFor(frame, 'rightbar'), 680, 690)
- expect(instance.getSnapshot().rightbar).toBe(410)
+ expect(instance.getSnapshot().layoutInfo.rightbar).toBe(410)
expect(rightOwner().width).toBe(410)
expect(tracks(frame)[1]).toBe(0)
})
@@ -436,7 +454,7 @@ describe('AppFrame pointer resizing', () => {
act(flushFrames)
expect(tracks(frame)[0]).toBe(360)
pointer(handle, 'pointerup', 360)
- expect(instance.getSnapshot().sidebar).toBe(360)
+ expect(instance.getSnapshot().layoutInfo.sidebar).toBe(360)
expect(frame.dataset.dragging).toBeUndefined()
expect(handle.hasPointerCapture(1)).toBe(false)
})
@@ -450,7 +468,7 @@ describe('AppFrame pointer resizing', () => {
expect(tracks(frame)[1]).toBe(420)
expect(handle.style.left).toBe('680px')
drag(handle, 680, 690)
- expect(instance.getSnapshot().rightbar).toBe(410)
+ expect(instance.getSnapshot().layoutInfo.rightbar).toBe(410)
expect(rightOwner().width).toBe(410)
expect(tracks(frame)[1]).toBe(410)
expect(handle.style.left).toBe('690px')
@@ -474,10 +492,10 @@ describe('AppFrame pointer resizing', () => {
pointer(handle, 'pointerdown', 280)
pointer(handle, 'pointermove', 320)
pointer(handle, 'pointerup', 360)
- expect(instance.getSnapshot().sidebar).toBe(360)
+ expect(instance.getSnapshot().layoutInfo.sidebar).toBe(360)
expect(animationFrames.size).toBe(0)
act(flushFrames)
- expect(instance.getSnapshot().sidebar).toBe(360)
+ expect(instance.getSnapshot().layoutInfo.sidebar).toBe(360)
})
it('ignores uncaptured motion, secondary buttons, and a second pointer', () => {
@@ -493,9 +511,9 @@ describe('AppFrame pointer resizing', () => {
pointer(handle, 'pointermove', 500, 9)
pointer(handle, 'pointerup', 500, 9)
expect(animationFrames.size).toBe(0)
- expect(instance.getSnapshot().sidebar).toBe(280)
+ expect(instance.getSnapshot().layoutInfo.sidebar).toBe(280)
pointer(handle, 'pointerup', 300)
- expect(instance.getSnapshot().sidebar).toBe(300)
+ expect(instance.getSnapshot().layoutInfo.sidebar).toBe(300)
})
it.each(['pointercancel', 'lostpointercapture'])('ends %s without committing queued motion', (event) => {
@@ -506,7 +524,7 @@ describe('AppFrame pointer resizing', () => {
if (event === 'lostpointercapture') handle.releasePointerCapture(1)
pointer(handle, event, 340)
act(flushFrames)
- expect(instance.getSnapshot().sidebar).toBe(280)
+ expect(instance.getSnapshot().layoutInfo.sidebar).toBe(280)
expect(animationFrames.size).toBe(0)
expect(frame.dataset.dragging).toBeUndefined()
expect(handle.hasPointerCapture(1)).toBe(false)
@@ -526,7 +544,7 @@ describe('AppFrame pointer resizing', () => {
const settled = instance.getSnapshot()
act(flushFrames)
expect(instance.getSnapshot()).toBe(settled)
- expect(instance.getSnapshot().rightbar).toBe(864)
+ expect(instance.getSnapshot().layoutInfo.rightbar).toBe(864)
expect(animationFrames.size).toBe(0)
expect(handle.hasPointerCapture(1)).toBe(false)
if (change !== 'unmount') expect(frame.dataset.dragging).toBeUndefined()
@@ -544,17 +562,17 @@ describe('AppFrame frame measurement lifecycle', () => {
observer.fire()
})
expect(animationFrames.size).toBe(1)
- expect(instance.getSnapshot().viewportWidth).toBe(1920)
+ expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1920)
act(flushFrames)
- expect(instance.getSnapshot().viewportWidth).toBe(1200)
+ expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1200)
expect(rightOwner().viewportWidth).toBe(1200)
- expect(instance.getSnapshot().rightbar).toBeNull()
+ expect(instance.getSnapshot().layoutInfo.rightbar).toBeNull()
})
it('retains the last positive measurement while the frame is hidden', () => {
const { instance, rightOwner } = mountFrame()
resize(0)
- expect(instance.getSnapshot().viewportWidth).toBe(1920)
+ expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1920)
expect(rightOwner().viewportWidth).toBe(1920)
})
@@ -568,7 +586,7 @@ describe('AppFrame frame measurement lifecycle', () => {
expect(observer.disconnected).toBe(true)
expect(animationFrames.size).toBe(0)
act(() => { observer.fire(); flushFrames() })
- expect(instance.getSnapshot().viewportWidth).toBe(1920)
+ expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1920)
expect(animationFrames.size).toBe(0)
})
})
diff --git a/packages/client/ui-layout/tests/apply.client.spec.ts b/packages/client/ui-layout/tests/apply.client.spec.ts
index 38732eda2c..74c58022c9 100644
--- a/packages/client/ui-layout/tests/apply.client.spec.ts
+++ b/packages/client/ui-layout/tests/apply.client.spec.ts
@@ -1,20 +1,58 @@
// @vitest-environment jsdom
-import { Context } from '@deepseek-ai/cordis'
+import { Context, type Fiber } from '@deepseek-ai/cordis'
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { apply as themeApply, inject as themeInject, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
import { apply, inject, LayoutController } from '@deepseek-ai/dsh-client-ui-layout/client'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout'
+import type { MainPanelId } from '../src/client/service.ts'
+import type { createLayoutStore } from '../src/client/stores.ts'
+
+const owners = new Set()
+let originalRootStyle: string | null
+let originalBodyStyle: string | null
+let originalDarkTheme: string | null
+let originalThemeMetadata: Element[]
beforeEach(() => {
- document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => { node.remove() })
+ originalRootStyle = document.documentElement.getAttribute('style')
+ originalBodyStyle = document.body.getAttribute('style')
+ originalDarkTheme = document.body.getAttribute('data-ds-dark-theme')
+ originalThemeMetadata = [...document.head.querySelectorAll('meta[name="theme-color"]')]
+ originalThemeMetadata.forEach((node) => { node.remove() })
+ vi.stubGlobal('innerWidth', 1920)
})
+afterEach(async () => {
+ try {
+ for (const owner of owners) await owner.dispose()
+ } finally {
+ owners.clear()
+ restoreAttribute(document.documentElement, 'style', originalRootStyle)
+ restoreAttribute(document.body, 'style', originalBodyStyle)
+ restoreAttribute(document.body, 'data-ds-dark-theme', originalDarkTheme)
+ document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => { node.remove() })
+ document.head.append(...originalThemeMetadata)
+ vi.unstubAllGlobals()
+ }
+})
+
+function restoreAttribute(element: Element, name: string, value: string | null): void {
+ if (value === null) element.removeAttribute(name)
+ else element.setAttribute(name, value)
+}
+
async function bench() {
- const ctx = new Context()
+ const root = new Context()
+ let ctx: Context | undefined
+ const owner = root.plugin((ownedContext: Context) => { ctx = ownedContext })
+ owners.add(owner)
+ await owner.await()
+ if (ctx === undefined) throw new Error('the fixture owner did not activate')
const slotsFiber = ctx.plugin(SlotRegistry)
// Theme registers its Appearance settings row and requires the connection
// seam for persistence; model this bench as a remote, memory-only browser.
@@ -25,7 +63,15 @@ async function bench() {
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
await ctx.plugin({ inject: themeInject, apply: themeApply }).await()
await slotsFiber.await()
- return { ctx, slots: ctx.get('slots') as SlotRegistry }
+ const slots = ctx.get('slots') as SlotRegistry
+ let host: SlotRendererHost | undefined
+ slots.install({ renderRoot: (value) => { host = value; return null } })
+ const rendererHost = (): SlotRendererHost => {
+ slots.renderSlot('root', {})
+ if (host === undefined) throw new Error('the root renderer did not receive its host')
+ return host
+ }
+ return { ctx, slots, rendererHost }
}
describe('ui-layout client apply', () => {
@@ -33,30 +79,46 @@ describe('ui-layout client apply', () => {
expect(inject).toEqual(['slots', 'theme', 'locale'])
})
- it('provides ctx.layout and registers AppFrame into root with the three child declarations', async () => {
+ it('provides ctx.layout and declares the four root-scoped frame slots', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(ctx.get('layout')).toBeInstanceOf(LayoutController)
- // The one register() call occupied 'root'…
expect(slots.entries('root')).toHaveLength(1)
- // …and declared the three children in the ledger.
expect(slots.spec('sidebar')).toEqual({ kind: 'single', scope: 'root' })
- expect(slots.spec('conversation')).toEqual({ kind: 'single', scope: 'session-maybe' })
+ expect(slots.spec('main')).toEqual({ kind: 'keyed', scope: 'root' })
+ expect(slots.spec('rightbar')).toEqual({ kind: 'single', scope: 'root' })
+ expect(slots.spec('shell.overlay')).toEqual({ kind: 'list', scope: 'root' })
})
- it('injects no business face and attaches the layout actions', async () => {
- const { ctx, slots } = await bench()
+ it('shares a pre-created instance between service actions, root rendering, and panelInfo', async () => {
+ const { ctx, slots, rendererHost } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
- const actions = {
- setSidebar: vi.fn(), setDetails: vi.fn(), toggleSidebar: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(),
- }
- const injected = (slots.entries('root')[0]!.inject as (actions: never) => object)(actions as never)
- expect(injected).toEqual({})
+ const entry = slots.entries('root')[0]!
+ expect(entry.inject).toBeUndefined()
+ const handle = entry.store as ReturnType
+ const instance = handle.create()
+ expect(handle.create()).toBe(instance)
const layout = ctx.get('layout') as LayoutController
+ expect(() => { layout.selectPanel('missing' as MainPanelId) }).toThrow('main panel "missing" is not registered')
layout.toggleSidebar()
- expect(actions.toggleSidebar).toHaveBeenCalledOnce()
+ expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0)
+ const host = rendererHost()
+ expect(host.storeOf(entry, undefined)).toBe(instance)
+ const panelInfo = host.root.getSnapshot().hooks.panelInfo!
+ expect(panelInfo.getSnapshot()).toBe(instance.getSnapshot().panelInfo)
+ const panelId = 'panel-a' as MainPanelId
+ const disposePanel = slots.register({ name: 'main', key: panelId }, () => null)
+ layout.selectPanel(panelId)
+ expect(panelInfo.getSnapshot()).toEqual({ activePanelId: panelId })
+ disposePanel()
+ await vi.waitFor(() => { expect(panelInfo.getSnapshot()).toEqual({ activePanelId: null }) })
+ expect(() => { layout.selectPanel(panelId) }).toThrow('main panel "panel-a" is not registered')
+ expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0)
+ const pending = layout.beginNavigation()
+ await fiber.dispose()
+ expect(pending.aborted).toBe(true)
})
it('theme presenter applies the initial snapshot, follows theme/change, and unwinds on dispose', async () => {
@@ -85,13 +147,19 @@ describe('ui-layout client apply', () => {
})
it('teardown unwinds the service, the root registration, and the child declarations', async () => {
- const { ctx, slots } = await bench()
+ const { ctx, slots, rendererHost } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
+ const host = rendererHost()
+ expect(host.root.getSnapshot().hooks.panelInfo).toBeDefined()
await fiber.dispose()
expect(ctx.get('layout')).toBeUndefined()
expect(slots.entries('root')).toHaveLength(0)
expect(slots.spec('sidebar')).toBeUndefined()
+ expect(slots.spec('main')).toBeUndefined()
+ expect(slots.spec('rightbar')).toBeUndefined()
+ expect(slots.spec('shell.overlay')).toBeUndefined()
+ expect(host.root.getSnapshot().hooks.panelInfo).toBeUndefined()
// The built-in root declaration survives entry teardown (renderer-owned).
expect(slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
})
diff --git a/packages/client/ui-layout/tests/document-title.client.spec.tsx b/packages/client/ui-layout/tests/document-title.client.spec.tsx
index 2cec953cb1..5c3184970f 100644
--- a/packages/client/ui-layout/tests/document-title.client.spec.tsx
+++ b/packages/client/ui-layout/tests/document-title.client.spec.tsx
@@ -1,36 +1,82 @@
// @vitest-environment jsdom
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { cleanup, render } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { act, cleanup, render } from '@testing-library/react'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
+import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { DocumentTitle } from '../src/client/DocumentTitle.tsx'
+import type { MainPanelId, PanelInfo } from '../src/client/service.ts'
+let originalTitle: string
+beforeEach(() => { originalTitle = document.title })
afterEach(() => {
- cleanup()
- document.title = ''
- vi.unstubAllEnvs()
+ try { cleanup() } finally { document.title = originalTitle }
})
+function titleSources() {
+ const sessionId = 'session-title' as SessionId
+ const sessions = createSnapshotStore({
+ ids: [sessionId],
+ byId: { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, blank: false, updatedAt: 1 } },
+ current: sessionId,
+ phase: 'ready',
+ subagentsByParent: {},
+ jobsBySession: {},
+ currentAddress: undefined,
+ })
+ const panelInfo = createSnapshotStore({ activePanelId: null })
+ return {
+ sessionId, sessions, panelInfo,
+ props: { useSessions: bindSnapshotSelector(sessions), usePanelInfo: bindSnapshotSelector(panelInfo) },
+ }
+}
+
describe('DocumentTitle', () => {
it('projects a durable title and restores the product title', () => {
- vi.stubEnv('DSH_CLIENT_TITLE', 'DeepSeek Harness')
+ const { sessionId, sessions, props } = titleSources()
document.title = 'stale title'
- const mounted = render()
+ const mounted = render()
expect(document.title).toBe('DeepSeek Harness')
- mounted.rerender()
+ act(() => { sessions.update((state) => { state.byId[sessionId]!.title = 'First title' }) })
expect(document.title).toBe('First title — DeepSeek Harness')
- mounted.rerender()
+ act(() => { sessions.update((state) => { state.byId[sessionId]!.title = 'Revised title' }) })
expect(document.title).toBe('Revised title — DeepSeek Harness')
- mounted.rerender()
+ act(() => { sessions.update((state) => { state.current = undefined }) })
expect(document.title).toBe('DeepSeek Harness')
mounted.unmount()
expect(document.title).toBe('DeepSeek Harness')
})
- it('uses the generic title when the build provides no title', () => {
- vi.stubEnv('DSH_CLIENT_TITLE', '')
- delete process.env.DSH_CLIENT_TITLE
- const mounted = render()
+ it('uses the localized product title supplied by the frame', () => {
+ const { sessionId, sessions, props } = titleSources()
+ sessions.update((state) => { state.byId[sessionId]!.title = 'First title' })
+ const mounted = render()
expect(document.title).toBe('First title — DSH Local Build')
mounted.unmount()
expect(document.title).toBe('DSH Local Build')
})
+
+ it('keeps the product title across global panels and restores the latest Session title on return', () => {
+ const { sessionId, sessions, panelInfo, props } = titleSources()
+ sessions.update((state) => { state.byId[sessionId]!.title = 'Session title' })
+ render()
+ expect(document.title).toBe('Session title — Product')
+ act(() => { panelInfo.set({ activePanelId: 'panel-a' as MainPanelId }) })
+ expect(document.title).toBe('Product')
+ act(() => { sessions.update((state) => { state.byId[sessionId]!.title = 'Updated title' }) })
+ expect(document.title).toBe('Product')
+ act(() => { panelInfo.set({ activePanelId: 'panel-b' as MainPanelId }) })
+ expect(document.title).toBe('Product')
+ expect(sessions.getSnapshot().current).toBe(sessionId)
+ act(() => { panelInfo.set({ activePanelId: null }) })
+ expect(document.title).toBe('Updated title — Product')
+ })
+
+ it('uses the product title when the current Session row is not available', () => {
+ const { sessions, props } = titleSources()
+ sessions.update((state) => { state.byId = {}; state.ids = [] })
+ render()
+ expect(document.title).toBe('Product')
+ })
})
diff --git a/packages/client/ui-layout/tests/layout-store.client.spec.ts b/packages/client/ui-layout/tests/layout-store.client.spec.ts
index 60dacf2f4b..edf2a5a3c9 100644
--- a/packages/client/ui-layout/tests/layout-store.client.spec.ts
+++ b/packages/client/ui-layout/tests/layout-store.client.spec.ts
@@ -2,6 +2,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createLayoutStore } from '../src/client/stores.ts'
+import type { MainPanelId } from '../src/client/service.ts'
beforeEach(() => { vi.stubGlobal('innerWidth', 1920) })
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() })
@@ -10,14 +11,17 @@ describe('createLayoutStore', () => {
it('starts with the default sidebar and no right panel preference', () => {
const { store } = createLayoutStore().create()
expect(store.getSnapshot()).toEqual({
- sidebar: 280,
- viewportWidth: 1920,
- narrowExpanded: false,
- rightbar: null,
- rightbarShown: false,
- rightbarTrack: false,
- rightbarFullscreen: false,
- rightbarInstant: false,
+ panelInfo: { activePanelId: null },
+ layoutInfo: {
+ sidebar: 280,
+ viewportWidth: 1920,
+ narrowExpanded: false,
+ rightbar: null,
+ rightbarShown: false,
+ rightbarTrack: false,
+ rightbarFullscreen: false,
+ rightbarInstant: false,
+ },
})
})
@@ -27,26 +31,26 @@ describe('createLayoutStore', () => {
const b = createLayoutStore().create()
a.actions.setSidebar(400)
a.actions.openRightbar(true, false)
- expect(b.store.getSnapshot().sidebar).toBe(280)
- expect(b.store.getSnapshot().rightbar).toBeNull()
+ expect(b.store.getSnapshot().layoutInfo.sidebar).toBe(280)
+ expect(b.store.getSnapshot().layoutInfo.rightbar).toBeNull()
expect(write).not.toHaveBeenCalled()
})
it('clamps the sidebar to 264–420px', () => {
const { store, actions } = createLayoutStore().create()
actions.setSidebar(1)
- expect(store.getSnapshot().sidebar).toBe(264)
+ expect(store.getSnapshot().layoutInfo.sidebar).toBe(264)
actions.setSidebar(9999)
- expect(store.getSnapshot().sidebar).toBe(420)
+ expect(store.getSnapshot().layoutInfo.sidebar).toBe(420)
})
it('toggles the wide sidebar between closed and default width', () => {
const { store, actions } = createLayoutStore().create()
actions.setSidebar(400)
actions.toggleSidebar()
- expect(store.getSnapshot().sidebar).toBe(0)
+ expect(store.getSnapshot().layoutInfo.sidebar).toBe(0)
actions.toggleSidebar()
- expect(store.getSnapshot().sidebar).toBe(280)
+ expect(store.getSnapshot().layoutInfo.sidebar).toBe(280)
})
it('keeps the sidebar preference while toggling its narrow override', () => {
@@ -54,9 +58,9 @@ describe('createLayoutStore', () => {
actions.setSidebar(400)
actions.setViewportWidth(980)
actions.toggleSidebar()
- expect(store.getSnapshot()).toMatchObject({ sidebar: 400, viewportWidth: 980, narrowExpanded: true })
+ expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 400, viewportWidth: 980, narrowExpanded: true })
actions.toggleSidebar()
- expect(store.getSnapshot()).toMatchObject({ sidebar: 400, narrowExpanded: false })
+ expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 400, narrowExpanded: false })
})
it('clears the manual override only when crossing 1024px', () => {
@@ -65,39 +69,101 @@ describe('createLayoutStore', () => {
actions.toggleSidebar()
actions.setViewportWidth(980)
actions.setViewportWidth(1023)
- expect(store.getSnapshot().narrowExpanded).toBe(true)
+ expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(true)
actions.setViewportWidth(1024)
- expect(store.getSnapshot().narrowExpanded).toBe(false)
+ expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(false)
actions.setViewportWidth(980)
- expect(store.getSnapshot().narrowExpanded).toBe(false)
+ expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(false)
})
})
+describe('main panel selection', () => {
+ const panelA = 'panel-a' as MainPanelId
+ const panelB = 'panel-b' as MainPanelId
+
+ it('changes only panelInfo when switching panels and returning to the Conversation', () => {
+ const { store, actions } = createLayoutStore().create()
+ actions.setSidebar(400)
+ actions.openRightbar(true, true)
+ actions.closeRightbar()
+ const layoutInfo = store.getSnapshot().layoutInfo
+ for (const activePanelId of [panelA, panelB, null]) {
+ const previousPanelInfo = store.getSnapshot().panelInfo
+ actions.selectPanel(activePanelId)
+ expect(store.getSnapshot().panelInfo).toEqual({ activePanelId })
+ expect(store.getSnapshot().panelInfo).not.toBe(previousPanelInfo)
+ expect(store.getSnapshot().layoutInfo).toBe(layoutInfo)
+ }
+ })
+
+ it('keeps the complete snapshot when selecting the current panel again', () => {
+ const { store, actions } = createLayoutStore().create()
+ actions.selectPanel(panelA)
+ const selected = store.getSnapshot()
+ actions.selectPanel(panelA)
+ expect(store.getSnapshot()).toBe(selected)
+ expect(store.getSnapshot().panelInfo.activePanelId).toBe(panelA)
+ })
+
+ it('returns to the Conversation only when the selected main registration disappears', () => {
+ const { store, actions } = createLayoutStore().create()
+ const initial = store.getSnapshot()
+ actions.retainMainPanels([])
+ expect(store.getSnapshot()).toBe(initial)
+ actions.selectPanel(panelA)
+ const selected = store.getSnapshot()
+ actions.retainMainPanels(['conversation', panelA, panelB])
+ expect(store.getSnapshot()).toBe(selected)
+ actions.retainMainPanels(['conversation', panelB])
+ expect(store.getSnapshot().panelInfo).toEqual({ activePanelId: null })
+ expect(store.getSnapshot().layoutInfo).toBe(selected.layoutInfo)
+ })
+
+ it.each(['setSidebar', 'toggleSidebar', 'setViewportWidth', 'setRightbar', 'openRightbar', 'closeRightbar'] as const)(
+ 'preserves panelInfo identity when %s changes layoutInfo', (action) => {
+ const { store, actions } = createLayoutStore().create()
+ actions.selectPanel(panelA)
+ if (action === 'closeRightbar') actions.openRightbar(true, true)
+ const previous = store.getSnapshot()
+ switch (action) {
+ case 'setSidebar': actions.setSidebar(400); break
+ case 'toggleSidebar': actions.toggleSidebar(); break
+ case 'setViewportWidth': actions.setViewportWidth(980); break
+ case 'setRightbar': actions.setRightbar(500); break
+ case 'openRightbar': actions.openRightbar(true, true); break
+ case 'closeRightbar': actions.closeRightbar(); break
+ }
+ expect(store.getSnapshot().panelInfo).toBe(previous.panelInfo)
+ expect(store.getSnapshot().layoutInfo).not.toBe(previous.layoutInfo)
+ },
+ )
+})
+
describe('right panel', () => {
it('initializes at 45% of the latest frame only on first opening', () => {
const { store, actions } = createLayoutStore().create()
actions.setViewportWidth(1000)
- expect(store.getSnapshot().rightbar).toBeNull()
+ expect(store.getSnapshot().layoutInfo.rightbar).toBeNull()
actions.openRightbar(true, false)
- expect(store.getSnapshot().rightbar).toBe(450)
+ expect(store.getSnapshot().layoutInfo.rightbar).toBe(450)
actions.setViewportWidth(2000)
actions.openRightbar(true, true)
- expect(store.getSnapshot().rightbar).toBe(450)
+ expect(store.getSnapshot().layoutInfo.rightbar).toBe(450)
actions.closeRightbar()
actions.openRightbar(true, false)
- expect(store.getSnapshot().rightbar).toBe(450)
+ expect(store.getSnapshot().layoutInfo.rightbar).toBe(450)
})
it('keeps track and fullscreen reports independent and clears both on close', () => {
const { store, actions } = createLayoutStore().create()
actions.openRightbar(true, false)
- expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false })
+ expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false })
actions.openRightbar(true, true)
- expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: true })
+ expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: true })
actions.openRightbar(false, true)
- expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: false, rightbarFullscreen: true })
+ expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: false, rightbarFullscreen: true })
actions.closeRightbar()
- expect(store.getSnapshot()).toMatchObject({ rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false })
+ expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false })
})
it('keeps dragged px preferences across resize, close, and reopen', () => {
@@ -105,29 +171,29 @@ describe('right panel', () => {
actions.openRightbar(true, false)
actions.setRightbar(1100)
actions.setViewportWidth(800)
- expect(store.getSnapshot().rightbar).toBe(1100)
+ expect(store.getSnapshot().layoutInfo.rightbar).toBe(1100)
actions.closeRightbar()
actions.openRightbar(false, true)
- expect(store.getSnapshot().rightbar).toBe(1100)
+ expect(store.getSnapshot().layoutInfo.rightbar).toBe(1100)
})
it('clamps drag preferences to 300px and 70% of the current frame', () => {
const { store, actions } = createLayoutStore().create()
actions.setViewportWidth(1600)
actions.setRightbar(9999)
- expect(store.getSnapshot().rightbar).toBe(1120)
+ expect(store.getSnapshot().layoutInfo.rightbar).toBe(1120)
actions.setViewportWidth(1000)
actions.setRightbar(9999)
- expect(store.getSnapshot().rightbar).toBe(700)
+ expect(store.getSnapshot().layoutInfo.rightbar).toBe(700)
actions.setRightbar(1)
- expect(store.getSnapshot().rightbar).toBe(300)
+ expect(store.getSnapshot().layoutInfo.rightbar).toBe(300)
})
it('retains a minimum normal preference when first opened fullscreen on a phone', () => {
const { store, actions } = createLayoutStore().create()
actions.setViewportWidth(320)
actions.openRightbar(false, true)
- expect(store.getSnapshot().rightbar).toBe(300)
+ expect(store.getSnapshot().layoutInfo.rightbar).toBe(300)
})
it('collapses a manually expanded narrow sidebar on opening, not presentation reports', () => {
@@ -136,23 +202,23 @@ describe('right panel', () => {
actions.setViewportWidth(800)
actions.toggleSidebar()
actions.openRightbar(true, false)
- expect(store.getSnapshot()).toMatchObject({ sidebar: 400, narrowExpanded: false })
+ expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 400, narrowExpanded: false })
actions.toggleSidebar()
actions.openRightbar(true, true)
- expect(store.getSnapshot().narrowExpanded).toBe(true)
+ expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(true)
actions.closeRightbar()
actions.openRightbar(true, false)
- expect(store.getSnapshot().narrowExpanded).toBe(false)
+ expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(false)
})
it('keeps the wide sidebar preference and never opens a closed right panel on resize', () => {
const { store, actions } = createLayoutStore().create()
actions.setSidebar(420)
actions.openRightbar(true, false)
- expect(store.getSnapshot().sidebar).toBe(420)
+ expect(store.getSnapshot().layoutInfo.sidebar).toBe(420)
actions.closeRightbar()
actions.setViewportWidth(3000)
- expect(store.getSnapshot()).toMatchObject({ sidebar: 420, rightbarShown: false, rightbarTrack: false })
+ expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 420, rightbarShown: false, rightbarTrack: false })
})
})
@@ -161,7 +227,7 @@ describe('right panel instant geometry', () => {
const { store, actions } = createLayoutStore().create()
actions.openRightbar(track, true)
actions.closeRightbar()
- expect(store.getSnapshot()).toMatchObject({
+ expect(store.getSnapshot().layoutInfo).toMatchObject({
rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false, rightbarInstant: true,
})
const closed = store.getSnapshot()
@@ -173,38 +239,38 @@ describe('right panel instant geometry', () => {
const { store, actions } = createLayoutStore().create()
actions.openRightbar(true, true)
actions.openRightbar(true, false)
- expect(store.getSnapshot()).toMatchObject({
+ expect(store.getSnapshot().layoutInfo).toMatchObject({
rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false, rightbarInstant: true,
})
const restored = store.getSnapshot()
actions.openRightbar(true, false)
expect(store.getSnapshot()).toBe(restored)
actions.openRightbar(false, false)
- expect(store.getSnapshot().rightbarInstant).toBe(false)
+ expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false)
})
it('allows a normal close to animate, including after restoring from fullscreen', () => {
const { store, actions } = createLayoutStore().create()
actions.openRightbar(true, false)
actions.closeRightbar()
- expect(store.getSnapshot().rightbarInstant).toBe(false)
+ expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false)
actions.openRightbar(true, true)
actions.openRightbar(true, false)
- expect(store.getSnapshot().rightbarInstant).toBe(true)
+ expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(true)
actions.closeRightbar()
- expect(store.getSnapshot()).toMatchObject({ rightbarTrack: false, rightbarInstant: false })
+ expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarTrack: false, rightbarInstant: false })
})
it.each(['setSidebar', 'toggleSidebar', 'setRightbar', 'setViewportWidth'] as const)('clears instant geometry on %s', (action) => {
const { store, actions } = createLayoutStore().create()
actions.openRightbar(true, true)
actions.closeRightbar()
- expect(store.getSnapshot().rightbarInstant).toBe(true)
+ expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(true)
if (action === 'toggleSidebar') actions.toggleSidebar()
else actions[action](action === 'setViewportWidth' ? 1800 : 350)
- expect(store.getSnapshot().rightbarInstant).toBe(false)
+ expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false)
actions.closeRightbar()
- expect(store.getSnapshot().rightbarInstant).toBe(false)
+ expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false)
})
it('does not let an unchanged frame measurement reset the fullscreen-exit marker', () => {
@@ -212,7 +278,7 @@ describe('right panel instant geometry', () => {
actions.openRightbar(true, true)
actions.closeRightbar()
const closed = store.getSnapshot()
- actions.setViewportWidth(closed.viewportWidth)
+ actions.setViewportWidth(closed.layoutInfo.viewportWidth)
expect(store.getSnapshot()).toBe(closed)
})
@@ -221,6 +287,6 @@ describe('right panel instant geometry', () => {
actions.openRightbar(true, true)
actions.closeRightbar()
actions.openRightbar(true, fullscreen)
- expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarFullscreen: fullscreen, rightbarInstant: false })
+ expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarFullscreen: fullscreen, rightbarInstant: false })
})
})
diff --git a/packages/client/ui-layout/tests/service.client.spec.ts b/packages/client/ui-layout/tests/service.client.spec.ts
index a95c6a5b44..7ccadb5c3c 100644
--- a/packages/client/ui-layout/tests/service.client.spec.ts
+++ b/packages/client/ui-layout/tests/service.client.spec.ts
@@ -1,9 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
import { LayoutController } from '../src/client/service.ts'
-import type { PanelActions } from '../src/client/service.ts'
+import type { MainPanelId, PanelActions } from '../src/client/service.ts'
function fakePanels(): PanelActions {
return {
+ selectPanel: vi.fn(),
+ retainMainPanels: vi.fn(),
setSidebar: vi.fn(),
toggleSidebar: vi.fn(),
setViewportWidth: vi.fn(),
@@ -14,10 +16,9 @@ function fakePanels(): PanelActions {
}
describe('LayoutController', () => {
- it('forwards the right column transitions to the attached set', () => {
- const service = new LayoutController()
+ it('forwards right column transitions to the constructor-supplied actions', () => {
const panels = fakePanels()
- service.attachPanels(panels)
+ const service = new LayoutController(panels, () => true)
service.openRightbar(true, false)
service.openRightbar(true, true)
@@ -32,10 +33,9 @@ describe('LayoutController', () => {
expect(panels.setRightbar).not.toHaveBeenCalled()
})
- it('forwards the three panel actions to the attached set', () => {
- const service = new LayoutController()
+ it('can toggle the sidebar immediately after construction', () => {
const panels = fakePanels()
- service.attachPanels(panels)
+ const service = new LayoutController(panels, () => true)
service.toggleSidebar()
@@ -43,21 +43,62 @@ describe('LayoutController', () => {
expect(panels.setSidebar).not.toHaveBeenCalled()
})
- it('fails loud before the root entry wired its actions', () => {
- const service = new LayoutController()
- expect(() => { service.toggleSidebar() }).toThrow(/panel actions not wired/)
+ it('forwards panel selection and returning to the Conversation without changing geometry', () => {
+ const panels = fakePanels()
+ const service = new LayoutController(panels, () => true)
+ const panelId = 'panel-a' as MainPanelId
+ service.selectPanel(panelId)
+ service.selectPanel(panelId)
+ service.selectPanel(null)
+ expect(panels.selectPanel).toHaveBeenNthCalledWith(1, panelId)
+ expect(panels.selectPanel).toHaveBeenNthCalledWith(2, panelId)
+ expect(panels.selectPanel).toHaveBeenNthCalledWith(3, null)
+ expect(panels.toggleSidebar).not.toHaveBeenCalled()
+ expect(panels.openRightbar).not.toHaveBeenCalled()
+ expect(panels.closeRightbar).not.toHaveBeenCalled()
})
- it('re-attach overwrites the stale action set (entry re-register)', () => {
- const service = new LayoutController()
- const stale = fakePanels()
- const fresh = fakePanels()
- service.attachPanels(stale)
- service.attachPanels(fresh)
+ it('keeps separately constructed controllers bound to their own instances', () => {
+ const first = fakePanels()
+ const second = fakePanels()
+ const firstService = new LayoutController(first, () => true)
+ const secondService = new LayoutController(second, () => true)
+ firstService.toggleSidebar()
+ expect(first.toggleSidebar).toHaveBeenCalledTimes(1)
+ expect(second.toggleSidebar).not.toHaveBeenCalled()
+ secondService.closeRightbar()
+ expect(first.closeRightbar).not.toHaveBeenCalled()
+ expect(second.closeRightbar).toHaveBeenCalledTimes(1)
+ })
- service.toggleSidebar()
+ it('rejects an absent main entry without changing selection or cancelling pending navigation', () => {
+ const panels = fakePanels()
+ const present = new Set(['panel-a'])
+ const service = new LayoutController(panels, id => present.has(id))
+ service.selectPanel('panel-a' as MainPanelId)
+ const navigation = service.beginNavigation()
+ present.delete('panel-a')
+ expect(() => { service.selectPanel('panel-a' as MainPanelId) }).toThrow('main panel "panel-a" is not registered')
+ expect(panels.selectPanel).toHaveBeenCalledOnce()
+ expect(navigation.aborted).toBe(false)
+ service.selectPanel(null)
+ expect(navigation.aborted).toBe(true)
+ })
- expect(stale.toggleSidebar).not.toHaveBeenCalled()
- expect(fresh.toggleSidebar).toHaveBeenCalledTimes(1)
+ it('supersedes asynchronous navigation on another request, any valid selection, and disposal', () => {
+ const service = new LayoutController(fakePanels(), () => true)
+ const first = service.beginNavigation()
+ const second = service.beginNavigation()
+ expect(first.aborted).toBe(true)
+ expect(second.aborted).toBe(false)
+ service.selectPanel('panel-a' as MainPanelId)
+ expect(second.aborted).toBe(true)
+ const repeated = service.beginNavigation()
+ service.selectPanel('panel-a' as MainPanelId)
+ expect(repeated.aborted).toBe(true)
+ const pending = service.beginNavigation()
+ service.dispose()
+ expect(pending.aborted).toBe(true)
+ service.dispose()
})
})
diff --git a/packages/client/ui-layout/tsconfig.json b/packages/client/ui-layout/tsconfig.json
index 79a7f1ce54..ed6a3944d2 100644
--- a/packages/client/ui-layout/tsconfig.json
+++ b/packages/client/ui-layout/tsconfig.json
@@ -8,6 +8,9 @@
"src"
],
"references": [
+ {
+ "path": "../../util/brand"
+ },
{
"path": "../../../vendor/cordis"
},
diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json
index 4d87ef6ca0..d3c89480e8 100644
--- a/packages/client/ui-model-selection/package.json
+++ b/packages/client/ui-model-selection/package.json
@@ -63,14 +63,12 @@
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
- "@deepseek-ai/dsh-client-ui-session": "workspace:^"
+ "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+ "clsx": "^2.1.1"
},
"files": [
"lib/index.js",
"lib/client.js",
"lib/types/**/*.d.ts"
- ],
- "dependencies": {
- "clsx": "^2.1.1"
- }
+ ]
}
diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx
index 5ef380f656..b1af331463 100644
--- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx
+++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx
@@ -13,6 +13,7 @@ import { PermissionPresetSettingsController } from '../src/client/settings-store
// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
const schema = new SettingsSchemaService(new Context())
@@ -60,7 +61,7 @@ const useSessionPendingInteraction: PermissionRowProps['useSessionPendingInterac
const runtime = {
useSessions: (() => { throw new Error('unused') }) as never,
useSessionPendingInteraction,
- useResource,
+ usePanelInfo, useResource,
useWorkspaces: (() => { throw new Error('unused') }) as never,
}
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index 4bda8cc5f7..6dd7e78078 100644
--- a/packages/client/ui-primitives/README.i18n.yaml
+++ b/packages/client/ui-primitives/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 18b136c78539d8ba6da3b17239112967faf63602
-README.zh.md: f91435e44a5ca79b245fde3dd025a732d35e54e9
+README.md: 3e0922690cc68a477e4b1d5ca1ed142d713369f0
+README.zh.md: de8487499ad31affc95986125c910b368ae39b95
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index 18b136c785..3e0922690c 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -25,6 +25,8 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out
## Use this package
+This package is a Web-shell build input. Its static ESM retains third-party imports and styles for Vite; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)).
+
Compose feature UI from these atoms whenever the web client needs a standard control or an agent-output renderer. They render through React only and take `--dsw-*` design tokens from the theme, so they fit any plugin without importing the theme or the slot system.
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
index f91435e44a..de8487499a 100644
--- a/packages/client/ui-primitives/README.zh.md
+++ b/packages/client/ui-primitives/README.zh.md
@@ -25,6 +25,8 @@ kind: "package-library"
## 使用本包
+本包是 Web 壳的构建输入。静态 ESM 为 Vite 保留第三方导入和样式;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。
+
只要 Web 客户端需要标准控件或 agent 输出渲染器,就用这些原子组件拼装功能 UI。它们只经 React 渲染,并从主题取得 `--dsw-*` 设计 token,因此无需导入主题或 slot 系统即可适配任意插件。
diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json
index 41fbdb4ebe..2613c3aa98 100644
--- a/packages/client/ui-primitives/package.json
+++ b/packages/client/ui-primitives/package.json
@@ -22,32 +22,30 @@
"./package.json": "./package.json"
},
"license": "MIT",
- "dependencies": {
- "@shikijs/langs": "^4.3.1",
- "@types/mdast": "^4.0.4",
- "anser": "^2.3.5",
- "clsx": "^2.0.0",
- "katex": "^0.16.47",
- "mdast-util-from-markdown": "^2.0.3",
- "mdast-util-gfm": "^3.1.0",
- "mdast-util-math": "^3.0.0",
- "micromark-core-commonmark": "^2.0.3",
- "micromark-extension-gfm": "^3.0.0",
- "micromark-extension-math": "^3.1.0",
- "micromark-factory-space": "^2.0.1",
- "micromark-util-character": "^2.1.1",
- "micromark-util-classify-character": "^2.0.1",
- "micromark-util-sanitize-uri": "^2.0.1",
- "micromark-util-symbol": "^2.0.1",
- "micromark-util-types": "^2.0.2",
- "react": "^18.2.0",
- "react-dom": "^18.2.0",
- "shiki": "^4.3.1"
- },
"devDependencies": {
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "react": "^18.2.0",
+ "clsx": "^2.0.0",
+ "react-dom": "^18.2.0",
+ "anser": "^2.3.5",
+ "katex": "^0.16.47",
+ "micromark-core-commonmark": "^2.0.3",
+ "micromark-util-character": "^2.1.1",
+ "micromark-util-classify-character": "^2.0.1",
+ "micromark-util-symbol": "^2.0.1",
+ "micromark-util-types": "^2.0.2",
+ "shiki": "^4.3.1",
+ "@shikijs/langs": "^4.3.1",
+ "@types/mdast": "^4.0.4",
+ "micromark-factory-space": "^2.0.1",
+ "micromark-extension-math": "^3.1.0",
+ "mdast-util-from-markdown": "^2.0.3",
+ "mdast-util-gfm": "^3.1.0",
+ "mdast-util-math": "^3.0.0",
+ "micromark-extension-gfm": "^3.0.0",
+ "micromark-util-sanitize-uri": "^2.0.1"
},
"files": [
"lib/index.js",
diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json
index 32b616d5a2..2aefdb4b1e 100644
--- a/packages/client/ui-renderer/package.json
+++ b/packages/client/ui-renderer/package.json
@@ -40,9 +40,6 @@
"watch": "tsdown --watch"
},
"license": "MIT",
- "dependencies": {
- "use-sync-external-store": "1.2.0"
- },
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^"
},
@@ -55,7 +52,8 @@
"@types/react-dom": "~18.3.0",
"@types/use-sync-external-store": "^1.5.0",
"react": "^18.2.0",
- "react-dom": "^18.2.0"
+ "react-dom": "^18.2.0",
+ "use-sync-external-store": "1.2.0"
},
"files": [
"lib/index.js",
diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json
index a31d2dad8d..2d33e995f4 100644
--- a/packages/client/ui-settings-general/package.json
+++ b/packages/client/ui-settings-general/package.json
@@ -43,8 +43,7 @@
},
"license": "MIT",
"dependencies": {
- "@deepseek-ai/schemastery": "workspace:^",
- "clsx": "^2.0.0"
+ "@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^"
@@ -64,7 +63,8 @@
"react": "^18.2.0",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
- "@deepseek-ai/dsh-settings": "workspace:^"
+ "@deepseek-ai/dsh-settings": "workspace:^",
+ "clsx": "^2.0.0"
},
"files": [
"lib/index.js",
diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx
index c3daeaaeb6..c9cb8eadad 100644
--- a/packages/client/ui-settings-general/tests/components.client.spec.tsx
+++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx
@@ -13,6 +13,7 @@ import { SettingsDocumentStore } from '../src/client/settings-document-store.ts'
// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
/** Store over a real mirror derived from the same scripted context. */
function derivedDocumentStore(remote: object) {
@@ -32,7 +33,7 @@ const unusedHook = (() => { throw new Error('unused by settings-general componen
type AttentionSnapshot = Parameters[0]>[0]
const noAttention: AttentionSnapshot = new Map()
const useSessionPendingInteraction: TriggerContentProps['useSessionPendingInteraction'] = selector => selector(noAttention)
-const kit = { useSessions: unusedHook, useSessionPendingInteraction, useResource, useWorkspaces: unusedHook }
+const kit = { useSessions: unusedHook, useSessionPendingInteraction, usePanelInfo, useResource, useWorkspaces: unusedHook }
describe('chrome content', () => {
it('TriggerContent renders the icon with the label in the wide column', () => {
diff --git a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx
index 3ebce030b4..bb936d5659 100644
--- a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx
+++ b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx
@@ -10,6 +10,7 @@ import { en } from '../src/client/locales.ts'
// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
afterEach(() => {
cleanup()
@@ -76,7 +77,7 @@ function mount({
const props: SettingsRootComponentProps = {
useSessions,
useSessionPendingInteraction,
- useResource,
+ usePanelInfo, useResource,
useWorkspaces: unusedHook,
wide,
reconnect,
diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml
index b60f05babf..102eeb21d6 100644
--- a/packages/client/ui-settings-models/README.i18n.yaml
+++ b/packages/client/ui-settings-models/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md
-README.md: 64a295e9ebacdd8cbb2b5a0a10b14c1c1dfb24ee
-README.zh.md: a75b6f2300eb59e2961b5ca85799b28b32d0856a
+README.md: 8c852023f6c7c90a4aad67beef8c4a3081e5968e
+README.zh.md: 6b5fd016dbc17bced816a16192c80640f56b9ab2
diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md
index 64a295e9eb..8c852023f6 100644
--- a/packages/client/ui-settings-models/README.md
+++ b/packages/client/ui-settings-models/README.md
@@ -27,6 +27,8 @@ English | [中文](README.zh.md)
Open the Models page from the Settings navigation to see every configured provider as a row. A whole-section provider whose key is not configured anywhere renders as its open setup card instead, but only in the first-run posture and only until the user closes that card. Each card kind owns its own open state, so closing one never discards a draft in another.
+A provider with a stored catalog error remains visible with its diagnostic and edit/delete actions. Add actions are offered only for registered settings namespaces, so an unavailable namespace cannot leave a button that opens no editor. A rejected save leaves the editor open and displays the Host diagnostic.
+
### API keys
The primary field on an editor card is a single **API key** input — the page never asks for an environment-variable name. A typed key stores write-only through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and preserves provider-native authentication (for example the Bedrock credential chain or Vertex ADC). A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing. A successful Apply emits a local accessible status message without echoing secret material.
diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md
index a75b6f2300..6b5fd016db 100644
--- a/packages/client/ui-settings-models/README.zh.md
+++ b/packages/client/ui-settings-models/README.zh.md
@@ -27,6 +27,8 @@ kind: "package-reference"
从设置导航打开 Models 页面,即可看到每个已配置的提供方都有一行。其配置键未在任何位置配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态,且仅持续到用户关闭该卡片为止。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。
+存在已存储目录错误的提供方仍显示诊断以及编辑、删除入口。添加操作只面向已注册的 settings 命名空间,因此不可用的命名空间不会留下无法打开编辑器的按钮。保存被拒绝时,编辑器保持打开并展示 Host 诊断。
+
### API 密钥
编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,从而保留提供方原生认证(例如 Bedrock 凭据链或 Vertex ADC)。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。
diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx
index b99767a364..54b0610888 100644
--- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx
+++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx
@@ -290,7 +290,8 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
// step: whether the user already has a provider to talk to.
const anyUsable = state.rows.some(providerUsable)
const configured = state.rows.filter(row => row.configured)
- const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
+ const configurable = state.rows.filter(row => state.namespaces.has(row.entry.settingsNs))
+ const addable = configurable.filter(row => !row.configured)
const addTarget = adding ? editing : undefined
const addNamespace = addTarget === undefined ? undefined : state.namespaces.get(addTarget.settingsNs)
// The draft's directory row, for the card extension seat. A refresh can drop
@@ -322,11 +323,15 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
const namespace = state.namespaces.get(target.settingsNs)
/* v8 ignore next -- the join marks a row configured only when its namespace resolved */
if (namespace === undefined) return null
+ const error = row.entry.error === undefined
+ ? null
+ :
{row.entry.error}
if (needsSetup(row, anyUsable) && !dismissedSetup.has(row.entry.provider)) {
// First-run posture: the provider exists but has no key — the
// setup card IS its presence on the page, until the user closes it.
return (
+ {error}
{renderProviderEditor({
target,
namespace,
@@ -416,6 +421,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
: null}
+ {error}
{renderSlot(
'settings.models.provider-card',
{ provider: row.entry, configured: row.configured, keyConfigured: keyConfiguredOf(row) },
@@ -504,37 +510,41 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
// and equal-width so they read as siblings and line up with the
// rows above, rather than two pills of different lengths.